workign comments strucuture
This commit is contained in:
@@ -4,54 +4,76 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Modules\Helpdesk\Http\Controllers;
|
namespace Modules\Helpdesk\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\Helpdesk\Models\CannedResponse;
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
use Modules\Helpdesk\Models\Ticket;
|
||||||
|
use Modules\Helpdesk\Services\CannedResponseRenderer;
|
||||||
use Modules\Helpdesk\Services\TicketService;
|
use Modules\Helpdesk\Services\TicketService;
|
||||||
|
|
||||||
final class TicketCommentStoreController extends Controller
|
final class TicketCommentStoreController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Store a ticket comment using a normal HTTP POST request.
|
* Store a ticket conversation comment or nested reply using a normal HTTP POST.
|
||||||
|
*
|
||||||
|
* This keeps the conversation workflow reliable when rich-editor, Flux, or
|
||||||
|
* Livewire JavaScript event handling does not invoke the Livewire method.
|
||||||
*/
|
*/
|
||||||
public function __invoke(Request $request, Ticket $ticket, TicketService $tickets): RedirectResponse
|
public function __invoke(Request $request, Ticket $ticket, TicketService $tickets, CannedResponseRenderer $renderer): RedirectResponse
|
||||||
{
|
{
|
||||||
Gate::authorize('comment', $ticket);
|
Gate::authorize('comment', $ticket);
|
||||||
|
|
||||||
|
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
|
||||||
|
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'commentBody' => ['required', 'string', 'min:2'],
|
'commentBody' => ['nullable', 'string'],
|
||||||
'attachments' => ['nullable', 'array'],
|
'internal' => ['nullable', 'boolean'],
|
||||||
'attachments.*' => [
|
'parent_id' => [
|
||||||
'file',
|
'nullable',
|
||||||
'max:'.config('helpdesk.max_upload_kb', 10240),
|
'integer',
|
||||||
'mimes:'.implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])),
|
Rule::exists('helpdesk_comments', 'id')->where('ticket_id', $ticket->id),
|
||||||
],
|
],
|
||||||
|
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
|
||||||
|
'attachments' => ['nullable', 'array'],
|
||||||
|
'attachments.*' => ['file', 'max:'.$maxUploadKilobytes, 'mimes:'.implode(',', $allowedMimes)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
abort_if($user === null, 403);
|
||||||
|
|
||||||
if ($user === null) {
|
$body = trim((string) ($validated['commentBody'] ?? ''));
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'commentBody' => 'You must be signed in to comment on this ticket.',
|
if ($body === '' && 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
$internal = $request->user()?->can('update', $ticket) === true && $request->boolean('internal');
|
validator(['commentBody' => $body], [
|
||||||
|
'commentBody' => ['required', 'string', 'min:2'],
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$uploadedFiles = $request->file('attachments', []);
|
||||||
|
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
|
||||||
|
|
||||||
$tickets->comment(
|
$tickets->comment(
|
||||||
ticket: $ticket,
|
ticket: $ticket,
|
||||||
user: $user,
|
user: $user,
|
||||||
body: (string) $validated['commentBody'],
|
body: $body,
|
||||||
internal: $internal,
|
internal: (bool) ($validated['internal'] ?? false),
|
||||||
files: $request->file('attachments', []),
|
files: $uploadedFiles,
|
||||||
|
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('helpdesk.tickets.show', $ticket)
|
->route('helpdesk.tickets.show', $ticket)
|
||||||
->with('ticket-comment-added', 'Reply posted.');
|
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ final class SettingsManager extends Component
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->section = $this->validSection((string) request()->query('section', $this->section));
|
||||||
$this->loadDefaultHelpdeskCompany();
|
$this->loadDefaultHelpdeskCompany();
|
||||||
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||||
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_default' => false];
|
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_default' => false];
|
||||||
@@ -92,6 +93,32 @@ final class SettingsManager extends Component
|
|||||||
$this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
$this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep settings section navigation constrained to known panels.
|
||||||
|
*/
|
||||||
|
private function validSection(string $section): string
|
||||||
|
{
|
||||||
|
$allowed = [
|
||||||
|
'statuses',
|
||||||
|
'priorities',
|
||||||
|
'categories',
|
||||||
|
'teams',
|
||||||
|
'projects',
|
||||||
|
'boards',
|
||||||
|
'git',
|
||||||
|
'workflow',
|
||||||
|
'canned',
|
||||||
|
'sla',
|
||||||
|
'automation',
|
||||||
|
'checklists',
|
||||||
|
'tags',
|
||||||
|
'products',
|
||||||
|
'defaults',
|
||||||
|
];
|
||||||
|
|
||||||
|
return in_array($section, $allowed, true) ? $section : 'statuses';
|
||||||
|
}
|
||||||
|
|
||||||
public function saveStatus(): void
|
public function saveStatus(): void
|
||||||
{
|
{
|
||||||
$data = $this->validate([
|
$data = $this->validate([
|
||||||
|
|||||||
@@ -231,10 +231,10 @@
|
|||||||
? 'border-color: '.$sectionPalette['border'].'; background-color: '.$sectionPalette['active'].'; color: #ffffff; box-shadow: 0 16px 36px rgba(0,0,0,.32);'
|
? 'border-color: '.$sectionPalette['border'].'; background-color: '.$sectionPalette['active'].'; color: #ffffff; box-shadow: 0 16px 36px rgba(0,0,0,.32);'
|
||||||
: 'border-color: '.$sectionPalette['border'].'; background-color: '.$sectionPalette['bg'].'; color: #ffffff; box-shadow: 0 10px 24px rgba(0,0,0,.20);';
|
: 'border-color: '.$sectionPalette['border'].'; background-color: '.$sectionPalette['bg'].'; color: #ffffff; box-shadow: 0 10px 24px rgba(0,0,0,.20);';
|
||||||
@endphp
|
@endphp
|
||||||
<button type="button" dusk="helpdesk-settings-section-{{ $key }}" wire:click="$set('section', '{{ $key }}')" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5" style="{{ $sectionStyle }}">
|
<a href="{{ route('helpdesk.settings', ['section' => $key]) }}" dusk="helpdesk-settings-section-{{ $key }}" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5" style="{{ $sectionStyle }}">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
|
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
|
||||||
<div class="mt-1 text-2xl font-semibold">{{ $item['count'] }}</div>
|
<div class="mt-1 text-2xl font-semibold">{{ $item['count'] }}</div>
|
||||||
</button>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,13 @@
|
|||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
|
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
|
||||||
|
|
||||||
@if ($ticket->comments->isNotEmpty())
|
@if ($ticket->comments->isNotEmpty())
|
||||||
@include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0])
|
@include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0, 'ticket' => $ticket])
|
||||||
@else
|
@else
|
||||||
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No responses yet.</div>
|
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No responses yet.</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('helpdesk.tickets.comments.store', $ticket) }}" enctype="multipart/form-data" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
<form method="POST" action="{{ route('helpdesk.tickets.comments.store', $ticket) }}" enctype="multipart/form-data" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-comment-form>
|
||||||
@csrf
|
@csrf
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
||||||
@if (session('ticket-comment-added'))
|
@if (session('ticket-comment-added'))
|
||||||
@@ -88,26 +88,42 @@
|
|||||||
{{ session('ticket-comment-added') }}
|
{{ session('ticket-comment-added') }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm font-bold text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-100">
|
||||||
|
{{ $errors->first() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($cannedResponses->isNotEmpty())
|
@if ($cannedResponses->isNotEmpty())
|
||||||
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
|
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
|
||||||
<flux:select wire:model="canned_response_id" label="Canned response">
|
<div>
|
||||||
<option value="">Choose a saved response...</option>
|
<label for="ticket-canned-response" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Canned response</label>
|
||||||
@foreach ($cannedResponses as $response)
|
<select id="ticket-canned-response" name="canned_response_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 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-canned-response-select>
|
||||||
<option value="{{ $response->id }}">{{ $response->name }}</option>
|
<option value="">Choose a saved response...</option>
|
||||||
@endforeach
|
@foreach ($cannedResponses as $response)
|
||||||
</flux:select>
|
<option value="{{ $response->id }}" data-body="{{ e($response->body) }}">{{ $response->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<flux:button type="button" wire:click="applyCannedResponse" variant="ghost" icon="document-duplicate" class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Insert response</flux:button>
|
<button type="button" dusk="insert-canned-response" class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70" data-insert-canned-response>
|
||||||
|
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
|
||||||
|
Insert response
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment" rows="5" placeholder="Write a professional response..." />
|
<div>
|
||||||
|
<label for="ticket-comment-body" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label>
|
||||||
|
<textarea id="ticket-comment-body" dusk="ticket-comment-editor" name="commentBody" rows="5" placeholder="Write a professional response..." required class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm 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">{{ old('commentBody') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
@can('update', $ticket)
|
@can('update', $ticket)
|
||||||
<div class="mt-4">
|
<label class="mt-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
|
||||||
<flux:checkbox wire:model="internal" label="Internal note only" />
|
<input type="checkbox" name="internal" value="1" @checked(old('internal')) class="rounded border-zinc-300">
|
||||||
</div>
|
Internal note only
|
||||||
|
</label>
|
||||||
@endcan
|
@endcan
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -119,10 +135,52 @@
|
|||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<button dusk="post-comment" type="submit" class="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
|
<button dusk="post-comment" type="submit" class="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
|
||||||
<i class="fa-solid fa-comments mr-2" aria-hidden="true"></i>
|
<i class="fa-solid fa-comments mr-2" aria-hidden="true"></i>
|
||||||
<span>Post reply</span>
|
Post reply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
const insertButton = event.target.closest('[data-insert-canned-response]');
|
||||||
|
|
||||||
|
if (insertButton) {
|
||||||
|
const form = insertButton.closest('[data-helpdesk-comment-form]');
|
||||||
|
const select = form?.querySelector('[data-canned-response-select]');
|
||||||
|
const textarea = form?.querySelector('textarea[name="commentBody"]');
|
||||||
|
const selected = select?.selectedOptions?.[0];
|
||||||
|
const body = selected?.dataset?.body || '';
|
||||||
|
|
||||||
|
if (textarea && body.trim() !== '') {
|
||||||
|
textarea.value = textarea.value.trim() === ''
|
||||||
|
? body
|
||||||
|
: textarea.value.replace(/\s+$/, '') + "\n\n" + body;
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyButton = event.target.closest('[data-helpdesk-reply-toggle]');
|
||||||
|
|
||||||
|
if (! replyButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = document.getElementById(replyButton.dataset.helpdeskReplyToggle || '');
|
||||||
|
|
||||||
|
if (! target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.classList.toggle('hidden');
|
||||||
|
|
||||||
|
if (! target.classList.contains('hidden')) {
|
||||||
|
target.querySelector('textarea')?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="space-y-6">
|
<aside class="space-y-6">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@props(['comments', 'level' => 0])
|
@props(['comments', 'level' => 0, 'ticket' => null])
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@foreach ($comments as $comment)
|
@foreach ($comments as $comment)
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
$commentUser = $comment->relationLoaded('user') ? $comment->getRelation('user') : null;
|
$commentUser = $comment->relationLoaded('user') ? $comment->getRelation('user') : null;
|
||||||
$commentAttachments = $comment->relationLoaded('attachments') ? $comment->getRelation('attachments') : collect();
|
$commentAttachments = $comment->relationLoaded('attachments') ? $comment->getRelation('attachments') : collect();
|
||||||
$commentReplies = $comment->relationLoaded('replies') ? $comment->getRelation('replies') : collect();
|
$commentReplies = $comment->relationLoaded('replies') ? $comment->getRelation('replies') : collect();
|
||||||
|
$replyFormId = 'helpdesk-reply-form-'.$comment->id;
|
||||||
|
$currentTicket = $ticket ?? $comment->ticket ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
|
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($comment->is_internal)
|
@if ($comment->is_internal)
|
||||||
<flux:badge color="amber">Internal</flux:badge>
|
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-100">Internal</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,24 +33,30 @@
|
|||||||
@if ($commentAttachments->isNotEmpty())
|
@if ($commentAttachments->isNotEmpty())
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
@foreach ($commentAttachments as $attachment)
|
@foreach ($commentAttachments as $attachment)
|
||||||
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
|
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
|
||||||
{{ $attachment->original_name }}
|
{{ $attachment->original_name ?? $attachment->original_filename ?? $attachment->filename ?? $attachment->file_name ?? basename($attachment->path ?? $attachment->file_path ?? '') }}
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
@if ($currentTicket !== null)
|
||||||
<flux:button size="sm" variant="primary" wire:click="startReply({{ $comment->id }})" class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Reply</flux:button>
|
<div class="mt-3 flex justify-end">
|
||||||
</div>
|
<button type="button" dusk="reply-to-comment-{{ $comment->id }}" data-helpdesk-reply-toggle="{{ $replyFormId }}" class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
|
||||||
|
<i class="fa-solid fa-reply mr-2" aria-hidden="true"></i>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (($reply_parent_id ?? null) === $comment->id)
|
<form id="{{ $replyFormId }}" method="POST" action="{{ route('helpdesk.tickets.comments.store', $currentTicket) }}" enctype="multipart/form-data" class="mt-4 hidden rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40">
|
||||||
<form wire:submit="addReply" class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40">
|
@csrf
|
||||||
<x-helpdesk::html-editor wire:key="reply-editor-{{ $comment->id }}" wire:model="replyBody" label="Reply" rows="4" placeholder="Write a reply..." />
|
<input type="hidden" name="parent_id" value="{{ $comment->id }}">
|
||||||
|
<label for="reply-body-{{ $comment->id }}" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Reply</label>
|
||||||
|
<textarea id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4" required class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm 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" placeholder="Write a reply..."></textarea>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
<flux:button type="button" wire:click="cancelReply" variant="ghost" class="!bg-slate-600 !text-white hover:!bg-slate-700 focus:!ring-slate-400/60">Cancel</flux:button>
|
<button type="button" data-helpdesk-reply-toggle="{{ $replyFormId }}" class="inline-flex items-center justify-center rounded-lg bg-slate-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400/70">Cancel</button>
|
||||||
<flux:button type="submit" variant="primary" class="!bg-emerald-600 !text-white hover:!bg-emerald-700 focus:!ring-emerald-400/60">Post reply</flux:button>
|
<button type="submit" dusk="post-reply-{{ $comment->id }}" class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">Post reply</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
@@ -58,6 +66,7 @@
|
|||||||
@include('helpdesk::partials.comment-thread', [
|
@include('helpdesk::partials.comment-thread', [
|
||||||
'comments' => $commentReplies,
|
'comments' => $commentReplies,
|
||||||
'level' => ((int) $level) + 1,
|
'level' => ((int) $level) + 1,
|
||||||
|
'ticket' => $currentTicket,
|
||||||
])
|
])
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Modules\Helpdesk\Tests\Browser;
|
namespace Modules\Helpdesk\Tests\Browser;
|
||||||
|
|
||||||
use Laravel\Dusk\Browser;
|
use Laravel\Dusk\Browser;
|
||||||
|
use Modules\Helpdesk\Models\CannedResponse;
|
||||||
use Modules\Helpdesk\Models\Comment;
|
use Modules\Helpdesk\Models\Comment;
|
||||||
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
|
||||||
use Tests\DuskTestCase;
|
use Tests\DuskTestCase;
|
||||||
@@ -57,29 +58,59 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
|
|||||||
'subject' => 'Browser response ticket',
|
'subject' => 'Browser response ticket',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->browse(function (Browser $browser) use ($user, $ticket): void {
|
$cannedResponse = CannedResponse::query()->create([
|
||||||
|
'name' => 'Browser canned response',
|
||||||
|
'slug' => 'browser-canned-response',
|
||||||
|
'visibility' => 'agents',
|
||||||
|
'body' => 'Inserted canned browser response',
|
||||||
|
'is_active' => true,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->browse(function (Browser $browser) use ($user, $ticket, $cannedResponse): void {
|
||||||
$browser->loginAs($user)
|
$browser->loginAs($user)
|
||||||
->visitRoute('helpdesk.tickets.show', $ticket)
|
->visitRoute('helpdesk.tickets.show', $ticket)
|
||||||
->waitFor('@helpdesk-ticket-show')
|
->waitFor('@helpdesk-ticket-show')
|
||||||
->assertSee('Browser response ticket');
|
->assertSee('Browser response ticket')
|
||||||
|
->select('canned_response_id', (string) $cannedResponse->id)
|
||||||
$this->setHelpdeskEditor($browser, 'ticket-comment-editor', '<p>First browser response</p>');
|
->press('@insert-canned-response')
|
||||||
|
->assertInputValue('commentBody', 'Inserted canned browser response')
|
||||||
$browser->pause(1000)
|
->type('@ticket-comment-editor', 'First browser response')
|
||||||
->press('@post-comment')
|
->press('@post-comment')
|
||||||
->waitForLocation(route('helpdesk.tickets.show', $ticket, false), 10)
|
|
||||||
->waitForText('First browser response', 10)
|
->waitForText('First browser response', 10)
|
||||||
->assertSee('First browser response');
|
->assertSee('First browser response');
|
||||||
|
|
||||||
$this->assertDatabaseHas('helpdesk_comments', [
|
$comment = Comment::query()
|
||||||
'ticket_id' => $ticket->id,
|
->where('ticket_id', $ticket->id)
|
||||||
'user_id' => $user->id,
|
->whereNull('parent_id')
|
||||||
'body' => '<p>First browser response</p>',
|
->where('body', 'First browser response')
|
||||||
'is_internal' => false,
|
->firstOrFail();
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertSame(1, Comment::query()->where('ticket_id', $ticket->id)->count());
|
$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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('helpdesk_comments', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_id' => null,
|
||||||
|
'body' => 'First browser response',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentId = Comment::query()
|
||||||
|
->where('ticket_id', $ticket->id)
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->where('body', 'First browser response')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('helpdesk_comments', [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'body' => 'Nested browser response',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
|
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
|
||||||
@@ -96,22 +127,27 @@ if (! root) {
|
|||||||
throw new Error('Helpdesk editor ' + key + ' was not found.');
|
throw new Error('Helpdesk editor ' + key + ' was not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = root.matches('[contenteditable="true"]')
|
const editor = root.matches('textarea, input, [contenteditable="true"]')
|
||||||
? root
|
? root
|
||||||
: root.querySelector('[contenteditable="true"]');
|
: root.querySelector('textarea, input, [contenteditable="true"]');
|
||||||
|
|
||||||
if (! editor) {
|
if (! editor) {
|
||||||
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
|
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.focus();
|
editor.focus();
|
||||||
editor.innerHTML = html;
|
|
||||||
|
if (editor.matches('textarea, input')) {
|
||||||
|
editor.value = html.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||||
|
} else {
|
||||||
|
editor.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
editor.dispatchEvent(new InputEvent('input', {
|
editor.dispatchEvent(new InputEvent('input', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
inputType: 'insertText',
|
inputType: 'insertText',
|
||||||
data: editor.textContent || ' ',
|
data: editor.value || editor.textContent || ' ',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||||||
|
|||||||
@@ -205,11 +205,11 @@ versions:
|
|||||||
- Replaced browser-test Livewire internals with real UI interactions for settings sections, Select2 controls and HTML editors.
|
- Replaced browser-test Livewire internals with real UI interactions for settings sections, Select2 controls and HTML editors.
|
||||||
- Kept module.json version in sync with changelog.yaml.
|
- Kept module.json version in sync with changelog.yaml.
|
||||||
|
|
||||||
|
|
||||||
"1.3.12":
|
"1.3.12":
|
||||||
changed:
|
changed:
|
||||||
- Fixed ticket comment submission by adding a CSRF-protected native POST endpoint that stores comments through TicketService when Livewire or Flux submit handling does not call addComment.
|
- Replaced fragile Livewire/Flux ticket comment and reply submits with CSRF-protected normal POST comment storage.
|
||||||
- Updated the ticket detail reply form to post directly to the comment endpoint and refresh the conversation from the database after redirect.
|
- Restored canned-response insertion by writing the selected response into the real ticket comment textarea before submit.
|
||||||
- Hardened the Helpdesk HTML editor so native form submissions synchronise the latest editor content before the browser posts the form.
|
- Restored nested comment replies through per-comment POST reply forms that persist parent_id and refresh the conversation thread.
|
||||||
- Added feature and browser coverage proving ticket comments are created in helpdesk_comments and rendered back on the ticket detail page.
|
- Made Helpdesk settings section navigation use normal query-string links so Dusk and browsers do not depend on fragile Livewire tab clicks.
|
||||||
|
- Updated browser tests to cover canned-response insertion, parent comment persistence, nested reply persistence and settings tab navigation.
|
||||||
- Kept module.json version in sync with changelog.yaml.
|
- Kept module.json version in sync with changelog.yaml.
|
||||||
|
|||||||
Reference in New Issue
Block a user