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\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use JsonException;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Services\GitWebhookIngestionService;
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.', [
'event' => $request->header('X-Gitea-Event'),
'github_event' => $request->header('X-GitHub-Event'),
'delivery' => $request->header('X-Gitea-Delivery'),
'authorization_present' => $request->headers->has('Authorization'),
'signature_present' => $request->headers->has('X-Gitea-Signature'),
]);
$provider = strtolower((string) ($repository->provider ?: 'git'));
$event = $this->eventName($request, $provider);
$deliveryId = $this->deliveryId($request, $provider);
$rawPayload = $request->getContent();
$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 $authResponse;
return response()->json($result, 403);
}
$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 $signatureResponse;
return response()->json($result, 403);
}
$event = (string) ($request->header('X-Gitea-Event') ?: $request->header('X-GitHub-Event'));
$payload = $request->json()->all();
try {
/** @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') {
Log::info('Gitea webhook ignored.', [
'event' => $event,
'delivery' => $request->header('X-Gitea-Delivery'),
]);
return response()->json([
'message' => 'Gitea webhook ignored.',
'event' => $event,
]);
return response()->json($result, 400);
}
$commits = data_get($payload, 'commits', []);
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
Log::info('Gitea push webhook received.', [
'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,
]);
return response()->json($result, 202);
}
/**
* 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');
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');
$secret = trim((string) $repository->webhook_secret);
if ($secret === '') {
return null;
return false;
}
$payload = $request->getContent();
$signature = (string) $request->header('X-Gitea-Signature');
return match ($provider) {
'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 null;
}
return hash_equals($expected, $signature);
}
Log::warning('Rejected Gitea webhook because signature was invalid.', [
'delivery' => $request->header('X-Gitea-Delivery'),
'signature_present' => $request->headers->has('X-Gitea-Signature'),
'expected' => $expected,
'actual' => $signature,
]);
/**
* Read the event name from provider-specific headers.
*/
private function eventName(Request $request, string $provider): ?string
{
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.',
], 403);
/**
* Read the provider delivery/request id used for idempotent audit tracing.
*/
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->body = '';
$this->due_at = null;
$this->tag_ids = [];
$this->attachments = [];
$this->company_id = null;
$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-form-cleared');
$this->dispatch('helpdesk-editor-reset', model: 'body');
}
+15 -28
View File
@@ -173,36 +173,23 @@ final class TicketEdit extends Component
$this->resetErrorBag();
$this->resetValidation();
$this->ticket = $this->freshTicket($this->ticket);
$this->company_id = $this->ticket->company_id;
$this->subject = $this->ticket->subject;
$this->body = $this->ticket->body;
$this->status_id = $this->ticket->status_id;
$this->board_id = $this->ticket->board_id;
$this->priority_id = $this->ticket->priority_id;
$this->category_id = $this->ticket->category_id;
$this->project_id = $this->ticket->project_id;
$this->product_id = $this->ticket->product_id;
$this->team_id = $this->ticket->team_id;
$this->agent_id = $this->ticket->agent_id;
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
$this->subject = '';
$this->body = '';
$this->company_id = null;
$this->status_id = null;
$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->agent_id = null;
$this->due_at = null;
$this->tag_ids = [];
$this->attachments = [];
$this->dispatch('helpdesk-form-cleared', values: [
'company_id' => $this->company_id,
'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);
$this->dispatch('helpdesk-form-cleared');
$this->dispatch('helpdesk-editor-reset', model: 'body');
}
public function render(): View
+16 -28
View File
@@ -4,45 +4,33 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class GitCommit extends Model
{
protected $table = 'git_commits';
use HasFactory;
/**
* @var array<int,string>
*/
protected $table = 'helpdesk_git_commits';
/** @var list<string> */
protected $fillable = [
'repository_full_name',
'repository_name',
'ticket_id',
'sha',
'branch',
'author_name',
'author_email',
'message',
'commit_url',
'committed_at',
'payload',
'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at',
];
/**
* @var array<string,string>
*/
protected $casts = [
'payload' => 'array',
'committed_at' => 'datetime',
];
/** @var array<string, string> */
protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime'];
/**
* Related helpdesk ticket.
*/
/** @return BelongsTo<GitRepository, GitCommit> */
public function repository(): BelongsTo
{
return $this->belongsTo(GitRepository::class, 'repository_id');
}
/** @return BelongsTo<Ticket, GitCommit> */
public function ticket(): BelongsTo
{
return $this->belongsTo(
Ticket::class,
);
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
final class GitRepository extends Model
{
use HasFactory;
protected $table = 'helpdesk_git_repositories';
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class GitWebhookLog extends Model
{
use HasFactory;
protected $table = 'helpdesk_git_webhook_logs';
@@ -1,122 +1,36 @@
@props([
'model' => 'body',
'label' => 'Details',
'placeholder' => 'Describe the ticket, include steps to reproduce, expected result, actual result and any useful technical details.',
'rows' => 10,
'placeholder' => 'Write your message...',
'editorDusk' => null,
])
@php
$wireModel = $attributes->wire('model');
$model = $wireModel->value();
$initialValue = $model ? data_get($this, $model, '') : '';
@endphp
<div class="space-y-2">
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
{{ $label }}
</label>
<div {{ $attributes->whereDoesntStartWith('wire:model')->merge(['class' => 'space-y-2']) }}
@if ($model) data-helpdesk-editor-model="{{ $model }}" @endif
x-on:helpdesk-editor-reset.window="if ($event.detail.model === model) clear($event.detail.value || '')"
x-on:helpdesk-editor-sync.window="if ($event.detail.model === model) syncToLivewire()"
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 class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-950 shadow-sm">
<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">
<span class="rounded-lg bg-zinc-800 px-2 py-1 font-semibold text-zinc-100">Spellcheck enabled</span>
<span class="text-zinc-500">Use Markdown for formatting, lists, quotes, code and links.</span>
</div>
<div x-ref="editor" contenteditable="true" spellcheck="true" lang="en-GB" autocapitalize="sentences" autocorrect="on" inputmode="text" aria-multiline="true" role="textbox"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
x-on:paste="handlePaste($event)" x-on:blur="syncToHiddenInput()"
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"
data-placeholder="{{ $placeholder }}"></div>
<textarea
id="{{ $model }}-editor"
wire:model.defer="{{ $model }}"
rows="{{ $rows }}"
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>
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
Browser spellcheck is enabled. Use Code for snippets, and paste screenshots directly into the editor to attach them to the ticket.
<p class="text-xs font-semibold text-zinc-400">
Browser spellcheck is handled by the native editor and remains active after punctuation such as commas.
</p>
@if ($model)
@error($model)
<div class="text-sm text-red-600">{{ $message }}</div>
@enderror
@endif
</div>
@@ -181,6 +181,12 @@
<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>
<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">
Track workload, SLA health, project activity, Git commits and support communication from one professional workspace.
</p>
@@ -330,33 +330,28 @@
@enderror
</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">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
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">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span>Cancel</span>
</a>
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
Cancel
</a>
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
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"
data-dusk="ticket-create-clear" dusk="ticket-create-clear">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Reset form</span>
<span wire:loading wire:target="clearForm">Resetting...</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>
<button type="submit"
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-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>
</form>
@@ -397,36 +392,11 @@
};
};
const resetTicketCreateFiles = (event) => {
const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = '';
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) => {
@@ -400,67 +400,25 @@
</div>
</div>
<div class="border-t border-zinc-700 bg-zinc-900/95 px-6 py-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
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">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span>Cancel</span>
</a>
<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"
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-700">
Clear
</button>
<button type="button" wire:click="clearForm" wire:loading.attr="disabled" wire:target="clearForm"
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">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
<span wire:loading.remove wire:target="clearForm">Reset changes</span>
<span wire:loading wire:target="clearForm">Resetting...</span>
</button>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
Cancel
</a>
<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-edit-submit" wire:loading.attr="disabled">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove>Save Ticket</span>
<span wire:loading>Saving...</span>
</button>
</div>
<button type="submit"
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">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
<span wire:loading.remove>Save Ticket</span>
<span wire:loading>Saving...</span>
</button>
</div>
</div>
</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>
-2
View File
@@ -13,5 +13,3 @@ Route::get('/status', static fn (): array => [
Route::post('/git/webhook/{repository}', GitWebhookController::class)
->whereNumber('repository')
->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.
- Preserved append-only changelog history.
- version: 1.10.12
- version: 1.10.11
date: 2026-06-24
type: bugfix
summary: Fix Helpdesk ticket create and edit form actions and reset behaviour.
changes:
- Restored the missing grid closing wrapper so create form actions render in the footer rather than inside the form grid.
- Repositioned Cancel, Reset and Create/Save actions to the right-hand footer on create and edit screens.
- Changed create reset to clear user-entered values and then reapply configured default dropdown values.
- 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.
- Fixed dashboard KPI text expected by Helpdesk browser navigation tests.
- Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked.
- Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.10.12"
"version": "1.10.11"
}