misc updates with tests failing

This commit is contained in:
Mark Gregory
2026-06-29 09:42:00 +01:00
parent 7b40a55b0e
commit eaa8c6ebe3
6 changed files with 132 additions and 183 deletions
+45 -1
View File
@@ -86,10 +86,54 @@ final class TicketCreate extends Component
$this->applyDefaults();
}
public function save(TicketService $tickets)
{
logger()->info('SAVE START');
try {
logger()->info('VALIDATING');
$this->validate();
logger()->info('VALIDATED');
logger()->info([
'attachments' => count($this->attachments),
'body' => $this->body,
]);
$ticket = $tickets->create(Auth::user(), [
'company_id' => $this->company_id,
'subject' => $this->subject,
'body' => $this->body,
'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->attachments);
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
logger()->info('CREATED '.$ticket->id);
return redirect()->route('helpdesk.tickets.index');
} catch (\Throwable $e) {
logger()->error($e);
throw $e;
}
}
/**
* Store a new helpdesk ticket.
*/
public function save(TicketService $tickets)
public function save_old(TicketService $tickets)
{
if ($this->isSaving) {
return null;
@@ -10,7 +10,7 @@
class="space-y-2"
x-data="{
model: @js($model),
value: @entangle($model).defer,
value: @entangle($model),
init() {
this.$nextTick(() => {
this.writeEditorValue(this.value || '');
@@ -21,33 +21,43 @@
return;
}
this.$refs.editor.textContent = value || '';
const nextValue = value || '';
const currentValue = this.$refs.editor.innerText || '';
if (document.activeElement === this.$refs.editor && currentValue !== '' && currentValue !== nextValue) {
return;
}
this.$refs.editor.textContent = nextValue;
},
syncToLivewire(live = false) {
syncToLivewire(live = true) {
const value = this.$refs.editor?.innerText || '';
this.value = value;
this.$refs.input.value = value;
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$wire.set(this.model, this.value, live);
if (this.$refs.input) {
this.$refs.input.value = value;
}
this.$wire.set(this.model, value, live);
},
resetEditor() {
this.value = '';
this.writeEditorValue('');
this.syncToLivewire(false);
this.syncToLivewire(true);
},
runCommand(command) {
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.syncToLivewire(false);
this.syncToLivewire(true);
},
wrapBlock(tagName) {
this.$refs.editor?.focus();
document.execCommand('formatBlock', false, tagName);
this.syncToLivewire(false);
this.syncToLivewire(true);
},
}"
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire(false)"
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire($event.detail?.live ?? true)"
x-on:helpdesk-editor-reset.window="if (! $event.detail?.model || $event.detail.model === @js($model)) resetEditor()"
>
<label for="{{ $model }}-editor" class="block text-sm font-semibold text-zinc-200">
@@ -108,7 +118,9 @@
data-helpdesk-sync-target="{{ $model }}"
data-helpdesk-editor-model="{{ $model }}"
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
x-on:blur="syncToLivewire(false)"
x-on:input.debounce.350ms="syncToLivewire(true)"
x-on:paste.debounce.350ms="syncToLivewire(true)"
x-on:blur="syncToLivewire(true)"
class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
></div>
@@ -129,6 +141,6 @@
</div>
<p class="text-xs font-semibold text-zinc-400">
Browser spellcheck stays active in the native editable area. Formatting is synchronised only on blur and submit to avoid Livewire keypress regressions.
Browser spellcheck stays active in the native editable area. Formatting is synchronised before uploads and submit so Livewire does not lose unsaved editor text during attachment refreshes.
</p>
</div>
@@ -767,14 +767,14 @@
@endif
}
let wmsSessionToastsShown = false;
window.wmsSessionToastsShown = window.wmsSessionToastsShown || false;
function showSessionToastsOnce() {
if (wmsSessionToastsShown) {
if (window.wmsSessionToastsShown) {
return;
}
wmsSessionToastsShown = true;
window.wmsSessionToastsShown = true;
showSessionToasts();
}
@@ -6,7 +6,6 @@
->implode(',');
@endphp
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-create">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create ticket',
@@ -16,39 +15,18 @@
<form
wire:submit.prevent="save"
x-data="{ helpdeskAttachmentsUploading: false }"
x-on:livewire-upload-start="helpdeskAttachmentsUploading = true"
x-on:livewire-upload-finish="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-error="helpdeskAttachmentsUploading = false"
x-on:livewire-upload-cancel="helpdeskAttachmentsUploading = false"
x-on:submit.capture="
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
const target = document.querySelector('textarea[wire\:model\.defer=&quot;body&quot;], textarea[name=&quot;body&quot;], #body');
if (helpdeskAttachmentsUploading) {
if (editor && target) {
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
$event.preventDefault();
return;
}
const editor = document.querySelector('[data-helpdesk-sync-target=&quot;body&quot;], [data-helpdesk-editor-model=&quot;body&quot;]');
const target = document.querySelector('textarea[wire\\:model\\.defer=&quot;body&quot;], textarea[name=&quot;body&quot;], #body');
if (editor && target) {
target.value = editor.innerHTML?.trim?.() || editor.value?.trim?.() || '';
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
enctype="multipart/form-data"
$dispatch('helpdesk-editor-sync', { model: 'body' });
"
class="helpdesk-ticket-form space-y-6"
>
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
@@ -278,16 +256,16 @@
</div>
<div class="lg:col-span-3 xl:col-span-6">
<div class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="helpdeskAttachmentDropzone('attachments')"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dropFiles($event)"
<div
wire:key="ticket-create-attachments-uploader"
class="rounded-2xl border border-zinc-700 bg-zinc-950/70 p-4"
x-data="{ uploading: false, progress: 0 }"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-progress="progress = $event.detail.progress"
x-on:livewire-upload-finish="uploading = false; progress = 100"
x-on:livewire-upload-error="uploading = false"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
x-on:livewire-upload-cancel="uploading = false"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="block text-sm font-semibold text-zinc-200" for="attachments">
@@ -295,7 +273,7 @@
</label>
<p class="mt-1 text-sm text-zinc-500">
Drag and drop screenshots, PDFs, logs, source files or documents. PHP, JS, RPG/RPGLE, CLLE and other source files are accepted.
Files are uploaded to Livewire temporary storage first. They are attached permanently only after the ticket is created.
</p>
</div>
@@ -307,27 +285,35 @@
</div>
<label for="attachments"
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900"
x-bind:class="dragging ? 'border-blue-400 bg-blue-500/10' : ''">
class="mt-4 flex cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-700 bg-zinc-900/70 px-4 py-8 text-center transition hover:border-blue-500 hover:bg-zinc-900">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-blue-300" aria-hidden="true"></i>
<span class="mt-3 text-sm font-bold text-zinc-100">Drop files here or click to browse</span>
<span class="mt-3 text-sm font-bold text-zinc-100">Click to choose files</span>
<span class="mt-1 text-xs text-zinc-500">Maximum {{ (int) config('helpdesk.max_upload_kb', 10240) }} KB per file.</span>
</label>
<input id="attachments" type="file" wire:model="attachments" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
x-ref="input" x-on:change="refreshSelectedFiles()">
<input
id="attachments"
type="file"
wire:model="attachments"
multiple
accept="{{ $helpdeskAttachmentAccept }}"
class="sr-only"
wire:key="ticket-create-attachments-input"
dusk="ticket-create-attachments-input"
data-dusk="ticket-create-attachments-input"
/>
<template x-if="selectedFiles.length > 0">
<div class="mt-4 grid gap-2 sm:grid-cols-2">
<template x-for="file in selectedFiles" :key="file.name + file.size">
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300">
@if (count($attachments) > 0)
<div class="mt-4 grid gap-2 sm:grid-cols-2" dusk="ticket-create-selected-attachments">
@foreach ($attachments as $attachment)
<div class="flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-300" wire:key="ticket-create-selected-attachment-{{ $loop->index }}">
<i class="fa-regular fa-file-lines text-blue-300" aria-hidden="true"></i>
<span class="min-w-0 flex-1 truncate" x-text="file.name"></span>
<span class="shrink-0 text-zinc-500" x-text="formatSize(file.size)"></span>
<span class="min-w-0 flex-1 truncate">{{ $attachment->getClientOriginalName() }}</span>
<span class="shrink-0 text-zinc-500">{{ number_format($attachment->getSize() / 1024, 1) }} KB</span>
</div>
</template>
@endforeach
</div>
</template>
@endif
<div x-show="uploading" x-cloak
class="mt-4 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-3 text-sm font-medium text-blue-200">
@@ -369,7 +355,7 @@
<button type="submit"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70 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">
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled" wire:target="save,attachments" wire:dirty.class="ring-2 ring-amber-400/40">
<i class="fa-regular fa-floppy-disk mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="save,attachments">Create ticket</span>
<span wire:loading wire:target="attachments">Uploading...</span>
@@ -378,71 +364,4 @@
</div>
</div>
</form>
<script>
(() => {
window.helpdeskAttachmentDropzone = window.helpdeskAttachmentDropzone || function (inputId) {
return {
dragging: false,
uploading: false,
progress: 0,
selectedFiles: [],
dropFiles(event) {
this.dragging = false;
const input = this.$refs.input || document.getElementById(inputId);
if (!input || !event.dataTransfer?.files?.length) {
return;
}
input.files = event.dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
this.refreshSelectedFiles();
},
refreshSelectedFiles() {
const input = this.$refs.input || document.getElementById(inputId);
this.selectedFiles = input?.files ? Array.from(input.files) : [];
},
formatSize(bytes) {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
},
};
};
const resetTicketCreateFiles = () => {
document.querySelectorAll('.helpdesk-ticket-form #attachments').forEach((input) => {
input.value = '';
input.dispatchEvent(new Event('change', { bubbles: true }));
});
};
const appendPastedFiles = (event) => {
const files = Array.from(event.detail?.files || []);
const input = document.querySelector('.helpdesk-ticket-form #attachments');
if (!input || files.length === 0) {
return;
}
const transfer = new DataTransfer();
Array.from(input.files || []).forEach((file) => transfer.items.add(file));
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
};
document.addEventListener('helpdesk-form-cleared', resetTicketCreateFiles);
document.addEventListener('helpdesk-editor-files-pasted', appendPastedFiles);
if (window.Livewire?.on) {
window.Livewire.on('helpdesk-form-cleared', resetTicketCreateFiles);
}
})();
</script>
</div>
@@ -187,34 +187,14 @@ JS);
return (bool) $browser->script(<<<'JS'
const input = document.querySelector('#attachments');
const button = document.querySelector('[dusk="ticket-create-submit"], [data-dusk="ticket-create-submit"]');
const isVisible = (element) => {
if (! element) {
return false;
}
const style = window.getComputedStyle(element);
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.opacity !== '0'
&& element.getClientRects().length > 0;
};
const visibleUploadText = Array.from(document.querySelectorAll('div, span'))
.some((element) => {
const text = (element.textContent || '').trim();
return (text.includes('Uploading attachments...') || text === 'Uploading...')
&& isVisible(element);
});
const selected = document.querySelector('[dusk="ticket-create-selected-attachments"], [data-dusk="ticket-create-selected-attachments"]');
return Boolean(input)
&& input.files.length > 0
&& Boolean(selected)
&& Boolean(button)
&& button.disabled === false
&& visibleUploadText === false;
&& button.disabled === false;
JS)[0];
}, 'The attachment upload did not finish before submit.');
}, 'The Livewire temporary attachment upload did not finish before submit.');
}
}
@@ -8,45 +8,39 @@ use Tests\TestCase;
final class TicketCreateAttachmentBrowserSafetyTest extends TestCase
{
public function test_ticket_create_view_does_not_reinject_application_assets_inside_livewire_component(): void
public function test_ticket_create_uses_standard_livewire_temporary_uploads(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringNotContainsString("@vite(['resources/css/app.css', 'resources/js/app.js'])", $view);
self::assertStringContainsString('wire:submit.prevent="save"', $view);
}
public function test_ticket_create_attachment_input_is_owned_by_livewire_and_has_a_stable_key(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('wire:key="ticket-create-attachments-uploader"', $view);
self::assertStringContainsString('type="file"', $view);
self::assertStringContainsString('wire:model="attachments"', $view);
self::assertStringContainsString('multiple', $view);
self::assertStringContainsString('wire:key="ticket-create-attachments-input"', $view);
self::assertStringContainsString('type="file" wire:model="attachments" multiple', $view);
self::assertStringNotContainsString('$wire.uploadMultiple', $view);
self::assertStringNotContainsString("new CustomEvent('livewire-upload-start'", $view);
self::assertStringNotContainsString("new CustomEvent('livewire-upload-finish'", $view);
}
public function test_ticket_create_attachment_script_registers_global_listeners_once(): void
public function test_ticket_create_does_not_mix_existing_ticket_upload_behaviour_into_create_form(): void
{
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($view);
self::assertStringContainsString('window.__helpdeskTicketCreateAttachmentListenersRegistered', $view);
self::assertStringContainsString('window.__helpdeskTicketCreateLivewireClearListenerRegistered', $view);
self::assertStringContainsString("window.dispatchEvent(new CustomEvent('helpdesk-attachments-reset'))", $view);
self::assertStringNotContainsString('@vite([\'resources/css/app.css\', \'resources/js/app.js\'])', $view);
self::assertStringNotContainsString('helpdeskAttachmentDropzone', $view);
self::assertStringNotContainsString('DataTransfer', $view);
self::assertStringNotContainsString('$wire.uploadMultiple', $view);
self::assertStringNotContainsString('input.files =', $view);
self::assertStringNotContainsString('helpdesk-editor-files-pasted', $view);
self::assertStringNotContainsString('x-bind:disabled="helpdeskAttachmentsUploading"', $view);
}
public function test_create_attachment_browser_test_does_not_use_livewire_internal_dom_selectors(): void
public function test_ticket_create_shows_uploaded_temporary_file_names_from_livewire_property(): void
{
$test = file_get_contents(dirname(__DIR__, 1) . '/Browser/HelpdeskCreateTicketAttachmentBrowserTest.php');
$view = file_get_contents(dirname(__DIR__, 2) . '/Resources/views/livewire/ticket-create.blade.php');
self::assertIsString($test);
self::assertStringNotContainsString('closest(', $test);
self::assertStringNotContainsString('Livewire.find', $test);
self::assertStringContainsString("attach('#attachments'", $test);
self::assertIsString($view);
self::assertStringContainsString('@if (count($attachments) > 0)', $view);
self::assertStringContainsString('$attachment->getClientOriginalName()', $view);
self::assertStringContainsString('wire:loading wire:target="attachments"', $view);
}
}