major updates

This commit is contained in:
Mark Gregory
2026-06-17 11:26:08 +01:00
parent 3b1fc48a3d
commit c4646d2f5c
10 changed files with 256 additions and 51 deletions
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_comments', 'subject')) {
$table->string('subject', 180)->nullable()->after('user_id');
}
});
DB::table('helpdesk_comments')
->whereNull('subject')
->orWhere('subject', '')
->orderBy('id')
->select(['id', 'parent_id', 'body', 'body_text', 'is_internal'])
->chunkById(100, static function ($comments): void {
foreach ($comments as $comment) {
$plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($comment->body_text ?: $comment->body))) ?: '');
$subject = (bool) $comment->is_internal
? 'Internal note'
: ((int) ($comment->parent_id ?? 0) > 0 ? 'RE: ' : '').Str::limit($plain !== '' ? $plain : 'Response', 120, '');
DB::table('helpdesk_comments')
->where('id', $comment->id)
->update(['subject' => $subject]);
}
});
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
$table->index(['ticket_id', 'subject'], 'helpdesk_comments_ticket_subject_index');
});
}
public function down(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$table->dropIndex('helpdesk_comments_ticket_subject_index');
$table->dropColumn('subject');
}
});
}
};
@@ -32,6 +32,7 @@ final class TicketCommentStoreController extends Controller
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
$validated = $request->validate([
'subject' => ['nullable', 'string', 'max:180'],
'commentBody' => ['nullable', 'string'],
'internal' => ['nullable', 'boolean'],
'parent_id' => [
@@ -48,16 +49,24 @@ final class TicketCommentStoreController extends Controller
abort_if($user === null, 403);
$body = trim((string) ($validated['commentBody'] ?? ''));
$subject = trim((string) ($validated['subject'] ?? ''));
if ($body === '' && filled($validated['canned_response_id'] ?? null)) {
if (filled($validated['canned_response_id'] ?? null)) {
$response = CannedResponse::query()
->where('is_active', true)
->findOrFail((int) $validated['canned_response_id']);
$body = trim($renderer->render($response->body, $ticket, $user));
if ($body === '') {
$body = trim($renderer->render($response->body, $ticket, $user));
}
if ($subject === '') {
$subject = (string) $response->name;
}
}
$body = $this->normaliseHtmlBody($body);
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
validator(['commentBody' => $body], [
'commentBody' => ['required', 'string', 'min:2'],
@@ -73,12 +82,34 @@ final class TicketCommentStoreController extends Controller
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
files: $uploadedFiles,
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
subject: $subject,
);
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
}
/**
* Derive a user-friendly Lotus Notes style response subject.
*/
private function normaliseSubject(string $subject, string $body, bool $isReply): string
{
$subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($subject !== '') {
return Str::limit($subject, 180, '');
}
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: '');
if ($plain === '') {
return $isReply ? 'RE: Response' : 'Response';
}
return ($isReply ? 'RE: ' : '').Str::limit($plain, 120, '');
}
/**
* Keep supported formatting tags but strip scripts and unsafe inline handlers.
*/
+1
View File
@@ -18,6 +18,7 @@ final class Comment extends Model
'ticket_id',
'parent_id',
'user_id',
'subject',
'body',
'body_text',
'is_internal',
@@ -61,7 +61,7 @@
</div>
<div class="p-6">
<div class="prose max-w-none rounded-2xl border border-zinc-200 bg-zinc-50 p-5 text-zinc-900 dark:prose-invert dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
<div class="prose max-w-none rounded-2xl border border-zinc-200 bg-zinc-50 p-6 text-zinc-900 shadow-inner dark:prose-invert dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 sm:min-h-40">
{!! $ticket->body !!}
</div>
</div>
@@ -101,16 +101,19 @@
@endif
<section class="space-y-4">
<details open class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 bg-gradient-to-r from-slate-950 via-slate-900 to-red-950 px-5 py-4 text-white marker:hidden">
<div>
<h2 class="flex items-center gap-2 text-lg font-extrabold">
<i class="fa-solid fa-comments" aria-hidden="true"></i>
Conversation
</h2>
<p class="mt-1 text-sm font-medium text-red-50/85">Lotus Notes-style document responses and responses to responses.</p>
<details class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-conversation-section>
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-5 py-5 text-white marker:hidden">
<div class="flex min-w-0 items-center gap-4">
<span class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-blue-500/25 text-white ring-1 ring-white/20">
<i class="fa-solid fa-comments text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Conversation</p>
<h2 class="mt-1 text-xl font-extrabold tracking-tight">Document responses</h2>
<p class="mt-1 text-sm font-medium text-blue-50/85">Lotus Notes-style document responses and replies.</p>
</div>
</div>
<span class="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-extrabold ring-1 ring-white/20">
<span class="inline-flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-sm font-extrabold ring-1 ring-white/20">
{{ $visibleCommentCount }} item{{ $visibleCommentCount === 1 ? '' : 's' }}
<i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i>
</span>
@@ -119,7 +122,7 @@
<div class="space-y-4 p-5">
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
<label class="sr-only" for="helpdesk-comment-search">Search comments</label>
<input id="helpdesk-comment-search" type="search" placeholder="Search comments, GUID, UNID, author or text..." class="block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-search>
<input id="helpdesk-comment-search" type="search" placeholder="Search comments by subject, author, text or technical reference..." class="block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-search>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="inline-flex items-center justify-center rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700" data-helpdesk-expand-comments>
<i class="fa-solid fa-up-right-and-down-left-from-center mr-2" aria-hidden="true"></i>
@@ -183,7 +186,11 @@
})->all();
@endphp
@php
$cannedResponseNames = $cannedResponses->mapWithKeys(fn ($response) => [(string) $response->id => (string) $response->name])->all();
@endphp
<script type="application/json" id="helpdesk-canned-response-bodies">@json($cannedResponseBodies)</script>
<script type="application/json" id="helpdesk-canned-response-names">@json($cannedResponseNames)</script>
@if ($cannedResponses->isNotEmpty())
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
@@ -213,6 +220,11 @@
$oldCommentBody = strip_tags($oldCommentBody, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>');
@endphp
<div class="mb-4">
<label for="ticket-comment-subject" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="ticket-comment-subject" name="subject" value="{{ old('subject') }}" placeholder="Response subject" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-subject>
</div>
<div>
<label for="ticket-comment-body-editor" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="ticket-comment-body-editor">
@@ -264,8 +276,16 @@
const editor = form?.querySelector('[data-helpdesk-rich-editor]');
const textarea = form?.querySelector('textarea[name="commentBody"]');
const selected = select?.selectedOptions?.[0];
const subjectInput = form?.querySelector('[data-helpdesk-comment-subject]');
const selectedId = String(selected?.value || '');
const cannedResponses = getCannedResponseBodies();
const body = normaliseCannedHtml(cannedResponses[String(selected?.value || '')] || selected?.dataset?.body || '');
const cannedResponseNames = getCannedResponseNames();
const body = normaliseCannedHtml(cannedResponses[selectedId] || selected?.dataset?.body || '');
if (subjectInput && selectedId !== '' && subjectInput.value.trim() === '') {
subjectInput.value = cannedResponseNames[selectedId] || selected?.textContent?.trim() || 'Response';
subjectInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (editor && textarea && body.trim() !== '') {
insertHtmlAtEnd(editor, body);
@@ -409,6 +429,21 @@
}
}
function getCannedResponseNames() {
const source = document.getElementById('helpdesk-canned-response-names');
if (! source) {
return {};
}
try {
return JSON.parse(source.textContent || '{}') || {};
} catch (error) {
console.error('Unable to read canned response names.', error);
return {};
}
}
function insertHtmlAtEnd(editor, html) {
const current = editor.innerHTML.trim();
const cleaned = html.trim();
@@ -526,7 +561,12 @@
<aside class="space-y-6">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
<div class="mb-5 border-b border-blue-200 pb-3 dark:border-blue-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-white shadow-sm"><i class="fa-solid fa-file-lines" aria-hidden="true"></i></span>
Ticket details
</h2>
</div>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
@@ -538,7 +578,12 @@
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2>
<div class="mb-5 border-b border-red-200 pb-3 dark:border-red-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-red-600 text-white shadow-sm"><i class="fa-solid fa-clock" aria-hidden="true"></i></span>
SLA metrics
</h2>
</div>
<div class="grid gap-3 text-sm">
<div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
<div class="text-xs font-semibold uppercase tracking-wide">First response</div>
@@ -558,7 +603,12 @@
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2>
<div class="mb-5 border-b border-emerald-200 pb-3 dark:border-emerald-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm"><i class="fa-solid fa-user" aria-hidden="true"></i></span>
Requester profile
</h2>
</div>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div>
@@ -10,8 +10,18 @@
$currentTicket = $ticket ?? $comment->ticket ?? null;
$replyCount = $commentReplies->count();
$isInternal = (bool) $comment->is_internal;
$borderClass = $isInternal ? 'border-amber-300 dark:border-amber-700' : 'border-red-200 dark:border-red-900/70';
$railClass = $isInternal ? 'bg-amber-500' : 'bg-red-500';
$borderClass = $isInternal ? 'border-purple-400/70 dark:border-purple-600/70' : 'border-zinc-200 dark:border-zinc-700';
$railClass = $isInternal ? 'bg-purple-500' : ((int) $level % 3 === 0 ? 'bg-red-500' : (((int) $level % 3 === 1) ? 'bg-blue-500' : 'bg-emerald-500'));
$commentSubject = trim((string) ($comment->subject ?? ''));
if ($commentSubject === '') {
$plainSubject = trim((string) ($comment->body_text ?? strip_tags((string) $comment->body)));
$commentSubject = Str::limit($plainSubject !== '' ? $plainSubject : 'Response', 72);
}
if ($isInternal && ! Str::startsWith(Str::lower($commentSubject), 'internal')) {
$commentSubject = 'Internal note';
}
$renderedBody = html_entity_decode((string) $comment->body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
while (str_contains($renderedBody, '&lt;') || str_contains($renderedBody, '&gt;') || str_contains($renderedBody, '&amp;lt;')) {
@@ -49,32 +59,34 @@
@endphp
@if (! $isInternal || $canSeeInternalNotes)
<details class="group rounded-2xl border {{ $borderClass }} bg-white shadow-sm transition dark:bg-zinc-900" data-helpdesk-comment-card data-helpdesk-comment-text="{{ e(Str::lower(strip_tags((string) $comment->body).' '.($comment->body_text ?? '').' '.($commentUser?->name ?? '').' '.($comment->unid ?? '').' '.($comment->guid ?? ''))) }}" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<details class="group rounded-2xl border {{ $borderClass }} bg-white shadow-sm transition dark:bg-zinc-900" data-helpdesk-comment-card data-helpdesk-comment-text="{{ e(Str::lower($commentSubject.' '.strip_tags((string) $comment->body).' '.($comment->body_text ?? '').' '.($commentUser?->name ?? '').' '.($comment->unid ?? '').' '.($comment->guid ?? ''))) }}" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<summary class="flex cursor-pointer list-none items-start gap-3 rounded-2xl p-4 marker:hidden">
<span class="mt-1 inline-flex h-12 w-2 shrink-0 rounded-full {{ $railClass }}" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-zinc-900 dark:text-zinc-100">{{ $commentUser?->name ?? 'Unknown user' }}</span>
<span class="text-xs font-semibold text-zinc-500">{{ $comment->created_at?->format('d M Y H:i') }}</span>
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-[11px] font-extrabold text-blue-800 dark:bg-blue-950 dark:text-blue-100">{{ $replyCount }} response{{ $replyCount === 1 ? '' : 's' }}</span>
@if ($commentAttachments->isNotEmpty())
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-extrabold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
<i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }}
</span>
@endif
@if ($isInternal)
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-extrabold text-amber-800 dark:bg-amber-950 dark:text-amber-100">MPBA internal</span>
@endif
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-mono text-zinc-400">
@if (! blank($comment->unid))
<span>UNID {{ Str::limit((string) $comment->unid, 16, '') }}</span>
@endif
@if (! blank($comment->guid))
<span>GUID {{ Str::limit((string) $comment->guid, 18, '') }}</span>
@endif
<span class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-zinc-100 text-zinc-700 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700">
<i class="fa-solid {{ $isInternal ? 'fa-note-sticky' : 'fa-envelope' }}" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-base font-extrabold text-zinc-900 dark:text-zinc-100">{{ $commentSubject }}</span>
<span class="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-extrabold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">{{ $replyCount }} {{ $replyCount === 1 ? 'reply' : 'replies' }}</span>
@if ($commentAttachments->isNotEmpty())
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-extrabold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
<i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }}
</span>
@endif
@if ($isInternal)
<span class="rounded-full bg-red-600 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-white">Internal</span>
@endif
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
<span>{{ $commentUser?->name ?? 'Unknown user' }}</span>
<span aria-hidden="true">•</span>
<span>{{ $comment->created_at?->format('d M Y H:i') }}</span>
</div>
</div>
</div>
</div>
@@ -116,6 +128,10 @@
<form id="{{ $replyFormId }}" method="POST" action="{{ route('helpdesk.tickets.comments.store', $currentTicket) }}" enctype="multipart/form-data" class="mt-4 hidden rounded-xl border border-red-200 bg-red-50/60 p-4 dark:border-red-900 dark:bg-red-950/20" data-helpdesk-comment-form>
@csrf
<input type="hidden" name="parent_id" value="{{ $comment->id }}">
<div class="mb-3">
<label for="reply-subject-{{ $comment->id }}" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="reply-subject-{{ $comment->id }}" name="subject" value="{{ old('subject', 'RE: '.$commentSubject) }}" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
</div>
<label for="reply-body-{{ $comment->id }}" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Response to response</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="reply-body-{{ $comment->id }}">
<button type="button" data-helpdesk-tag="strong" class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
+11 -5
View File
@@ -102,9 +102,9 @@ final class TicketService
*
* @param array<int, UploadedFile> $files
*/
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null, ?string $subject = null): Comment
{
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId): Comment {
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment {
$parent = $parentId === null
? null
: Comment::query()
@@ -117,8 +117,7 @@ final class TicketService
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
$plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
/** @var Comment $comment */
$comment = $ticket->comments()->create([
$payload = [
'parent_id' => $parentId,
'user_id' => $user->id,
'body' => $body,
@@ -132,7 +131,14 @@ final class TicketService
'sync_sequence' => 1,
'sync_hash' => hash('sha256', $body),
'source_system' => 'wms-helpdesk',
]);
];
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
}
/** @var Comment $comment */
$comment = $ticket->comments()->create($payload);
$ticket->forceFill(['last_activity_at' => now()])->save();
if (! $internal) {
@@ -79,11 +79,17 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->waitForText('Hello Response Browser User', 10)
->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello')
->assertInputValue('subject', 'Browser canned response')
->assertInputValue('commentBody', '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>')
->press('@post-comment')
->waitForText('Hello Response Browser User', 10)
->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello');
->waitFor('@helpdesk-ticket-show', 10);
$this->openHelpdeskConversation($browser);
$browser->waitForText('Browser canned response', 10)
->assertSee('Browser canned response')
->assertDontSee('UNID')
->assertDontSee('GUID');
$comment = Comment::query()
->where('ticket_id', $ticket->id)
@@ -91,17 +97,24 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->where('body', 'like', '%Hello Response Browser User%')
->firstOrFail();
$this->openHelpdeskConversation($browser);
$browser->press('@reply-to-comment-'.$comment->id)
->waitFor('@reply-body-'.$comment->id)
->type('@reply-body-'.$comment->id, 'Nested browser response')
->press('@post-reply-'.$comment->id)
->waitForText('Nested browser response', 10)
->assertSee('Nested browser response');
->waitFor('@helpdesk-ticket-show', 10);
$this->openHelpdeskConversation($browser);
$browser->waitForText('RE: Browser canned response', 10)
->assertSee('RE: Browser canned response');
});
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'subject' => 'Browser canned response',
'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>',
]);
@@ -114,10 +127,30 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => $parentId,
'subject' => 'RE: Browser canned response',
'body' => 'Nested browser response',
]);
}
private function openHelpdeskConversation(Browser $browser): void
{
$browser->script(<<<JS
const conversation = document.querySelector('[data-helpdesk-conversation-section]');
if (conversation) {
conversation.open = true;
}
document.querySelectorAll('[data-helpdesk-comment-card]').forEach((card) => {
card.open = true;
});
const update = window.updateCommentToggleButtons;
if (typeof update === 'function') {
update();
}
JS);
$browser->pause(150);
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
{
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
@@ -74,6 +74,7 @@ final class TicketConversationTest extends TestCase
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'subject' => 'Searchable response subject',
'commentBody' => '<p><strong>Searchable response</strong></p>',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
@@ -83,6 +84,7 @@ final class TicketConversationTest extends TestCase
self::assertNotEmpty($comment->guid);
self::assertNotEmpty($comment->unid);
self::assertSame($comment->unid, $comment->root_unid);
self::assertSame('Searchable response subject', $comment->subject);
self::assertSame('Searchable response', $comment->body_text);
self::assertSame(1, $comment->sync_sequence);
}
@@ -97,6 +99,7 @@ final class TicketConversationTest extends TestCase
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'parent_id' => $parent->id,
'subject' => 'RE: Parent response',
'commentBody' => '<p>Child response</p>',
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
@@ -105,6 +108,7 @@ final class TicketConversationTest extends TestCase
self::assertSame($parent->unid, $reply->parent_unid);
self::assertSame($parent->root_unid, $reply->root_unid);
self::assertSame('RE: Parent response', $reply->subject);
}
public function test_agent_can_update_status_to_closed_and_priority(): void
+11
View File
@@ -225,3 +225,14 @@ versions:
- Updated TicketService comment creation to populate Lotus-style replication identity metadata for document responses and nested responses.
- Updated Helpdesk conversation tests to cover POST comment storage, nested reply identity metadata and searchable comment text.
- Kept module.json version in sync with changelog.yaml.
- version: 1.7.0
date: 2026-06-17
type: changed
summary: "Polished Lotus Notes-style ticket document and conversation headers, fully collapsed conversation default, subject-led comment cards, and hidden replication identifiers."
changes:
- "Conversation panel now starts fully collapsed and only reveals comment cards when opened."
- "Comment cards now show a professional subject header with author/date metadata and no visible GUID/UNID."
- "Comment subjects are stored for public responses, canned responses, and nested replies while GUID/UNID remain searchable."
- "Ticket details, SLA metrics, requester profile, ticket document, attachments, and conversation headers received consistent professional styling."
- "Browser and feature tests were updated for collapsed conversations, comment subjects, and hidden replication identifiers."
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.3.13"
"version": "1.7.0"
}