out new baseline
This commit is contained in:
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.10.12"
|
||||
"version": "1.10.11"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user