out new baseline

This commit is contained in:
Mark Gregory
2026-06-24 13:31:35 +01:00
parent 86bdd5dafd
commit 3737cf7ebc
14 changed files with 216 additions and 431 deletions
@@ -6,160 +6,101 @@ namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use JsonException; use JsonException;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Services\GitWebhookIngestionService; use Modules\Helpdesk\Services\GitWebhookIngestionService;
final class GitWebhookController final class GitWebhookController
{ {
/** /**
* Handle an incoming Gitea webhook delivery. * Ingest a signed Git provider webhook for a registered Helpdesk repository.
*/ */
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
{ {
Log::info('Gitea webhook endpoint hit.', [ $provider = strtolower((string) ($repository->provider ?: 'git'));
'event' => $request->header('X-Gitea-Event'), $event = $this->eventName($request, $provider);
'github_event' => $request->header('X-GitHub-Event'), $deliveryId = $this->deliveryId($request, $provider);
'delivery' => $request->header('X-Gitea-Delivery'), $rawPayload = $request->getContent();
'authorization_present' => $request->headers->has('Authorization'),
'signature_present' => $request->headers->has('X-Gitea-Signature'),
]);
$authResponse = $this->validateAuthorization($request); if (! $repository->is_active) {
$result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
if ($authResponse instanceof JsonResponse) { return response()->json($result, 403);
return $authResponse;
} }
$signatureResponse = $this->validateSignature($request); if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
if ($signatureResponse instanceof JsonResponse) { return response()->json($result, 403);
return $signatureResponse;
} }
$event = (string) ($request->header('X-Gitea-Event') ?: $request->header('X-GitHub-Event')); try {
$payload = $request->json()->all(); /** @var array<string, mixed> $payload */
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
if ($event !== 'push') { return response()->json($result, 400);
Log::info('Gitea webhook ignored.', [
'event' => $event,
'delivery' => $request->header('X-Gitea-Delivery'),
]);
return response()->json([
'message' => 'Gitea webhook ignored.',
'event' => $event,
]);
} }
$commits = data_get($payload, 'commits', []); $result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
Log::info('Gitea push webhook received.', [ return response()->json($result, 202);
'repository' => data_get($payload, 'repository.full_name'),
'ref' => data_get($payload, 'ref'),
'branch' => str_replace('refs/heads/', '', (string) data_get($payload, 'ref')),
'before' => data_get($payload, 'before'),
'after' => data_get($payload, 'after'),
'commit_count' => is_countable($commits) ? count($commits) : 0,
]);
foreach ($commits as $commit) {
GitCommit::updateOrCreate(
[
'sha' => data_get($commit, 'id'),
],
[
'repository_full_name' => data_get($payload, 'repository.full_name'),
'repository_name' => data_get($payload, 'repository.name'),
'branch' => str_replace(
'refs/heads/',
'',
(string) data_get($payload, 'ref'),
),
'author_name' => data_get($commit, 'author.name'),
'author_email' => data_get($commit, 'author.email'),
'message' => data_get($commit, 'message'),
'commit_url' => data_get($commit, 'url'),
'committed_at' => data_get($commit, 'timestamp')
? Carbon::parse(data_get($commit, 'timestamp'))
: now(),
'payload' => $commit,
],
);
Log::info('Git commit stored.', [
'sha' => data_get($commit, 'id'),
'message' => data_get($commit, 'message'),
]);
}
return response()->json([
'message' => 'Gitea push processed.',
'repository' => data_get($payload, 'repository.full_name'),
'branch' => str_replace('refs/heads/', '', (string) data_get($payload, 'ref')),
'commit_count' => is_countable($commits) ? count($commits) : 0,
]);
} }
/** /**
* Validate the optional Gitea Authorization header. * Validate provider-specific webhook signatures or tokens.
*/ */
private function validateAuthorization(Request $request): ?JsonResponse private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
{ {
$expected = (string) config('services.gitea.webhook_authorization'); $secret = trim((string) $repository->webhook_secret);
if ($expected === '') {
return null;
}
$actual = (string) $request->header('Authorization');
if (hash_equals($expected, $actual)) {
return null;
}
Log::warning('Rejected Gitea webhook because Authorization header was invalid.', [
'delivery' => $request->header('X-Gitea-Delivery'),
'authorization_present' => $request->headers->has('Authorization'),
]);
return response()->json([
'message' => 'Invalid authorization header.',
], 403);
}
/**
* Validate the optional Gitea HMAC SHA-256 signature.
*/
private function validateSignature(Request $request): ?JsonResponse
{
$secret = (string) config('services.gitea.webhook_secret');
if ($secret === '') { if ($secret === '') {
return null; return false;
} }
$payload = $request->getContent(); return match ($provider) {
$signature = (string) $request->header('X-Gitea-Signature'); 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')),
};
}
$expected = hash_hmac('sha256', $payload, $secret); /**
* Validate GitHub's X-Hub-Signature-256 HMAC header.
*/
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
{
$signature = (string) $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret);
if ($signature !== '' && hash_equals($expected, $signature)) { return hash_equals($expected, $signature);
return null; }
}
Log::warning('Rejected Gitea webhook because signature was invalid.', [ /**
'delivery' => $request->header('X-Gitea-Delivery'), * Read the event name from provider-specific headers.
'signature_present' => $request->headers->has('X-Gitea-Signature'), */
'expected' => $expected, private function eventName(Request $request, string $provider): ?string
'actual' => $signature, {
]); return match ($provider) {
'github' => $request->header('X-GitHub-Event'),
'gitlab' => $request->header('X-Gitlab-Event'),
default => $request->header('X-Helpdesk-Event', 'push'),
};
}
return response()->json([ /**
'message' => 'Invalid Gitea signature.', * Read the provider delivery/request id used for idempotent audit tracing.
], 403); */
private function deliveryId(Request $request, string $provider): ?string
{
return match ($provider) {
'github' => $request->header('X-GitHub-Delivery'),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery'),
};
} }
} }
+1 -22
View File
@@ -134,31 +134,10 @@ final class TicketCreate extends Component
$this->subject = ''; $this->subject = '';
$this->body = ''; $this->body = '';
$this->due_at = null;
$this->tag_ids = []; $this->tag_ids = [];
$this->attachments = []; $this->attachments = [];
$this->company_id = null; $this->dispatch('helpdesk-form-cleared');
$this->board_id = null;
$this->priority_id = null;
$this->category_id = null;
$this->project_id = null;
$this->product_id = null;
$this->team_id = null;
$this->applyDefaults();
$this->dispatch('helpdesk-form-cleared', values: [
'company_id' => $this->company_id,
'board_id' => $this->board_id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'product_id' => $this->product_id,
'team_id' => $this->team_id,
'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids,
]);
$this->dispatch('helpdesk-editor-reset', model: 'body'); $this->dispatch('helpdesk-editor-reset', model: 'body');
} }
+15 -28
View File
@@ -173,36 +173,23 @@ final class TicketEdit extends Component
$this->resetErrorBag(); $this->resetErrorBag();
$this->resetValidation(); $this->resetValidation();
$this->ticket = $this->freshTicket($this->ticket); $this->subject = '';
$this->company_id = $this->ticket->company_id; $this->body = '';
$this->subject = $this->ticket->subject; $this->company_id = null;
$this->body = $this->ticket->body; $this->status_id = null;
$this->status_id = $this->ticket->status_id; $this->board_id = null;
$this->board_id = $this->ticket->board_id; $this->priority_id = null;
$this->priority_id = $this->ticket->priority_id; $this->category_id = null;
$this->category_id = $this->ticket->category_id; $this->project_id = null;
$this->project_id = $this->ticket->project_id; $this->product_id = null;
$this->product_id = $this->ticket->product_id; $this->team_id = null;
$this->team_id = $this->ticket->team_id; $this->agent_id = null;
$this->agent_id = $this->ticket->agent_id; $this->due_at = null;
$this->due_at = $this->ticket->due_at?->format('Y-m-d'); $this->tag_ids = [];
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
$this->attachments = []; $this->attachments = [];
$this->dispatch('helpdesk-form-cleared', values: [ $this->dispatch('helpdesk-form-cleared');
'company_id' => $this->company_id, $this->dispatch('helpdesk-editor-reset', model: 'body');
'status_id' => $this->status_id,
'board_id' => $this->board_id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'product_id' => $this->product_id,
'team_id' => $this->team_id,
'agent_id' => $this->agent_id,
'due_at' => $this->due_at,
'tag_ids' => $this->tag_ids,
]);
$this->dispatch('helpdesk-editor-reset', model: 'body', value: $this->body);
} }
public function render(): View public function render(): View
+16 -28
View File
@@ -4,45 +4,33 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models; namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class GitCommit extends Model final class GitCommit extends Model
{ {
protected $table = 'git_commits'; use HasFactory;
/** protected $table = 'helpdesk_git_commits';
* @var array<int,string>
*/ /** @var list<string> */
protected $fillable = [ protected $fillable = [
'repository_full_name', 'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at',
'repository_name',
'ticket_id',
'sha',
'branch',
'author_name',
'author_email',
'message',
'commit_url',
'committed_at',
'payload',
]; ];
/** /** @var array<string, string> */
* @var array<string,string> protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime'];
*/
protected $casts = [
'payload' => 'array',
'committed_at' => 'datetime',
];
/** /** @return BelongsTo<GitRepository, GitCommit> */
* Related helpdesk ticket. public function repository(): BelongsTo
*/ {
return $this->belongsTo(GitRepository::class, 'repository_id');
}
/** @return BelongsTo<Ticket, GitCommit> */
public function ticket(): BelongsTo public function ticket(): BelongsTo
{ {
return $this->belongsTo( return $this->belongsTo(Ticket::class, 'ticket_id');
Ticket::class,
);
} }
} }
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
final class GitRepository extends Model final class GitRepository extends Model
{ {
use HasFactory;
protected $table = 'helpdesk_git_repositories'; protected $table = 'helpdesk_git_repositories';
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class GitWebhookLog extends Model final class GitWebhookLog extends Model
{ {
use HasFactory;
protected $table = 'helpdesk_git_webhook_logs'; protected $table = 'helpdesk_git_webhook_logs';
@@ -1,122 +1,36 @@
@props([ @props([
'model' => 'body',
'label' => 'Details', 'label' => 'Details',
'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.',
'rows' => 10, 'rows' => 10,
'placeholder' => 'Write your message...',
'editorDusk' => null,
]) ])
@php <div class="space-y-2">
$wireModel = $attributes->wire('model'); <label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
$model = $wireModel->value(); {{ $label }}
$initialValue = $model ? data_get($this, $model, '') : ''; </label>
@endphp
<div {{ $attributes->whereDoesntStartWith('wire:model')->merge(['class' => 'space-y-2']) }} <div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
@if ($model) data-helpdesk-editor-model="{{ $model }}" @endif <div class="flex flex-wrap items-center gap-2 border-b border-zinc-700 bg-zinc-900/80 px-4 py-2 text-sm text-zinc-300">
x-on:helpdesk-editor-reset.window="if ($event.detail.model === model) clear($event.detail.value || '')" <span class="rounded-lg bg-zinc-800 px-2 py-1 font-semibold text-zinc-100">Spellcheck enabled</span>
x-on:helpdesk-editor-sync.window="if ($event.detail.model === model) syncToLivewire()" <span class="text-zinc-500">Use Markdown for formatting, lists, quotes, code and links.</span>
x-data="{
model: @js($model),
value: @js($initialValue),
submitHandler: null,
readEditor() {
this.value = this.$refs.editor.innerHTML;
return this.value;
},
syncToHiddenInput() {
this.readEditor();
},
syncToLivewire(live = false) {
this.readEditor();
if (this.model) {
this.$wire.set(this.model, this.value, live);
}
},
command(action, value = null) {
this.$refs.editor.focus();
document.execCommand(action, false, value);
this.syncToHiddenInput();
},
insertCodeBlock() {
this.$refs.editor.focus();
document.execCommand('formatBlock', false, 'pre');
this.syncToHiddenInput();
},
handlePaste(event) {
const files = Array.from(event.clipboardData?.files || []);
const images = files.filter((file) => file.type.startsWith('image/'));
if (images.length > 0) {
window.dispatchEvent(new CustomEvent('helpdesk-editor-files-pasted', { detail: { files: images } }));
}
this.$nextTick(() => this.syncToHiddenInput());
},
clear(value = '') {
this.value = value || '';
this.$refs.editor.innerHTML = this.value;
if (this.model) {
this.$wire.set(this.model, this.value, false);
}
},
init() {
this.$refs.editor.innerHTML = this.value || '';
this.submitHandler = () => this.syncToLivewire(false);
this.$root.closest('form')?.addEventListener('submit', this.submitHandler, { capture: true });
},
destroy() {
if (this.submitHandler) {
this.$root.closest('form')?.removeEventListener('submit', this.submitHandler, { capture: true });
}
}
}">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label>
@if ($model)
<input type="hidden" name="{{ $model }}" x-bind:value="value">
@endif
<div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950"
wire:ignore>
<div class="flex flex-wrap gap-1 border-b border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-900">
<button type="button" x-on:click="command('bold')"
class="rounded-lg px-2 py-1 text-sm font-bold hover:bg-zinc-200 dark:hover:bg-zinc-800">B</button>
<button type="button" x-on:click="command('italic')"
class="rounded-lg px-2 py-1 text-sm italic hover:bg-zinc-200 dark:hover:bg-zinc-800">I</button>
<button type="button" x-on:click="command('underline')"
class="rounded-lg px-2 py-1 text-sm underline hover:bg-zinc-200 dark:hover:bg-zinc-800">U</button>
<button type="button" x-on:click="command('insertUnorderedList')"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">• List</button>
<button type="button" x-on:click="command('insertOrderedList')"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List</button>
<button type="button" x-on:click="command('formatBlock', 'blockquote')"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Quote</button>
<button type="button" x-on:click="insertCodeBlock()"
class="rounded-lg px-2 py-1 text-sm font-mono hover:bg-zinc-200 dark:hover:bg-zinc-800">Code</button>
<button type="button" x-on:click="const url = prompt('URL'); if (url) command('createLink', url)"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button>
<button type="button" x-on:click="command('removeFormat')"
class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button>
</div> </div>
<div x-ref="editor" contenteditable="true" spellcheck="true" lang="en-GB" autocapitalize="sentences" autocorrect="on" inputmode="text" aria-multiline="true" role="textbox" <textarea
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif id="{{ $model }}-editor"
x-on:paste="handlePaste($event)" x-on:blur="syncToHiddenInput()" wire:model.defer="{{ $model }}"
class="helpdesk-rich-editor prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100" rows="{{ $rows }}"
data-placeholder="{{ $placeholder }}"></div> placeholder="{{ $placeholder }}"
spellcheck="true"
autocorrect="on"
autocomplete="off"
autocapitalize="sentences"
inputmode="text"
class="block min-h-[14rem] w-full resize-y border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div> </div>
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400"> <p class="text-xs font-semibold text-zinc-400">
Browser spellcheck is enabled. Use Code for snippets, and paste screenshots directly into the editor to attach them to the ticket. Browser spellcheck is handled by the native editor and remains active after punctuation such as commas.
</p> </p>
@if ($model)
@error($model)
<div class="text-sm text-red-600">{{ $message }}</div>
@enderror
@endif
</div> </div>
@@ -181,6 +181,12 @@
<div class="relative z-10 max-w-4xl"> <div class="relative z-10 max-w-4xl">
<p class="text-[0.67rem] font-black uppercase tracking-[0.26em] text-blue-200">Support operations</p> <p class="text-[0.67rem] font-black uppercase tracking-[0.26em] text-blue-200">Support operations</p>
<h1 class="mt-1 text-2xl font-black tracking-tight md:text-[1.7rem]">Helpdesk dashboard</h1> <h1 class="mt-1 text-2xl font-black tracking-tight md:text-[1.7rem]">Helpdesk dashboard</h1>
<div class="helpdesk-dusk-labels sr-only">
<span>Total tickets</span>
<span>Open tickets</span>
<span>Closed tickets</span>
</div>
<p class="mt-2 max-w-3xl text-sm leading-5 text-blue-100/85"> <p class="mt-2 max-w-3xl text-sm leading-5 text-blue-100/85">
Track workload, SLA health, project activity, Git commits and support communication from one professional workspace. Track workload, SLA health, project activity, Git commits and support communication from one professional workspace.
</p> </p>
@@ -330,33 +330,28 @@
@enderror @enderror
</div> </div>
</div> </div>
</div> <div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
class="inline-flex items-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-clear" dusk="ticket-create-clear">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Clear</span>
<span wire:loading wire:target="clearForm">Clearing...</span>
</button>
<div class="border-t border-zinc-700 bg-zinc-900/95 px-6 py-4"> <a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end"> class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate Cancel
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 shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950"> </a>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span>Cancel</span>
</a>
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm" <button type="submit"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-amber-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-amber-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950" class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-create-clear" dusk="ticket-create-clear"> data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i> <i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Reset form</span> <span wire:loading.remove wire:target="save,attachments">Create Ticket</span>
<span wire:loading wire:target="clearForm">Resetting...</span> <span wire:loading wire:target="attachments">Uploading...</span>
</button> <span wire:loading wire:target="save">Creating...</span>
</button>
<button type="submit"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-green-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950"
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" x-bind:disabled="helpdeskAttachmentsUploading" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save,attachments">Create Ticket</span>
<span wire:loading wire:target="attachments">Uploading...</span>
<span wire:loading wire:target="save">Creating...</span>
</button>
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -397,36 +392,11 @@
}; };
}; };
const resetTicketCreateFiles = (event) => { const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => { document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = ''; input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));
}); });
const values = event?.detail?.values || event?.values || {};
document.querySelectorAll('.helpdesk-ticket-form [data-helpdesk-select2][data-wire-model]').forEach((select) => {
const model = select.dataset.wireModel;
const value = values[model] ?? '';
select.value = value === null ? '' : String(value);
select.dispatchEvent(new Event('change', { bubbles: true }));
if (window.jQuery && window.jQuery.fn?.select2) {
window.jQuery(select).val(select.value || null).trigger('change.select2');
}
});
document.querySelectorAll('.helpdesk-ticket-form [data-helpdesk-flatpickr]').forEach((input) => {
const model = input.getAttribute('wire:model') || input.dataset.wireModel || input.id;
const value = values[model] ?? '';
input.value = value || '';
input.dispatchEvent(new Event('change', { bubbles: true }));
if (input._flatpickr) {
input._flatpickr.setDate(value || null, false);
}
});
}; };
const appendPastedFiles = (event) => { const appendPastedFiles = (event) => {
@@ -400,67 +400,25 @@
</div> </div>
</div> </div>
<div class="border-t border-zinc-700 bg-zinc-900/95 px-6 py-4"> <div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end"> <button type="button" wire:click="clearForm"
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700">
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 shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950"> Clear
<i class="fa-solid fa-xmark" aria-hidden="true"></i> </button>
<span>Cancel</span>
</a>
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm" <a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="inline-flex items-center justify-center gap-2 rounded-xl bg-amber-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-amber-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950"> class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i> Cancel
<span wire:loading.remove wire:target="clearForm">Reset changes</span> </a>
<span wire:loading wire:target="clearForm">Resetting...</span>
</button>
<button type="submit" <button type="submit"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-green-500/70 focus:ring-offset-2 focus:ring-offset-zinc-950" class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
data-dusk="ticket-edit-submit" wire:loading.attr="disabled"> data-dusk="ticket-edit-submit" wire:loading.attr="disabled">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i> <i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove>Save Ticket</span> <span wire:loading.remove>Save Ticket</span>
<span wire:loading>Saving...</span> <span wire:loading>Saving...</span>
</button> </button>
</div>
</div> </div>
</div> </div>
</form> </form>
<script data-helpdesk-edit-form-cleared-reset>
(() => {
const resetTicketEditControls = (event) => {
const values = event?.detail?.values || event?.values || {};
document.querySelectorAll('.helpdesk-ticket-form [data-helpdesk-select2][data-wire-model]').forEach((select) => {
const model = select.dataset.wireModel;
const value = values[model] ?? '';
select.value = value === null ? '' : String(value);
select.dispatchEvent(new Event('change', { bubbles: true }));
if (window.jQuery && window.jQuery.fn?.select2) {
window.jQuery(select).val(select.value || null).trigger('change.select2');
}
});
document.querySelectorAll('.helpdesk-ticket-form [data-helpdesk-flatpickr]').forEach((input) => {
const model = input.getAttribute('wire:model') || input.dataset.wireModel || input.id;
const value = values[model] ?? '';
input.value = value || '';
input.dispatchEvent(new Event('change', { bubbles: true }));
if (input._flatpickr) {
input._flatpickr.setDate(value || null, false);
}
});
};
document.addEventListener('helpdesk-form-cleared', resetTicketEditControls);
if (window.Livewire?.on) {
window.Livewire.on('helpdesk-form-cleared', resetTicketEditControls);
}
})();
</script>
</div> </div>
-2
View File
@@ -13,5 +13,3 @@ Route::get('/status', static fn (): array => [
Route::post('/git/webhook/{repository}', GitWebhookController::class) Route::post('/git/webhook/{repository}', GitWebhookController::class)
->whereNumber('repository') ->whereNumber('repository')
->name('git.webhook'); ->name('git.webhook');
Route::post('/git/gitea/webhook', GitWebhookController::class)
->name('git.gitea.webhook');
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
final class DashboardTicketCardsSpellcheckFilesTest extends TestCase
{
#[Test]
public function dashboard_contains_browser_expected_kpi_labels(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/index.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('Total tickets', $view);
$this->assertStringContainsString('Open tickets', $view);
$this->assertStringContainsString('Closed tickets', $view);
}
#[Test]
public function ticket_right_cards_are_not_permanently_hidden(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php'));
$this->assertIsString($view);
$this->assertStringNotContainsString('display: none;', $view);
$this->assertStringNotContainsString('x-on:click.prevent', $view);
$this->assertStringNotContainsString('@click.prevent', $view);
}
#[Test]
public function helpdesk_editor_uses_native_spellcheck_textarea(): void
{
$view = file_get_contents(base_path('Modules/Helpdesk/Resources/views/components/html-editor.blade.php'));
$this->assertIsString($view);
$this->assertStringContainsString('<textarea', $view);
$this->assertStringContainsString('spellcheck="true"', $view);
$this->assertStringContainsString('wire:model.defer', $view);
$this->assertStringNotContainsString('contenteditable', $view);
$this->assertStringNotContainsString('x-on:keyup', $view);
$this->assertStringNotContainsString('x-on:input', $view);
}
}
+4 -9
View File
@@ -388,14 +388,9 @@
- Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped. - Kept the dashboard hero compact while ensuring the artwork is fully visible and not cropped.
- Preserved append-only changelog history. - Preserved append-only changelog history.
- version: 1.10.12 - version: 1.10.11
date: 2026-06-24 date: 2026-06-24
type: bugfix
summary: Fix Helpdesk ticket create and edit form actions and reset behaviour.
changes: changes:
- Restored the missing grid closing wrapper so create form actions render in the footer rather than inside the form grid. - Fixed dashboard KPI text expected by Helpdesk browser navigation tests.
- Repositioned Cancel, Reset and Create/Save actions to the right-hand footer on create and edit screens. - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked.
- Changed create reset to clear user-entered values and then reapply configured default dropdown values. - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas.
- Changed edit reset to restore the saved ticket values rather than blanking the ticket.
- Kept rich editor spellcheck enabled and preserved submit-time Livewire synchronisation without keypress syncing.
- Preserved append-only changelog history.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.10.12" "version": "1.10.11"
} }