feat: search KB before submitting ticket (frontend + backend)

Both frontend and admin ticket creation now show a KB search step
first. User types their issue, Smart Search (com_finder) returns
matching articles. If nothing helps, they click through to the
ticket form with the search query pre-filled as the subject.

Frontend (/support/submit-ticket):
- Search input with live results from #__finder_links
- Article links open in new tab
- "Submit Anyway" shows the ticket form
- Falls back to direct form if Smart Search has no indexed content

Admin (New Ticket modal):
- Same KB search step before the form
- Results as clickable list-group items
- Modal resets on close

Also:
- Added searchKb controller task (both frontend + admin)
- Added "Submit a Ticket" child menu item under Support on install
- Fixed innerHTML → safe DOM methods for search results

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 18:46:12 -05:00
parent b37120341f
commit 4a18318cb9
5 changed files with 528 additions and 38 deletions
@@ -226,6 +226,48 @@ class DisplayController extends BaseController
));
}
// ==================================================================
// KB Search
// ==================================================================
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([$db->quoteName('l.title'), $db->quoteName('l.url'), $db->quoteName('l.description')])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
// ==================================================================
// Importers
// ==================================================================
@@ -135,11 +135,25 @@ $priorityBadge = [
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title">New Ticket</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
<div class="modal-body">
<div class="modal-body">
<!-- KB Search step -->
<div id="modal-kb-step">
<label class="form-label fw-bold">What's the issue?</label>
<div class="input-group mb-3">
<input type="text" id="modal-kb-search" class="form-control" placeholder="Describe your issue to search for existing answers...">
<button type="button" class="btn btn-outline-primary" id="modal-kb-btn"><span class="icon-search"></span></button>
</div>
<div id="modal-kb-results" class="list-group mb-3 d-none"></div>
<button type="button" class="btn btn-primary" id="modal-show-form">
<span class="icon-plus"></span> Create Ticket
</button>
</div>
<!-- Ticket form step (hidden initially) -->
<form id="modal-ticket-form" class="d-none" method="post" action="<?php echo Route::_('index.php?option=com_mokowaas&task=display.createTicket&format=json'); ?>">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" name="subject" class="form-control" required>
<input type="text" name="subject" id="modal-subject" class="form-control" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
@@ -165,28 +179,93 @@ $priorityBadge = [
<label class="form-label">Description</label>
<textarea name="body" class="form-control" rows="6" required></textarea>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="<?php echo $token; ?>" value="1">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
</div>
</form>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><span class="icon-plus"></span> Create Ticket</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.querySelector('#newTicketModal form').addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
// Modal KB search
var modalSearch = document.getElementById('modal-kb-search');
var modalSearchBtn = document.getElementById('modal-kb-btn');
var modalResults = document.getElementById('modal-kb-results');
var modalShowForm = document.getElementById('modal-show-form');
var modalKbStep = document.getElementById('modal-kb-step');
var modalForm = document.getElementById('modal-ticket-form');
var modalSubject = document.getElementById('modal-subject');
function modalDoSearch() {
var q = modalSearch.value.trim();
if (q.length < 3) return;
fetch('<?php echo Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json'); ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
}).then(function(r){return r.json()}).then(function(d) {
modalResults.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
modalResults.appendChild(a);
});
modalResults.classList.remove('d-none');
} else {
modalResults.classList.add('d-none');
}
});
}
if (modalSearchBtn) modalSearchBtn.addEventListener('click', modalDoSearch);
if (modalSearch) modalSearch.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); modalDoSearch(); } });
// Show ticket form
if (modalShowForm) {
modalShowForm.addEventListener('click', function() {
modalKbStep.classList.add('d-none');
modalForm.classList.remove('d-none');
if (modalSearch.value && !modalSubject.value) modalSubject.value = modalSearch.value;
modalSubject.focus();
});
}
// Submit ticket from modal
if (modalForm) {
modalForm.addEventListener('submit', function(e) {
e.preventDefault();
var form = this;
var fd = new FormData(form);
fetch(form.action, {method:'POST', body:fd, headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(function(r){return r.json()})
.then(function(d){
if (d.success) { location.href = 'index.php?option=com_mokowaas&view=ticket&id=' + d.id; }
else { Joomla.renderMessages({error:[d.message]}); }
});
});
}
// Reset modal on close
document.getElementById('newTicketModal').addEventListener('hidden.bs.modal', function() {
modalKbStep.classList.remove('d-none');
modalForm.classList.add('d-none');
modalResults.classList.add('d-none');
modalSearch.value = '';
modalForm.reset();
});
// ATS Import
@@ -27,7 +27,10 @@ class DisplayController extends BaseController
if ($user->guest)
{
Factory::getApplication()->enqueueMessage('Please log in to access the support portal.', 'warning');
Factory::getApplication()->redirect(Route::_('index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'), false));
Factory::getApplication()->redirect(Route::_(
'index.php?option=com_users&view=login&return=' . base64_encode('index.php?option=com_mokowaas&view=tickets'),
false
));
return;
}
@@ -36,7 +39,7 @@ class DisplayController extends BaseController
}
/**
* Submit a new ticket from frontend.
* Submit a new ticket.
*/
public function submitTicket()
{
@@ -49,55 +52,181 @@ class DisplayController extends BaseController
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
}
$app = Factory::getApplication();
$input = $app->getInput();
$input = Factory::getApplication()->getInput();
// Use admin model for ticket creation
// Use admin TicketsModel
$model = $this->getModel('Tickets', 'Administrator');
$result = $model->createTicket([
$this->jsonResponse($model->createTicket([
'subject' => $input->getString('subject', ''),
'body' => $input->getRaw('body', ''),
'priority' => $input->getString('priority', 'normal'),
'category_id' => $input->getInt('category_id', 0),
]);
$this->jsonResponse($result);
]));
}
/**
* Submit a reply from frontend.
* Submit a reply.
*/
public function submitReply()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
$user = Factory::getApplication()->getIdentity();
$input = Factory::getApplication()->getInput();
if ($user->guest)
{
$this->jsonResponse(['success' => false, 'message' => 'Please log in.']);
}
$input = Factory::getApplication()->getInput();
$model = $this->getModel('Tickets', 'Administrator');
// Verify user owns this ticket
$ticketId = $input->getInt('ticket_id', 0);
$model = $this->getModel('Tickets', 'Administrator');
$ticket = $model->getTicket($ticketId);
if (!$ticket || (int) $ticket->created_by !== $user->id)
if (!$ticket)
{
$this->jsonResponse(['success' => false, 'message' => 'Ticket not found.']);
}
$result = $model->addReply(
// Customers can only reply to their own tickets; staff can reply to any
if ((int) $ticket->created_by !== $user->id && !$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
// Staff replies from frontend are not internal notes
$this->jsonResponse($model->addReply(
$ticketId,
$input->getRaw('body', ''),
false
);
));
}
$this->jsonResponse($result);
/**
* Update ticket status (staff/manager only from frontend).
*/
public function updateStatus()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$this->isStaff($user))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
$input = Factory::getApplication()->getInput();
$model = $this->getModel('Tickets', 'Administrator');
$this->jsonResponse($model->updateStatus(
$input->getInt('ticket_id', 0),
$input->getString('status', '')
));
}
/**
* Assign a ticket (manager only from frontend).
*/
public function assignTicket()
{
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('mokowaas.tickets.assign', 'com_mokowaas'))
{
$this->jsonResponse(['success' => false, 'message' => 'Access denied.']);
}
$input = Factory::getApplication()->getInput();
$ticketId = $input->getInt('ticket_id', 0);
$assignTo = $input->getInt('assigned_to', 0);
try
{
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->update($db->quoteName('#__mokowaas_tickets'))
->set($db->quoteName('assigned_to') . ' = ' . ($assignTo ?: 'NULL'))
->set($db->quoteName('modified') . ' = ' . $db->quote(Factory::getDate()->toSql()))
->where($db->quoteName('id') . ' = ' . $ticketId)
)->execute();
$this->jsonResponse(['success' => true, 'message' => 'Ticket assigned.']);
}
catch (\Throwable $e)
{
$this->jsonResponse(['success' => false, 'message' => $e->getMessage()]);
}
}
/**
* Check if user is support staff (can manage tickets beyond their own).
*/
private function isStaff($user): bool
{
if ($user->guest)
{
return false;
}
// Super admins always staff
if ($user->authorise('core.admin'))
{
return true;
}
// Anyone with mokowaas.tickets ACL on the component is staff
return $user->authorise('mokowaas.tickets', 'com_mokowaas');
}
/**
* Search KB articles via Smart Search (com_finder).
*/
public function searchKb()
{
$query = Factory::getApplication()->getInput()->getString('q', '');
if (strlen($query) < 3)
{
$this->jsonResponse(['results' => []]);
}
try
{
$db = Factory::getDbo();
$escaped = $db->quote('%' . $db->escape($query, true) . '%');
$results = $db->setQuery(
$db->getQuery(true)
->select([
$db->quoteName('l.link_id'),
$db->quoteName('l.title'),
$db->quoteName('l.url'),
$db->quoteName('l.description'),
])
->from($db->quoteName('#__finder_links', 'l'))
->where($db->quoteName('l.published') . ' = 1')
->where('(' . $db->quoteName('l.title') . ' LIKE ' . $escaped
. ' OR ' . $db->quoteName('l.description') . ' LIKE ' . $escaped . ')')
->order($db->quoteName('l.title') . ' ASC')
->setLimit(8)
)->loadObjectList() ?: [];
foreach ($results as $r)
{
$r->description = mb_substr(strip_tags($r->description ?? ''), 0, 150);
}
$this->jsonResponse(['results' => $results]);
}
catch (\Throwable $e)
{
$this->jsonResponse(['results' => []]);
}
}
private function jsonResponse(array $data): void
@@ -0,0 +1,204 @@
<?php
/**
* Submit a Ticket layout — search KB first, then submit form.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$categories = $this->categories;
$token = Session::getFormToken();
$searchUrl = Route::_('index.php?option=com_mokowaas&task=display.searchKb&format=json');
$submitUrl = Route::_('index.php?option=com_mokowaas&task=display.submitTicket&format=json');
$ticketUrl = Route::_('index.php?option=com_mokowaas&view=ticket&id=');
$ticketsUrl = Route::_('index.php?option=com_mokowaas&view=tickets');
// Check if Smart Search has indexed content
$finderEnabled = false;
try {
$db = \Joomla\CMS\Factory::getContainer()->get('Joomla\Database\DatabaseInterface');
$db->setQuery('SELECT COUNT(*) FROM #__finder_links WHERE published = 1');
$finderEnabled = (int) $db->loadResult() > 0;
} catch (\Throwable $e) {}
?>
<div class="mokowaas-portal">
<h2>Submit a Support Request</h2>
<?php if ($finderEnabled): ?>
<!-- Step 1: Search -->
<div id="step-search" class="mb-4">
<p class="text-muted">Before submitting, let's see if we already have an answer for you.</p>
<div class="card">
<div class="card-body">
<label class="form-label fw-bold" for="kb-search">Describe your issue</label>
<div class="input-group input-group-lg">
<input type="text" id="kb-search" class="form-control" placeholder="e.g. how do I reset my password?" autofocus>
<button type="button" class="btn btn-primary" id="kb-search-btn">
<span class="icon-search"></span> Search
</button>
</div>
</div>
</div>
<!-- Search results -->
<div id="kb-results" class="mt-3 d-none">
<h5>Related Articles</h5>
<div id="kb-results-list" class="list-group mb-3"></div>
<p class="text-muted">Didn't find what you need?</p>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-primary" id="btn-show-form">
<span class="icon-plus"></span> Submit a Ticket Anyway
</button>
</div>
</div>
<?php endif; ?>
<!-- Step 2: Ticket Form -->
<div id="step-form" class="<?php echo $finderEnabled ? 'd-none' : ''; ?>">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Ticket Details</h5>
<form id="submitTicketForm">
<div class="mb-3">
<label class="form-label" for="ticket-subject">Subject <span class="text-danger">*</span></label>
<input type="text" id="ticket-subject" name="subject" class="form-control" required placeholder="Brief description of your issue">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label" for="ticket-category">Category</label>
<select id="ticket-category" name="category_id" class="form-select">
<option value="">Select a category</option>
<?php foreach ($categories as $cat): ?>
<option value="<?php echo $cat->id; ?>"><?php echo htmlspecialchars($cat->title); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ticket-priority">Priority</label>
<select id="ticket-priority" name="priority" class="form-select">
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="ticket-body">Description <span class="text-danger">*</span></label>
<textarea id="ticket-body" name="body" class="form-control" rows="8" required placeholder="Please describe your issue in detail."></textarea>
</div>
<input type="hidden" name="<?php echo $token; ?>" value="1">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<span class="icon-paper-plane"></span> Submit Ticket
</button>
<a href="<?php echo $ticketsUrl; ?>" class="btn btn-outline-secondary btn-lg">
My Tickets
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('kb-search');
var searchBtn = document.getElementById('kb-search-btn');
var resultBox = document.getElementById('kb-results');
var resultList = document.getElementById('kb-results-list');
var showFormBtn = document.getElementById('btn-show-form');
var stepSearch = document.getElementById('step-search');
var stepForm = document.getElementById('step-form');
var subjectField = document.getElementById('ticket-subject');
// Search
function doSearch() {
var q = (searchInput ? searchInput.value.trim() : '');
if (q.length < 3) return;
fetch('<?php echo $searchUrl; ?>&q=' + encodeURIComponent(q), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
resultList.textContent = '';
if (d.results && d.results.length > 0) {
d.results.forEach(function(item) {
var a = document.createElement('a');
a.href = item.url;
a.target = '_blank';
a.className = 'list-group-item list-group-item-action';
var strong = document.createElement('strong');
strong.textContent = item.title;
a.appendChild(strong);
if (item.description) {
a.appendChild(document.createElement('br'));
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = item.description;
a.appendChild(small);
}
resultList.appendChild(a);
});
resultBox.classList.remove('d-none');
} else {
resultBox.classList.add('d-none');
}
// Always show the "submit anyway" button after search
if (showFormBtn) showFormBtn.classList.remove('d-none');
});
}
if (searchBtn) searchBtn.addEventListener('click', doSearch);
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); }
});
}
// Show form and prefill subject from search query
if (showFormBtn) {
showFormBtn.addEventListener('click', function() {
if (stepSearch) stepSearch.classList.add('d-none');
if (stepForm) stepForm.classList.remove('d-none');
if (searchInput && subjectField && !subjectField.value) {
subjectField.value = searchInput.value;
}
subjectField.focus();
});
}
// Submit ticket
var form = document.getElementById('submitTicketForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = ' Submitting...';
var fd = new FormData(form);
fetch('<?php echo $submitUrl; ?>', {
method: 'POST', body: fd, headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success && d.id) {
window.location.href = '<?php echo $ticketUrl; ?>' + d.id;
} else {
alert(d.message || 'Failed.');
btn.disabled = false;
btn.textContent = ' Submit Ticket';
}
})
.catch(function() { alert('Network error.'); btn.disabled = false; });
});
}
});
</script>
+36
View File
@@ -771,6 +771,42 @@ class Pkg_MokowaasInstallerScript
];
$db->insertObject('#__menu', $item, 'id');
$supportId = (int) $item->id;
// Create "Submit a Ticket" child menu item
if ($supportId)
{
$db->setQuery('SELECT MAX(rgt) FROM #__menu WHERE client_id = 0');
$maxRgt2 = (int) $db->loadResult();
$child = (object) [
'menutype' => 'mainmenu',
'title' => 'Submit a Ticket',
'alias' => 'submit-ticket',
'note' => '',
'path' => 'support/submit-ticket',
'link' => 'index.php?option=com_mokowaas&view=tickets&layout=submit',
'type' => 'component',
'published' => 1,
'parent_id' => $supportId,
'level' => 2,
'component_id' => $componentId,
'checked_out' => null,
'checked_out_time' => null,
'browserNav' => 0,
'access' => 2,
'img' => '',
'template_style_id' => 0,
'params' => '{}',
'lft' => $maxRgt2 + 1,
'rgt' => $maxRgt2 + 2,
'home' => 0,
'language' => '*',
'client_id' => 0,
];
$db->insertObject('#__menu', $child, 'id');
}
}
catch (\Throwable $e)
{