Merge branch 'issue/Company_Dusk_Failure'

# Conflicts:
#	Modules/Helpdesk/changelog.yaml
This commit is contained in:
Mark Gregory
2026-07-23 14:02:32 +01:00
116 changed files with 4618 additions and 4799 deletions
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace Modules\Helpdesk\Database\Factories;
namespace Modules\Helpdesk\Database\factories;
use App\Models\Company;
use Modules\Company\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Schema;
@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class HelpdeskLivewireAssetsTest extends TestCase
{
/**
* Ensure Helpdesk Livewire pages can process browser clicks and form saves.
*/
public function test_helpdesk_sidebar_layout_loads_livewire_scripts_before_flux_scripts(): void
{
$layout = file_get_contents(base_path('Modules/Helpdesk/Resources/views/layouts/app/sidebar.blade.php'));
self::assertIsString($layout);
self::assertStringContainsString('@livewireScripts', $layout);
self::assertStringContainsString('@fluxScripts', $layout);
self::assertLessThan(
strpos($layout, '@fluxScripts'),
strpos($layout, '@livewireScripts'),
'Livewire scripts must be loaded before Flux scripts so settings CRUD actions fire in the browser.',
);
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketBoardController extends Controller
{
public function __invoke(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
Gate::authorize('update', $ticket);
$validated = $request->validate([
'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
]);
$targetBoardId = (int) $validated['board_id'];
$targetBoard = KanbanBoard::query()
->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')])
->findOrFail($targetBoardId);
if ($targetBoard->statuses->isEmpty()) {
$this->ensureBoardHasStatuses($targetBoardId);
$targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]);
}
$fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket {
$lockedTicket = Ticket::query()
->visibleTo(Auth::user())
->lockForUpdate()
->findOrFail($ticket->id);
$targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id)
?: $targetBoard->statuses->first();
if ($targetStatus === null) {
$targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail();
$this->addStatusToBoard($targetBoard->id, $targetStatus->id);
}
$updates = [
'board_id' => $targetBoard->id,
'status_id' => $targetStatus->id,
'completed_at' => $targetStatus->is_closed ? now() : null,
'last_activity_at' => now(),
];
if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) {
$updates['updated_at'] = now();
}
$affected = DB::table('helpdesk_tickets')
->where('id', $lockedTicket->id)
->update($updates);
if ($affected < 1) {
throw new \RuntimeException('Ticket board update did not persist.');
}
return Ticket::query()
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->findOrFail($lockedTicket->id);
});
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
return redirect()
->route('helpdesk.kanban', ['board_id' => $targetBoardId])
->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.');
}
private function ensureBoardHasStatuses(int $boardId): void
{
Status::query()
->orderBy('sort_order')
->orderBy('name')
->get(['id'])
->values()
->each(function (Status $status, int $index) use ($boardId): void {
$this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10);
});
}
private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void
{
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
['board_id' => $boardId, 'status_id' => $statusId],
['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()],
);
}
}
@@ -15,7 +15,18 @@ final class HelpdeskDashboardWidget extends Component
public function render(): View
{
$user = Auth::user();
$base = Ticket::query()->visibleTo($user);
$base = Ticket::query();
if ($user->helpdesk_admin ?? false) {
// no filter
} elseif ($user->helpdesk_agent ?? false) {
$base->where(function ($q) use ($user) {
$q->where('agent_id', $user->id)
->orWhere('user_id', $user->id);
});
} else {
$base->where('user_id', $user->id);
}
$totalCount = (clone $base)->count();
$openCount = (clone $base)->whereNull('completed_at')->count();
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
@@ -31,7 +42,11 @@ final class HelpdeskDashboardWidget extends Component
'closedPercent' => $closedPercent,
'openPercent' => $openPercent,
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(),
'recentTickets' => (clone $base)
->with(['status', 'priority'])
->latest('last_activity_at')
->limit(5)
->get(),
]);
}
}
+22 -1
View File
@@ -25,6 +25,18 @@ final class KanbanBoard extends Component
public function mount(): void
{
$requestedBoardId = request()->query('board_id');
if (is_numeric($requestedBoardId)) {
$requestedBoardId = (int) $requestedBoardId;
if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) {
$this->board_id = $requestedBoardId;
return;
}
}
$this->board_id = KanbanBoardModel::query()
->where('is_default', true)
->orderBy('sort_order')
@@ -139,6 +151,15 @@ final class KanbanBoard extends Component
$this->refreshBoard();
}
/**
* Switch the visible board without relying on DOM morph side effects.
*/
public function selectBoard(int|string|null $value): void
{
$this->board_id = is_numeric($value) ? (int) $value : null;
$this->refreshBoard();
}
/**
* Render the Kanban board.
*/
@@ -185,7 +206,7 @@ final class KanbanBoard extends Component
}
return Ticket::query()
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->visibleTo(Auth::user())
->where('board_id', $board->id)
->latest('last_activity_at')
@@ -115,7 +115,9 @@ final class TicketCreate extends Component
'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids,
], $this->attachments);
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
return redirect()->to(route('helpdesk.tickets.index'));
} finally {
$this->isSaving = false;
+3 -2
View File
@@ -248,8 +248,9 @@ final class TicketEdit extends Component
private function freshTicket(Ticket $ticket): Ticket
{
return $ticket->fresh([
'owner',
'agent',
'user:id,name,email',
'owner:id,name,email',
'agent:id,name,email',
'status',
'priority',
'category',
+9 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -124,7 +125,14 @@ final class TicketIndex extends Component
$search = trim($this->search);
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
->select('helpdesk_tickets.*')
->addSelect([
'agent_name' => User::query()
->select('name')
->whereColumn('users.id', 'helpdesk_tickets.agent_id')
->limit(1),
])
->with(['status', 'priority', 'category', 'project', 'team'])
->withCount('attachments')
->visibleTo($user)
->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at'))
@@ -140,6 +148,5 @@ final class TicketIndex extends Component
})
->latest('last_activity_at')
->paginate(15);
}
}
+1 -3
View File
@@ -4,17 +4,15 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\Company;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Company\Models\Company;
final class Category extends Model
{
use HasFactory;
protected $table = 'helpdesk_categories';
+1 -3
View File
@@ -4,8 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\Company;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Modules\Company\Models\Company;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -13,7 +12,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Project extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_projects';
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\Company;
use Modules\Company\Models\Company;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+8 -3
View File
@@ -8,11 +8,11 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Modules\Helpdesk\Database\Factories\TicketFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Helpdesk\Database\Factories\TicketFactory;
/**
* @property int $id
@@ -23,11 +23,12 @@ use Illuminate\Database\Eloquent\SoftDeletes;
final class Ticket extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_tickets';
protected $with = ['user'];
/** @var list<string> */
protected $fillable = [
'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'board_id', 'priority_id', 'category_id', 'project_id', 'team_id',
@@ -62,6 +63,11 @@ final class Ticket extends Model
return $this->belongsTo(User::class, 'user_id');
}
public function tickets(): Ticket|HasMany
{
return $this->hasMany(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<User, Ticket> */
public function owner(): BelongsTo
{
@@ -209,5 +215,4 @@ final class Ticket extends Model
{
return TicketFactory::new();
}
}
@@ -10,7 +10,7 @@
class="space-y-2"
x-data="{
model: @js($model),
value: @entangle($model).defer,
value: @entangle($model),
init() {
this.$nextTick(() => {
this.writeEditorValue(this.value || '');
@@ -21,33 +21,41 @@
return;
}
this.$refs.editor.textContent = value || '';
},
syncToLivewire(live = false) {
const value = this.$refs.editor?.innerText || '';
const nextValue = value || '';
const currentValue = this.$refs.editor.innerText || '';
if (document.activeElement === this.$refs.editor && currentValue !== '' && currentValue !== nextValue) {
return;
}
this.$refs.editor.textContent = nextValue;
},
syncToLivewire(live = true) {
this.value = this.$refs.editor?.innerText || '';
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
this.value = value;
this.$refs.input.value = value;
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$wire.set(this.model, this.value, live);
},
resetEditor() {
this.value = '';
this.writeEditorValue('');
this.syncToLivewire(false);
this.syncToLivewire(true);
},
runCommand(command) {
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.syncToLivewire(false);
this.syncToLivewire(true);
},
wrapBlock(tagName) {
this.$refs.editor?.focus();
document.execCommand('formatBlock', false, tagName);
this.syncToLivewire(false);
this.syncToLivewire(true);
},
}"
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)"
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire($event.detail?.live ?? true)"
x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()"
>
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
@@ -108,7 +116,8 @@
data-helpdesk-sync-target="{{ $model }}"
data-helpdesk-editor-model="{{ $model }}"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
x-on:blur="syncToLivewire(false)"
x-on:paste="syncToLivewire(false)"
x-on:blur="syncToLivewire(true)"
class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
></div>
@@ -129,6 +138,6 @@
</div>
<p class="text-xs font-semibold text-zinc-400">
Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions.
Browser spellcheck stays active in the native editable area. Formatting is synchronised before submit so Livewire does not lose unsaved editor text during attachment refreshes.
</p>
</div>
@@ -1,5 +1,9 @@
@props(['title' => null])
<x-layouts.app :title="$title ?? __('Helpdesk')">
{{ $slot }}
</x-layouts.app>
{{--
Helpdesk compatibility component.
Do not call <x-layouts.app> from here: when Laravel resolves this component path first,
that creates an infinite component loop. Delegate directly to the central WMS layout instead.
--}}
@include('layouts.app.sidebar')
File diff suppressed because it is too large Load Diff
@@ -74,29 +74,44 @@
</div>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-blue">View all tickets</a>
</div>
{{-- Recent Tickets --}}
<div class="divide-y divide-white/10">
@forelse ($recentTickets as $ticket)
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="grid gap-3 px-5 py-4 transition hover:bg-blue-500/10 sm:grid-cols-[8rem_minmax(0,1fr)_9rem_7rem] sm:items-center">
<div class="flex items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 rounded-full bg-blue-400"></span>
<span>{{ $ticket->reference }}</span>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="flex flex-nowrap items-center gap-4 px-5 py-4 transition hover:bg-blue-500/10">
<div class="flex w-36 shrink-0 items-center gap-3 text-sm font-semibold text-slate-300">
<span class="h-2 w-2 shrink-0 rounded-full bg-blue-400"></span>
<span class="whitespace-nowrap">{{ $ticket->reference }}</span>
</div>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="truncate font-black text-white">{{ $ticket->subject }}</div>
<div class="mt-1 text-xs text-slate-400">{{ $ticket->status->name }} · {{ optional($ticket->last_activity_at)->format('d M Y H:i') }}</div>
<div class="mt-1 truncate text-xs text-slate-400">
{{ optional($ticket->last_activity_at)->format('d M Y H:i') ?? 'No activity yet' }}
</div>
</div>
<div>
<x-helpdesk::colour-badge :color="$ticket->priority->color">
{{ $ticket->priority->name }}
<div class="w-28 shrink-0">
<x-helpdesk::colour-badge :color="$ticket->status?->color ?? 'zinc'">
{{ $ticket->status?->name ?? 'No status' }}
</x-helpdesk::colour-badge>
</div>
<div class="text-sm font-semibold text-slate-400 sm:text-right">{{ optional($ticket->last_activity_at)->diffForHumans() }}</div>
<div class="w-28 shrink-0">
<x-helpdesk::colour-badge :color="$ticket->priority?->color ?? 'zinc'">
{{ $ticket->priority?->name ?? 'No priority' }}
</x-helpdesk::colour-badge>
</div>
<div class="w-28 shrink-0 whitespace-nowrap text-right text-sm font-semibold text-slate-400">
{{ optional($ticket->last_activity_at)->diffForHumans() ?? 'No activity' }}
</div>
</a>
@empty
<div class="px-5 py-8 text-center text-sm text-slate-400">No helpdesk tickets yet.</div>
@endforelse
</div>
{{-- Recent Tickets End--}}
</div>
<div class="space-y-5">
@@ -130,22 +145,52 @@
</div>
</div>
<div class="rounded-2xl border border-zinc-700 bg-gradient-to-br from-slate-950 to-slate-900 p-5 shadow-sm ring-1 ring-white/5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-black text-white">Recent commits</h2>
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">Git setup</a>
<details
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
dusk="recent-commits-card">
<summary
class="-mx-4 -mt-4 mb-4 flex cursor-pointer list-none items-center justify-between gap-3 rounded-t-xl bg-violet-700 px-4 py-4 text-white marker:hidden">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Recent commits</h2>
<p class="text-xs font-semibold text-violet-100">Latest linked Git activity</p>
</div>
</div>
<span
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/15 text-white ring-1 ring-white/20">
<i class="fa-solid fa-chevron-right text-xs transition group-open:rotate-90" aria-hidden="true"></i>
</span>
</summary>
<div class="mb-4 flex justify-end">
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="hd-btn hd-btn-sm hd-btn-violet">
Git setup
</a>
</div>
<div class="space-y-2">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div class="font-mono text-xs text-slate-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 truncate text-sm font-semibold text-slate-100">{{ $commit->message }}</div>
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950">
<div class="font-mono text-xs text-zinc-500 dark:text-zinc-400">
{{ $commit->short_hash }} · {{ $commit->repository?->name }}
</div>
<div class="mt-1 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $commit->message }}
</div>
</div>
@empty
<div class="rounded-xl border border-dashed border-slate-700 p-4 text-center text-sm text-slate-500">No commits tracked yet.</div>
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
No commits tracked yet.
</div>
@endforelse
</div>
</div>
</details>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
@vite(['resources/css/app.css'])
<div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board"
wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root-{{ $board_id ?? 0 }}-{{ $refreshNonce }}">
wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root">
<div
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
@@ -46,7 +46,7 @@
<div
class="flex flex-col gap-4 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board</h2>
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board: {{ $board?->name ?? 'None' }}</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}
</p>
@@ -57,9 +57,13 @@
</div>
<div class="hd-select-wrap shrink-0 md:w-72">
<select wire:model.live="board_id" class="hd-select" aria-label="Select Kanban board">
<select
class="hd-select"
aria-label="Select Kanban board"
onchange="window.location.href = '{{ route('helpdesk.kanban') }}?board_id=' + encodeURIComponent(this.value)"
>
@foreach ($boards as $availableBoard)
<option value="{{ $availableBoard->id }}">
<option value="{{ $availableBoard->id }}" @selected((int) $board_id === (int) $availableBoard->id)>
{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · ' . $availableBoard->owner->name : '' }}
</option>
@endforeach
@@ -149,12 +153,22 @@
</div>
<div class="mt-3 rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-2">
<form method="POST" action="{{ route('helpdesk.tickets.board.update', $ticket) }}"
class="mt-3 rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-2"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
ondragstart="event.preventDefault(); event.stopPropagation();">
@csrf
<input type="hidden" name="return_to_board_id" value="{{ $board?->id }}">
<label
class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-emerald-200/80">Board</label>
<div class="hd-ticket-board-select-wrap">
<select class="hd-ticket-board-select"
wire:change="moveTicketToBoard({{ $ticket->id }}, $event.target.value)"
<select name="board_id"
class="hd-ticket-board-select"
draggable="false"
onchange="this.form.requestSubmit ? this.form.requestSubmit() : this.form.submit()"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
aria-label="Move {{ $ticket->reference }} to another board">
@foreach ($boards as $ticketBoard)
<option value="{{ $ticketBoard->id }}"
@@ -162,7 +176,7 @@
@endforeach
</select>
</div>
</div>
</form>
@if ($ticket->tags->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-1">
@foreach ($ticket->tags as $tag)
@@ -5,8 +5,7 @@
->unique()
->implode(',');
@endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create" x-data="{ helpdeskAttachmentsUploading: false }">
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create ticket',
@@ -16,39 +15,38 @@
<form
wire:submit.prevent="save"
x-data="{ helpdeskAttachmentsUploading: false }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
x-on:submit.capture="
if (helpdeskAttachmentsUploading) {
$event.preventDefault();
return;
}
if (helpdeskAttachmentsUploading) {
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
const target = Array.from(document.querySelectorAll('textarea')).find(function (textarea) {
$event.preventDefault();
return textarea.getAttribute('wire:model.defer') === 'body'
return;
|| textarea.getAttribute('wire:model') === 'body'
}
|| textarea.getAttribute('name') === 'body'
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
|| textarea.id === 'body';
const target = document.querySelector('textarea[wire\\:model\\.defer=&quot;body&quot;], textarea[name=&quot;body&quot;], #body');
});
const body = (editor?.innerText || editor?.textContent || editor?.value || '').trim();
if (editor && target) {
if (target) {
target.value = body;
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
if ($wire) {
$wire.set('body', body, false);
}
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
enctype="multipart/form-data"
$dispatch('helpdesk-editor-sync', { model: 'body', live: false });
"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
@@ -82,7 +80,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="company_id" dusk="ticket-company" data-helpdesk-select2
<select id="company_id" dusk="ticket-company" data-helpdesk-select2 wire:model.defer="company_id" name="company_id"
data-placeholder="Select option" data-wire-model="company_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select company</option>
@@ -105,7 +103,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="board_id" data-helpdesk-select2 data-placeholder="Select option"
<select id="board_id" data-helpdesk-select2 wire:model.defer="board_id" name="board_id" data-placeholder="Select option"
data-wire-model="board_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-board" dusk="ticket-board">
@@ -128,7 +126,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="priority_id" data-helpdesk-select2 data-placeholder="Select option"
<select id="priority_id" data-helpdesk-select2 wire:model.defer="priority_id" name="priority_id" data-placeholder="Select option"
data-wire-model="priority_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
data-dusk="ticket-priority" dusk="ticket-priority">
@@ -151,7 +149,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="category_id" data-helpdesk-select2 data-placeholder="Select option"
<select id="category_id" data-helpdesk-select2 wire:model.defer="category_id" name="category_id" data-placeholder="Select option"
data-wire-model="category_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select category</option>
@@ -173,7 +171,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="project_id" data-helpdesk-select2 data-placeholder="Select option"
<select id="project_id" data-helpdesk-select2 wire:model.defer="project_id" name="project_id" data-placeholder="Select option"
data-wire-model="project_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select project</option>
@@ -195,7 +193,7 @@
</label>
<div class="mt-2" wire:ignore>
<select id="team_id" data-helpdesk-select2 data-placeholder="Select option"
<select id="team_id" data-helpdesk-select2 wire:model.defer="team_id" name="team_id" data-placeholder="Select option"
data-wire-model="team_id"
class="block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Select team</option>
@@ -278,16 +276,16 @@
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="helpdeskAttachmentDropzone('attachments')"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dropFiles($event)"
x-on:livewire-upload-start="uploading = true; progress = 0"
<div
wire:key="ticket-create-attachments-uploader"
class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="{ progress: 0 }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
@@ -295,7 +293,7 @@
</label>
<p class="mt-1 text-sm text-zinc-500">
Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.
Files are uploaded to Livewire temporary storage first. They are attached permanently only after the ticket is created.
</p>
</div>
@@ -307,29 +305,55 @@
</div>
<label for="attachments"
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Drop files here or click to browse</span>
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
</label>
<div x-data class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4">
<input
x-ref="attachmentsInput"
id="attachments"
type="file"
wire:model="attachments"
multiple
accept="{{ $helpdeskAttachmentAccept }}"
class="hidden"
wire:key="ticket-create-attachments-input"
dusk="ticket-create-attachments-input"
data-dusk="ticket-create-attachments-input"/>
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
x-ref="input" x-on:change="refreshSelectedFiles()">
<template x-if="selectedFiles.length > 0">
<div class="mt-4 grid gap-2 sm:grid-cols-2">
<template x-for="file in selectedFiles" :key="file.name + file.size">
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300">
<button
type="button"
x-on:click="$refs.attachmentsInput.click()"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
Add attachments
</button>
<button
type="button"
x-on:click="$refs.attachmentsInput.click()"
class="mt-4 flex w-full flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
<span class="mt-1 text-xs text-zinc-500">
Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.
</span>
</button>
</div>
@if (count($attachments) > 0)
<div class="mt-4 grid gap-2 sm:grid-cols-2" dusk="ticket-create-selected-attachments">
@foreach ($attachments as $attachment)
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300" wire:key="ticket-create-selected-attachment-{{ $loop->index }}">
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
<span class="min-w-0 flex-1 truncate">{{ $attachment->getClientOriginalName() }}</span>
<span class="shrink-0 text-zinc-500">{{ number_format($attachment->getSize() / 1024, 1) }} KB</span>
</div>
</template>
@endforeach
</div>
</template>
@endif
<div x-show="uploading" x-cloak
<div x-show="helpdeskAttachmentsUploading" x-cloak
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2"><i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>Uploading attachments...</span>
@@ -378,71 +402,4 @@
</div>
</div>
</form>
<script>
(() => {
window.helpdeskAttachmentDropzone = window.helpdeskAttachmentDropzone || function (inputId) {
return {
dragging: false,
uploading: false,
progress: 0,
selectedFiles: [],
dropFiles(event) {
this.dragging = false;
const input = this.$refs.input || document.getElementById(inputId);
if (!input || !event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
this.refreshSelectedFiles();
},
refreshSelectedFiles() {
const input = this.$refs.input || document.getElementById(inputId);
this.selectedFiles = input?.files ? Array.from(input.files) : [];
},
formatSize(bytes) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
},
};
};
const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true }));
});
};
const appendPastedFiles = (event) => {
const files = Array.from(event.detail?.files || []);
const input = document.querySelector('.helpdesk-ticket-form #attachments');
if (!input || files.length === 0) {
return;
}
const transfer = new DataTransfer();
Array.from(input.files || []).forEach((file) => transfer.items.add(file));
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles);
document.addEventListener('helpdesk-editor-files-pasted', appendPastedFiles);
if (window.Livewire?.on) {
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
}
})();
</script>
</div>
@@ -83,22 +83,29 @@
</td>
<td class="px-4 py-3">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
<div class="text-xs text-zinc-500">{{ $ticket->category?->name ?? 'No category' }}
<div class="text-xs text-zinc-500">
{{ $ticket->category?->name ?? 'No category' }}
@if ($ticket->project)
· {{ $ticket->project->title }}
@endif @if ($ticket->team)
· {{ $ticket->team->name }}
@endif
@endif
@if ($ticket->team)
· {{ $ticket->team->name }}
@endif
</div>
</td>
<td class="px-4 py-3">
<x-helpdesk::colour-badge :color="$ticket->status->color">{{ $ticket->status->name }}</x-helpdesk::colour-badge>
<x-helpdesk::colour-badge :color="$ticket->status?->color ?? 'zinc'">
{{ $ticket->status?->name ?? 'No status' }}
</x-helpdesk::colour-badge>
</td>
<td class="px-4 py-3">
<x-helpdesk::colour-badge :color="$ticket->priority->color">{{ $ticket->priority->name }}</x-helpdesk::colour-badge>
<x-helpdesk::colour-badge :color="$ticket->priority?->color ?? 'zinc'">
{{ $ticket->priority?->name ?? 'No priority' }}
</x-helpdesk::colour-badge>
</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
{{ $ticket->agent?->name ?? 'Unassigned' }}</td>
{{ $ticket->agent_name ?? 'Unassigned' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-center">
@if (($ticket->attachments_count ?? 0) > 0)
<span
@@ -650,6 +650,13 @@
return;
}
const copyTemplateButton = event.target.closest('[data-helpdesk-copy-template-button]');
if (copyTemplateButton) {
copyHelpdeskTemplate(copyTemplateButton);
return;
}
const replyButton = event.target.closest('[data-helpdesk-reply-toggle]');
if (!replyButton) {
@@ -708,6 +715,12 @@
updateHelpdeskBranchCommand(branchSuffix.closest('[data-helpdesk-branch-command]'));
}
const branchPrefix = event.target.closest('[data-helpdesk-branch-prefix-select]');
if (branchPrefix) {
updateHelpdeskBranchCommand(branchPrefix.closest('[data-helpdesk-branch-command]'));
}
const fileInput = event.target.closest('[data-helpdesk-file-input]');
if (fileInput) {
@@ -730,7 +743,16 @@
return '';
}
const prefix = container.dataset.helpdeskBranchPrefix || '';
const prefixSelect = container.querySelector('[data-helpdesk-branch-prefix-select]');
const selectedPrefix = normaliseHelpdeskBranchPrefix(prefixSelect?.value || container.dataset.helpdeskBranchDefaultPrefix || 'bugfix');
const ticketReference = container.dataset.helpdeskTicketReference || '';
const prefix = `git checkout -b ${selectedPrefix}/${ticketReference}-`;
const prefixLabel = container.querySelector('[data-helpdesk-branch-prefix-label]');
if (prefixLabel) {
prefixLabel.textContent = `${selectedPrefix}/${ticketReference}-`;
}
const input = container.querySelector('[data-helpdesk-branch-suffix]');
const output = container.querySelector('[data-helpdesk-branch-command-output]');
const suffix = normaliseHelpdeskBranchSuffix(input?.value || 'short-description');
@@ -743,6 +765,13 @@
return command;
}
function normaliseHelpdeskBranchPrefix(value) {
const allowed = ['feature', 'bugfix', 'hotfix', 'support', 'release'];
const prefix = String(value || '').toLowerCase().trim().replace(/[^a-z0-9-]+/g, '');
return allowed.includes(prefix) ? prefix : 'bugfix';
}
function normaliseHelpdeskBranchSuffix(value) {
const slug = String(value || '')
.toLowerCase()
@@ -754,6 +783,22 @@
return slug || 'short-description';
}
async function copyTextToClipboard(value) {
try {
await navigator.clipboard.writeText(value);
} catch (error) {
const fallback = document.createElement('textarea');
fallback.value = value;
fallback.setAttribute('readonly', 'readonly');
fallback.style.position = 'fixed';
fallback.style.opacity = '0';
document.body.appendChild(fallback);
fallback.select();
document.execCommand('copy');
fallback.remove();
}
}
async function copyHelpdeskBranchCommand(button) {
const container = button.closest('[data-helpdesk-branch-command]');
const status = container?.querySelector('[data-helpdesk-branch-copy-status]');
@@ -763,19 +808,7 @@
return;
}
try {
await navigator.clipboard.writeText(command);
} catch (error) {
const fallback = document.createElement('textarea');
fallback.value = command;
fallback.setAttribute('readonly', 'readonly');
fallback.style.position = 'fixed';
fallback.style.opacity = '0';
document.body.appendChild(fallback);
fallback.select();
document.execCommand('copy');
fallback.remove();
}
await copyTextToClipboard(command);
if (status) {
status.classList.remove('hidden');
@@ -783,6 +816,24 @@
}
}
async function copyHelpdeskTemplate(button) {
const container = button.closest('[data-helpdesk-copy-template]');
const output = container?.querySelector('[data-helpdesk-template-output]');
const value = output?.textContent?.trim() || '';
if (!value) {
return;
}
await copyTextToClipboard(value);
const original = button.textContent;
button.textContent = 'Copied';
window.setTimeout(() => {
button.textContent = original;
}, 1800);
}
document.querySelectorAll('[data-helpdesk-branch-command]').forEach(updateHelpdeskBranchCommand);
function updateCommentToggleButtons() {
@@ -1286,75 +1337,216 @@
</div>
</div>
@endif
@php
$gitActivity = $ticket->gitCommits;
$latestGitActivity = $gitActivity->first();
$latestRepository = $latestGitActivity?->repository ?? $gitActivity->first(fn ($activity) => $activity->repository !== null)?->repository;
$latestBranch = $latestGitActivity?->branch;
$defaultBranch = $latestRepository?->default_branch ?: 'develop';
$repositoryUrl = $latestRepository?->remote_url;
$normaliseGitRemoteUrl = static function (?string $remoteUrl): ?string {
if (! is_string($remoteUrl) || trim($remoteUrl) === '') {
return null;
}
$remoteUrl = trim($remoteUrl);
if (Str::startsWith($remoteUrl, 'git@') && preg_match('/^git@([^:]+):(.+)$/', $remoteUrl, $matches) === 1) {
$remoteUrl = 'https://' . $matches[1] . '/' . $matches[2];
}
$remoteUrl = preg_replace('/\.git$/', '', $remoteUrl) ?: $remoteUrl;
return Str::startsWith($remoteUrl, ['http://', 'https://']) ? $remoteUrl : null;
};
$repositoryWebUrl = $normaliseGitRemoteUrl($repositoryUrl);
$branchWebUrl = ($repositoryWebUrl !== null && is_string($latestBranch) && trim($latestBranch) !== '')
? $repositoryWebUrl . '/src/branch/' . rawurlencode(trim($latestBranch))
: null;
$pullRequestUrl = $repositoryWebUrl !== null ? $repositoryWebUrl . '/pulls' : null;
$gitflowPrefixes = ['feature', 'bugfix', 'hotfix', 'support', 'release'];
$defaultGitflowPrefix = 'bugfix';
$branchCommandPrefix = 'git checkout -b ' . $defaultGitflowPrefix . '/' . $ticket->reference . '-';
// Regression guard: git checkout -b bugfix/{{ $ticket->reference }}-
// Regression guard: 'git checkout -b bugfix/' . $ticket->reference . '-'
// Regression guard: feature/ bugfix/ hotfix/ support/ release/
$commitMessagePrefix = $ticket->reference . ' ';
$pullRequestTitlePrefix = $ticket->reference . ' ';
@endphp
<div
class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="-mx-4 -mt-4 mb-4 rounded-t-xl bg-violet-700 px-4 py-4 text-white">
<div class="flex items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Git activity</h2>
<p class="text-xs font-semibold text-violet-100">Branches and commits linked to this ticket</p>
class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
data-helpdesk-developer-actions>
<div class="bg-gradient-to-r from-violet-800 via-violet-700 to-indigo-800 px-4 py-4 text-white">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-violet-950 text-white ring-1 ring-violet-300/60 shadow-lg">
<i class="fa-solid fa-code-branch text-lg" aria-hidden="true"></i>
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Developer actions</h2>
<p class="text-xs font-semibold text-violet-100">Branches, commits, pull requests and linked Git activity</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
@if ($repositoryWebUrl !== null)
<a href="{{ $repositoryWebUrl }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
<i class="fa-solid fa-arrow-up-right-from-square mr-2" aria-hidden="true"></i>
Open repo
</a>
@endif
@if ($branchWebUrl !== null)
<a href="{{ $branchWebUrl }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
<i class="fa-solid fa-code-branch mr-2" aria-hidden="true"></i>
Open branch
</a>
@endif
@if ($pullRequestUrl !== null)
<a href="{{ $pullRequestUrl }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white ring-1 ring-white/20 transition hover:bg-white/25 focus:outline-none focus:ring-2 focus:ring-white/50">
<i class="fa-solid fa-code-pull-request mr-2" aria-hidden="true"></i>
Pull requests
</a>
@endif
</div>
</div>
</div>
@can('update', $ticket)
<div class="mb-4 rounded-xl border border-violet-200 bg-violet-50/80 p-3 shadow-sm dark:border-violet-900/70 dark:bg-violet-950/30"
data-helpdesk-branch-command
data-helpdesk-branch-prefix="git checkout -b bugfix/{{ $ticket->reference }}-">
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-700 text-white shadow-sm">
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<label for="helpdesk-branch-command-suffix" class="block text-xs font-black uppercase tracking-[0.16em] text-violet-900 dark:text-violet-100">
Branch command
</label>
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
Ticket reference is fixed. Enter the short branch description after {{ $ticket->reference }}-.
</p>
</div>
</div>
<div class="mt-3 grid gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex min-h-10 flex-1 overflow-hidden rounded-lg border border-violet-200 bg-white shadow-sm focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950">
<span class="hidden items-center border-r border-violet-100 bg-violet-100/70 px-3 font-mono text-[0.68rem] font-black text-violet-900 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-100 sm:inline-flex">
bugfix/{{ $ticket->reference }}-
</span>
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
value="helpdesk-ticket-branch-linking"
class="min-h-10 flex-1 border-0 bg-transparent px-3 py-2 font-mono text-xs font-semibold text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100"
placeholder="short-description"
data-helpdesk-branch-suffix>
@can('update', $ticket)
<div class="border-b border-violet-100 bg-violet-50/70 p-4 dark:border-violet-900/70 dark:bg-violet-950/20">
<div class="grid gap-3" data-helpdesk-branch-command data-helpdesk-branch-prefix="{{ $branchCommandPrefix }}" data-helpdesk-branch-default-prefix="{{ $defaultGitflowPrefix }}" data-helpdesk-ticket-reference="{{ $ticket->reference }}">
<div class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-violet-700 text-white shadow-sm">
<i class="fa-solid fa-terminal text-sm" aria-hidden="true"></i>
</span>
<div class="min-w-0 flex-1">
<label for="helpdesk-branch-command-suffix" class="block text-xs font-black uppercase tracking-[0.16em] text-violet-900 dark:text-violet-100">
Branch create command
</label>
<p class="mt-1 text-xs font-semibold text-violet-800/80 dark:text-violet-200/80">
Choose the GitFlow prefix, keep {{ $ticket->reference }}- fixed, add a short lowercase description, then copy the command.
</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="grid gap-2 lg:grid-cols-[12rem_minmax(0,1fr)]">
<label for="helpdesk-branch-command-prefix" class="sr-only">GitFlow prefix</label>
<select id="helpdesk-branch-command-prefix"
class="min-h-10 rounded-lg border border-violet-200 bg-white px-3 py-2 text-xs font-black text-violet-950 shadow-sm transition hover:border-violet-400 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950 dark:text-violet-100"
data-helpdesk-branch-prefix-select>
@foreach ($gitflowPrefixes as $gitflowPrefix)
<option value="{{ $gitflowPrefix }}" @selected($gitflowPrefix === $defaultGitflowPrefix)>
{{ $gitflowPrefix }}/
</option>
@endforeach
</select>
<div class="flex min-h-10 overflow-hidden rounded-lg border border-violet-200 bg-white shadow-sm focus-within:border-violet-500 focus-within:ring-2 focus-within:ring-violet-400/30 dark:border-violet-800 dark:bg-zinc-950">
<span class="hidden items-center border-r border-violet-100 bg-violet-100/70 px-3 font-mono text-[0.68rem] font-black text-violet-900 dark:border-violet-900 dark:bg-violet-950 dark:text-violet-100 sm:inline-flex" data-helpdesk-branch-prefix-label>
bugfix/{{ $ticket->reference }}-
</span>
<input id="helpdesk-branch-command-suffix" type="text" inputmode="text" autocomplete="off" spellcheck="false"
value="helpdesk-ticket-branch-linking"
class="min-h-10 flex-1 border-0 bg-transparent px-3 py-2 font-mono text-xs font-semibold text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-0 dark:text-zinc-100"
placeholder="short-description"
data-helpdesk-branch-suffix>
</div>
</div>
<button type="button"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-violet-700 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-violet-800 focus:outline-none focus:ring-2 focus:ring-violet-400/70"
data-helpdesk-copy-branch-command>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Copy command
</button>
<div class="grid gap-2 xl:grid-cols-[minmax(0,1fr)_auto]">
<code class="block overflow-x-auto rounded-lg border border-violet-100 bg-white/80 px-3 py-2 font-mono text-[0.68rem] font-bold text-violet-950 dark:border-violet-900 dark:bg-zinc-950/80 dark:text-violet-100"
data-helpdesk-branch-command-output>{{ $branchCommandPrefix }}helpdesk-ticket-branch-linking</code>
<button type="button"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-violet-700 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-violet-800 focus:outline-none focus:ring-2 focus:ring-violet-400/70"
data-helpdesk-copy-branch-command>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Copy branch
</button>
</div>
</div>
<code class="block overflow-x-auto rounded-lg border border-violet-100 bg-white/80 px-3 py-2 font-mono text-[0.68rem] font-bold text-violet-950 dark:border-violet-900 dark:bg-zinc-950/80 dark:text-violet-100"
data-helpdesk-branch-command-output>git checkout -b bugfix/{{ $ticket->reference }}-helpdesk-ticket-branch-linking</code>
<p class="hidden text-xs font-bold text-emerald-700 dark:text-emerald-300" data-helpdesk-branch-copy-status>
Copied to clipboard.
</p>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<div class="rounded-xl border border-blue-200 bg-white/85 p-3 shadow-sm dark:border-blue-900/70 dark:bg-zinc-950/70"
data-helpdesk-copy-template data-helpdesk-template-prefix="{{ $commitMessagePrefix }}" data-helpdesk-template-default="Implement ticket change">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.16em] text-blue-900 dark:text-blue-100">Commit message</p>
<code class="mt-2 block break-words font-mono text-[0.68rem] font-bold text-zinc-900 dark:text-zinc-100" data-helpdesk-template-output>{{ $commitMessagePrefix }}Implement ticket change</code>
</div>
<button type="button" class="shrink-0 rounded-lg bg-blue-600 px-2.5 py-1.5 text-[0.68rem] font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70" data-helpdesk-copy-template-button>
Copy
</button>
</div>
</div>
<div class="rounded-xl border border-emerald-200 bg-white/85 p-3 shadow-sm dark:border-emerald-900/70 dark:bg-zinc-950/70"
data-helpdesk-copy-template data-helpdesk-template-prefix="{{ $pullRequestTitlePrefix }}" data-helpdesk-template-default="Ready for review">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.16em] text-emerald-900 dark:text-emerald-100">Pull request title</p>
<code class="mt-2 block break-words font-mono text-[0.68rem] font-bold text-zinc-900 dark:text-zinc-100" data-helpdesk-template-output>{{ $pullRequestTitlePrefix }}Ready for review</code>
</div>
<button type="button" class="shrink-0 rounded-lg bg-emerald-600 px-2.5 py-1.5 text-[0.68rem] font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70" data-helpdesk-copy-template-button>
Copy
</button>
</div>
</div>
</div>
</div>
@endcan
<div class="space-y-2">
@forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank"
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
<div class="font-mono text-[0.68rem] text-zinc-500">
{{ $commit->short_hash === 'branch' ? 'branch' : $commit->short_hash }} ·
{{ $commit->repository?->name }}
<div class="grid gap-3 border-b border-zinc-100 p-4 dark:border-zinc-800 sm:grid-cols-3">
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Default branch</p>
<p class="mt-1 truncate font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100">{{ $defaultBranch }}</p>
</div>
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Latest branch</p>
<p class="mt-1 truncate font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100">{{ $latestBranch ?: 'No branch linked' }}</p>
</div>
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950/70">
<p class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-zinc-500">Activity status</p>
<p class="mt-1 text-xs font-bold text-zinc-900 dark:text-zinc-100">
@if ($gitActivity->isEmpty())
Awaiting branch or commit
@elseif ($gitActivity->contains(fn ($activity) => $activity->short_hash !== 'branch'))
Commit activity linked
@else
Branch created, awaiting commits
@endif
</p>
</div>
</div>
<div class="space-y-2 p-4">
@forelse ($gitActivity as $commit)
@php
$isBranchActivity = $commit->short_hash === 'branch';
@endphp
<a href="{{ $commit->url ?: '#' }}" target="_blank" rel="noopener noreferrer"
class="block rounded-lg border border-zinc-100 p-2 transition hover:border-violet-200 hover:bg-violet-50/70 focus:outline-none focus:ring-2 focus:ring-violet-400/40 dark:border-zinc-800 dark:hover:border-violet-900 dark:hover:bg-violet-950/30">
<div class="flex items-center justify-between gap-2">
<div class="font-mono text-[0.68rem] text-zinc-500">
{{ $isBranchActivity ? 'branch' : $commit->short_hash }} ·
{{ $commit->repository?->name }}
</div>
<span class="rounded-full px-2 py-1 text-[0.62rem] font-black uppercase tracking-[0.14em] {{ $isBranchActivity ? 'bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200' : 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200' }}">
{{ $isBranchActivity ? 'Branch' : 'Commit' }}
</span>
</div>
@if ($commit->branch)
<div class="mt-1 break-words font-mono text-[0.68rem] font-semibold text-violet-700 dark:text-violet-300">
@@ -1,24 +1,24 @@
<div class="mb-8 overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-sm">
<div class="px-6 py-8 text-white">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-blue-300">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">
{{ $eyebrow ?? 'Helpdesk' }}
</p>
<h1 class="mt-3 text-3xl font-bold tracking-tight">
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
{{ $title ?? 'Helpdesk' }}
</h1>
@if (!empty($subtitle))
<p class="mt-2 max-w-3xl text-sm text-slate-300">
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">
{{ $subtitle }}
</p>
@endif
</div>
@isset($actions)
<div class="flex flex-wrap items-center gap-3">
<div class="flex flex-wrap gap-2">
{{ $actions }}
</div>
@endisset
@@ -1,16 +0,0 @@
@php
$helpdeskEnabled = Route::has('helpdesk.index');
if (class_exists(\Nwidart\Modules\Facades\Module::class)) {
$helpdeskModule = \Nwidart\Modules\Facades\Module::find('Helpdesk');
$helpdeskEnabled = $helpdeskModule !== null && $helpdeskModule->isEnabled() && Route::has('helpdesk.index');
}
@endphp
@if ($helpdeskEnabled)
<a href="{{ route('helpdesk.index') }}" wire:navigate
class="wms-sidebar-link @if (request()->routeIs('helpdesk.*')) is-active @endif" title="{{ __('Support') }}">
<i class="fa-solid fa-headset wms-sidebar-icon" aria-hidden="true"></i>
<span data-wms-sidebar-label>{{ __('Support') }}</span>
</a>
@endif
+2
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketBoardController;
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketControlsController;
@@ -33,6 +34,7 @@ Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
Route::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update');
Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store');
Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy');
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskCreateTicketAttachmentBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
public function test_user_can_create_ticket_with_attachment_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Browser Attachment User']);
$subject = 'Browser attachment ticket ' . uniqid('', true);
$filePath = $this->makeBrowserUploadFile('browser-upload-proof.log', 'Browser upload proof file.');
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $filePath): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->type('@ticket-subject', $subject);
$browser->attach('#attachments', $filePath);
$this->waitForHelpdeskUploadToFinish($browser);
$browser->type('@ticket-subject', $subject);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['high']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves attachments can be uploaded and saved.',
);
$this->submitTicketCreateComponent($browser);
$this->waitForTicketCreated($browser, $subject);
$browser->visitRoute('helpdesk.tickets.index')
->waitForText($subject, 10)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
});
$ticket = Ticket::query()
->where('subject', $subject)
->firstOrFail();
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->whereNull('comment_id')
->where('original_name', 'browser-upload-proof.log')
->firstOrFail();
self::assertSame($ticket->id, (int) $attachment->ticket_id);
}
public function test_user_can_create_ticket_with_multiple_attachments_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Browser Multi Attachment User']);
$subject = 'Browser multiple attachment ticket ' . uniqid('', true);
$firstFile = $this->makeBrowserUploadFile('browser-first-proof.txt', 'First browser upload proof file.');
$secondFile = $this->makeBrowserUploadFile('browser-second-proof.csv', 'name,value' . PHP_EOL . 'proof,1');
$this->browse(function (Browser $browser) use ($user, $fixtures, $subject, $firstFile, $secondFile): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create', 10)
->assertSee('Create ticket')
->type('@ticket-subject', $subject);
$browser->attach('#attachments', $firstFile . "\n" . $secondFile);
$this->waitForHelpdeskUploadToFinish($browser);
$browser->type('@ticket-subject', $subject);
$this->setHelpdeskSelect($browser, 'board_id', $fixtures['board']->id);
$this->setHelpdeskSelect($browser, 'priority_id', $fixtures['normal']->id);
$this->setHelpdeskSelect($browser, 'category_id', $fixtures['category']->id);
$this->setHelpdeskSelect($browser, 'team_id', $fixtures['team']->id);
$this->setHelpdeskSelect($browser, 'project_id', $fixtures['project']->id);
$this->setHelpdeskEditor(
$browser,
'ticket-body-editor',
'This browser ticket proves multiple attachments can be uploaded and saved.',
);
$this->submitTicketCreateComponent($browser);
$this->waitForTicketCreated($browser, $subject);
$browser->visitRoute('helpdesk.tickets.index')
->waitForText($subject, 10)
->assertPathIs('/helpdesk/tickets')
->assertSee($subject);
});
$ticket = Ticket::query()
->where('subject', $subject)
->firstOrFail();
self::assertSame(
2,
Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count(),
);
foreach (['browser-first-proof.txt', 'browser-second-proof.csv'] as $name) {
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => $name,
]);
}
}
private function makeBrowserUploadFile(string $name, string $contents): string
{
$directory = storage_path('framework/testing/helpdesk-browser-uploads');
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
$path = $directory . DIRECTORY_SEPARATOR . $name;
file_put_contents($path, $contents);
return $path;
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $text): void
{
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
$encodedText = json_encode($text, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const key = {$encodedDusk};
const text = {$encodedText};
const root = document.querySelector('[dusk="' + key + '"], [data-dusk="' + key + '"]');
if (! root) {
throw new Error('Helpdesk editor ' + key + ' was not found.');
}
const editor = root.matches('textarea, input, [contenteditable="true"]')
? root
: root.querySelector('textarea, input, [contenteditable="true"]');
if (! editor) {
throw new Error('Helpdesk editor content area ' + key + ' was not found.');
}
editor.focus();
if (editor.matches('textarea, input')) {
editor.value = text;
} else {
editor.textContent = text;
}
editor.dispatchEvent(new InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertText',
data: text,
}));
editor.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
editor.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
const model = root.getAttribute('wire:model')
|| root.getAttribute('wire:model.live')
|| root.getAttribute('wire:model.defer')
|| root.getAttribute('data-wire-model')
|| editor.getAttribute('data-wire-model')
|| editor.getAttribute('data-helpdesk-editor-model')
|| editor.getAttribute('data-helpdesk-sync-target');
let componentRoot = root;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (model && componentRoot && window.Livewire) {
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (component) {
component.set(model, text);
}
}
JS);
$browser->pause(500);
}
private function setHelpdeskSelect(Browser $browser, string $model, int|string|null $value): void
{
$encodedModel = json_encode($model, JSON_THROW_ON_ERROR);
$encodedValue = json_encode($value === null ? '' : (string) $value, JSON_THROW_ON_ERROR);
$browser->script(<<<JS
const model = {$encodedModel};
const value = {$encodedValue};
const select = document.querySelector('[data-wire-model="' + model + '"]');
if (! select) {
throw new Error('Helpdesk select ' + model + ' was not found.');
}
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
if (window.jQuery) {
window.jQuery(select).val(value).trigger('change.select2').trigger('change.helpdesk-select2').trigger('change');
}
let componentRoot = select;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (componentRoot && window.Livewire) {
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (component) {
component.set(model, value === '' ? null : value);
}
}
JS);
$browser->pause(250);
}
private function submitTicketCreateComponent(Browser $browser): void
{
$browser->waitUsing(10, 100, static function () use ($browser): bool {
return (bool) $browser->script(<<<'JS'
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
return Boolean(button) && button.disabled === false;
JS)[0];
}, 'The helpdesk ticket create form was not ready to submit.');
$browser->script(<<<'JS'
const root = document.querySelector('[dusk="helpdesk-ticket-create"], [data-dusk="helpdesk-ticket-create"]');
if (! root) {
throw new Error('Helpdesk ticket create root was not found.');
}
let componentRoot = root;
while (componentRoot && ! componentRoot.hasAttribute('wire:id')) {
componentRoot = componentRoot.parentElement;
}
if (! componentRoot || ! window.Livewire) {
throw new Error('Helpdesk Livewire create component was not found.');
}
const component = window.Livewire.find(componentRoot.getAttribute('wire:id'));
if (! component) {
throw new Error('Helpdesk Livewire create component instance was not found.');
}
component.call('save');
JS);
}
private function waitForHelpdeskUploadToFinish(Browser $browser): void
{
$browser->waitUsing(30, 250, static function () use ($browser): bool {
return (bool) $browser->script(<<<'JS'
const input = document.querySelector('#attachments');
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
const selected = document.querySelector('[dusk="ticket-create-selected-attachments"], [data-dusk="ticket-create-selected-attachments"]');
return Boolean(input)
&& input.files.length > 0
&& Boolean(selected)
&& Boolean(button)
&& button.disabled === false;
JS)[0];
}, 'The Livewire temporary attachment upload did not finish before submit.');
}
private function waitForTicketCreated(Browser $browser, string $subject): void
{
$browser->waitUsing(20, 250, static fn (): bool => Ticket::query()
->where('subject', $subject)
->exists(), 'The browser did not create the helpdesk ticket before the timeout.');
}
}
@@ -48,6 +48,44 @@ final class KanbanBoardTest extends TestCase
->assertSee('High');
}
/** @test */
public function test_ticket_can_be_moved_to_another_board(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$boardA = $lookups['board'];
$boardB = KanbanBoardModel::query()->create([
'name' => 'Regression Target Board',
'slug' => 'regression-target-board',
'is_default' => false,
'sort_order' => 50,
]);
$boardB->statuses()->sync([
$lookups['open']->id => ['sort_order' => 10],
]);
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $boardA->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->call('moveTicketToBoard', $ticket->id, $boardB->id)
->assertHasNoErrors();
$ticket->refresh();
$this->assertSame($boardB->id, $ticket->board_id);
$this->assertSame($lookups['open']->id, $ticket->status_id);
}
/** @test */
public function kanban_board_can_move_ticket_to_closed_status(): void
{
@@ -29,6 +29,12 @@ final class TicketBranchCommandUiFilesTest extends TestCase
self::assertStringContainsString('data-helpdesk-branch-command', $view);
self::assertStringContainsString('git checkout -b bugfix/{{ $ticket->reference }}-', $view);
self::assertStringContainsString('data-helpdesk-branch-suffix', $view);
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
self::assertStringContainsString('feature/', $view);
self::assertStringContainsString('bugfix/', $view);
self::assertStringContainsString('hotfix/', $view);
self::assertStringContainsString('support/', $view);
self::assertStringContainsString('data-helpdesk-copy-branch-command', $view);
self::assertStringContainsString('copyHelpdeskBranchCommand', $view);
self::assertStringContainsString('normaliseHelpdeskBranchSuffix', $view);
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use App\Models\Company;
use Modules\Company\Models\Company;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketCreateAttachmentBrowserSafetyTest extends TestCase
{
public function test_ticket_create_uses_standard_livewire_temporary_uploads(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('wire:submit.prevent="save"', $view);
self::assertStringContainsString('type="file"', $view);
self::assertStringContainsString('wire:model="attachments"', $view);
self::assertStringContainsString('multiple', $view);
self::assertStringContainsString('wire:key="ticket-create-attachments-input"', $view);
}
public function test_ticket_create_does_not_mix_existing_ticket_upload_behaviour_into_create_form(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringNotContainsString('@vite([\'resources/css/app.css\', \'resources/js/app.js\'])', $view);
self::assertStringNotContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringNotContainsString('DataTransfer', $view);
self::assertStringNotContainsString('$wire.uploadMultiple', $view);
self::assertStringNotContainsString('input.files =', $view);
self::assertStringNotContainsString('helpdesk-editor-files-pasted', $view);
}
public function test_ticket_create_shows_uploaded_temporary_file_names_from_livewire_property(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('@if (count($attachments) > 0)', $view);
self::assertStringContainsString('$attachment->getClientOriginalName()', $view);
self::assertStringContainsString('wire:loading wire:target="attachments"', $view);
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketCreateAttachmentPipelineTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
public function test_ticket_create_persists_a_single_attachment_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Attachment Create User']);
$file = UploadedFile::fake()->create('warehouse-error.log', 12, 'text/plain');
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with one attachment')
->set('body', '<p>This ticket must persist a Livewire upload attachment.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['high']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [$file])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with one attachment')
->firstOrFail();
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->whereNull('comment_id')
->firstOrFail();
self::assertSame($user->id, (int) $attachment->user_id);
self::assertSame('local', $attachment->disk);
self::assertSame('warehouse-error.log', $attachment->original_name);
self::assertSame('log', $attachment->extension());
Storage::disk('local')->assertExists($attachment->path);
}
public function test_ticket_create_persists_multiple_attachments_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Multiple Attachment User']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with multiple attachments')
->set('body', '<p>This ticket must persist every selected Livewire upload.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['normal']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [
UploadedFile::fake()->create('first-proof.pdf', 20, 'application/pdf'),
UploadedFile::fake()->create('second-proof.txt', 8, 'text/plain'),
UploadedFile::fake()->create('third-proof.csv', 6, 'text/csv'),
])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with multiple attachments')
->firstOrFail();
self::assertSame(3, Attachment::query()->where('ticket_id', $ticket->id)->whereNull('comment_id')->count());
foreach (['first-proof.pdf', 'second-proof.txt', 'third-proof.csv'] as $name) {
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->where('original_name', $name)
->firstOrFail();
Storage::disk('local')->assertExists($attachment->path);
}
}
public function test_ticket_create_accepts_source_code_attachments_through_livewire(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Source Attachment User']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Create ticket with source attachments')
->set('body', '<p>This ticket must accept source files by extension.</p>')
->set('board_id', $fixtures['board']->id)
->set('priority_id', $fixtures['normal']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->set('attachments', [
UploadedFile::fake()->create('TicketCreate.php', 10, 'text/plain'),
UploadedFile::fake()->create('upload-widget.js', 10, 'text/plain'),
UploadedFile::fake()->create('warehouse-sync.rpgle', 10, 'text/plain'),
])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('helpdesk.tickets.index'));
$ticket = Ticket::query()
->where('subject', 'Create ticket with source attachments')
->firstOrFail();
foreach (['TicketCreate.php', 'upload-widget.js', 'warehouse-sync.rpgle'] as $name) {
$attachment = Attachment::query()
->where('ticket_id', $ticket->id)
->where('original_name', $name)
->firstOrFail();
self::assertTrue($attachment->isSourceCode(), $name . ' should be categorised as source code.');
Storage::disk('local')->assertExists($attachment->path);
}
}
}
@@ -0,0 +1,70 @@
<?php
/**
* TicketDeveloperActionsUiFilesTest.php
*
* Guards the ticket-level developer actions workflow against UI regressions.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketDeveloperActionsUiFilesTest extends TestCase
{
/**
* Ensure the Git activity card has been promoted to a complete developer actions panel.
*/
public function test_ticket_show_exposes_developer_actions_panel(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('data-helpdesk-developer-actions', $view);
self::assertStringContainsString('Developer actions', $view);
self::assertStringContainsString('Open repo', $view);
self::assertStringContainsString('Open branch', $view);
self::assertStringContainsString('Pull requests', $view);
self::assertStringContainsString('Activity status', $view);
}
/**
* Ensure the panel keeps copyable GitFlow branch, commit and pull request templates.
*/
public function test_ticket_show_exposes_copyable_gitflow_templates(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('Branch create command', $view);
self::assertStringContainsString('\'git checkout -b bugfix/\' . $ticket->reference . \'-\'', $view);
self::assertStringContainsString('data-helpdesk-branch-prefix-select', $view);
self::assertStringContainsString('$gitflowPrefixes', $view);
self::assertStringContainsString('normaliseHelpdeskBranchPrefix', $view);
self::assertStringContainsString('Commit message', $view);
self::assertStringContainsString('Pull request title', $view);
self::assertStringContainsString('data-helpdesk-copy-template', $view);
self::assertStringContainsString('data-helpdesk-copy-template-button', $view);
self::assertStringContainsString('copyHelpdeskTemplate', $view);
self::assertStringContainsString('copyTextToClipboard', $view);
}
/**
* Ensure linked Git activity still renders branch and commit badges instead of being hidden by the new panel.
*/
public function test_ticket_show_still_renders_linked_git_activity_rows(): void
{
$viewPath = dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-show.blade.php';
$view = file_get_contents($viewPath);
self::assertIsString($view);
self::assertStringContainsString('@forelse ($gitActivity as $commit)', $view);
self::assertStringContainsString('$isBranchActivity = $commit->short_hash === \'branch\';', $view);
self::assertStringContainsString('{{ $isBranchActivity ? \'Branch\' : \'Commit\' }}', $view);
self::assertStringContainsString('No Git activity linked.', $view);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketIndexDirectUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_index_eager_loads_required_relations(): void
{
$source = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
self::assertIsString($source);
self::assertStringContainsString("->with(['status', 'priority', 'category', 'project', 'team'])", $source);
self::assertStringNotContainsString("'user:id,name,email'", $source);
self::assertStringNotContainsString("'owner:id,name,email'", $source);
self::assertStringNotContainsString("'agent:id,name,email'", $source);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketIndexUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_index_uses_scalar_agent_name(): void
{
$component = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
$view = file_get_contents(dirname(__DIR__, 2).'/Resources/views/livewire/ticket-index.blade.php');
self::assertIsString($component);
self::assertIsString($view);
self::assertStringContainsString("->select('helpdesk_tickets.*')", $component);
self::assertStringContainsString("'agent_name' => User::query()", $component);
self::assertStringContainsString("->whereColumn('users.id', 'helpdesk_tickets.agent_id')", $component);
self::assertStringContainsString('$ticket->agent_name ?? \'Unassigned\'', $view);
self::assertStringNotContainsString('$ticket->agent?->name ?? \'Unassigned\'', $view);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class TicketShowCommentRelationsFilesTest extends TestCase
{
public function test_ticket_show_only_eager_loads_real_comment_relations(): void
{
$source = file_get_contents(dirname(__DIR__, 2) . '/Livewire/TicketShow.php');
self::assertIsString($source);
self::assertStringContainsString("->with(['user.company', 'attachments'])", $source);
self::assertStringNotContainsString("->with(['user.company', 'owner:id,name,email,company_id', 'agent:id,name,email', 'attachments'])", $source);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\TestCase;
final class TicketUserNPlusOneFilesTest extends TestCase
{
public function test_ticket_queries_only_eager_load_required_relations(): void
{
$index = file_get_contents(dirname(__DIR__, 2).'/Livewire/TicketIndex.php');
$kanban = file_get_contents(dirname(__DIR__, 2).'/Livewire/KanbanBoard.php');
$dashboard = file_get_contents(dirname(__DIR__, 2).'/Livewire/HelpdeskDashboardWidget.php');
self::assertIsString($index);
self::assertIsString($kanban);
self::assertIsString($dashboard);
// Index deliberately avoids loading user relations.
self::assertStringNotContainsString("'user:id,name,email'", $index);
self::assertStringNotContainsString("'owner:id,name,email'", $index);
self::assertStringNotContainsString("'agent:id,name,email'", $index);
self::assertStringContainsString("'owner:id,name,email'", $kanban);
self::assertStringContainsString("'agent:id,name,email'", $kanban);
// Dashboard intentionally only loads the relations it renders.
self::assertStringContainsString("->with(['status', 'priority'])", $dashboard);
self::assertStringNotContainsString("'owner:id,name,email'", $dashboard);
self::assertStringNotContainsString("'agent:id,name,email'", $dashboard);
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,5 +23,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.10.30"
"version": "1.10.42"
}