fixed tag rendering

This commit is contained in:
Mark Gregory
2026-06-17 10:57:02 +01:00
parent febdb7da7a
commit 12736df164
3 changed files with 126 additions and 23 deletions
@@ -54,10 +54,10 @@ final class TicketCommentStoreController extends Controller
->where('is_active', true)
->findOrFail((int) $validated['canned_response_id']);
$body = trim($response->body);
$body = trim($renderer->render($response->body, $ticket, $user));
}
$body = $this->normaliseHtmlBody($renderer->render($body, $ticket, $user));
$body = $this->normaliseHtmlBody($body);
validator(['commentBody' => $body], [
'commentBody' => ['required', 'string', 'min:2'],
@@ -84,12 +84,40 @@ final class TicketCommentStoreController extends Controller
*/
private function normaliseHtmlBody(string $body): string
{
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = trim($body);
for ($i = 0; $i < 5; $i++) {
if (! str_contains($body, '&lt;') && ! str_contains($body, '&gt;') && ! str_contains($body, '&amp;lt;')) {
break;
}
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($decoded === $body) {
break;
}
$body = $decoded;
}
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
$body = trim(strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'));
return trim(strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'));
if ($body === '') {
return '';
}
if ($body === strip_tags($body)) {
return collect(preg_split('/\R{2,}/', $body) ?: [])
->map(static fn (string $paragraph): string => trim($paragraph))
->filter(static fn (string $paragraph): bool => $paragraph !== '')
->map(static fn (string $paragraph): string => '<p>'.nl2br(e($paragraph), false).'</p>')
->implode('');
}
return $body;
}
/**
@@ -133,6 +133,36 @@
</div>
@endif
@php
$cannedResponseBodies = $cannedResponses->mapWithKeys(function ($response) use ($ticket, $currentUser) {
$body = (string) $response->body;
$body = strtr($body, [
'{{ ticket.reference }}' => e((string) ($ticket->reference ?? '')),
'{{ ticket.subject }}' => e((string) ($ticket->subject ?? '')),
'{{ ticket.status }}' => e((string) ($ticket->status?->name ?? '')),
'{{ ticket.priority }}' => e((string) ($ticket->priority?->name ?? '')),
'{{ ticket.category }}' => e((string) ($ticket->category?->name ?? '')),
'{{ ticket.project }}' => e((string) ($ticket->project?->title ?? '')),
'{{ ticket.team }}' => e((string) ($ticket->team?->name ?? '')),
'{{ requester.name }}' => e((string) ($ticket->owner?->name ?? $ticket->user?->name ?? '')),
'{{ agent.name }}' => e((string) ($ticket->agent?->name ?? $currentUser?->name ?? '')),
'{{ actor.name }}' => e((string) ($currentUser?->name ?? '')),
'{{ ticket.url }}' => e(route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()])),
]);
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
$body = strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>');
return [(string) $response->id => trim($body)];
})->all();
@endphp
<script type="application/json" id="helpdesk-canned-response-bodies">@json($cannedResponseBodies)</script>
@if ($cannedResponses->isNotEmpty())
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
<div>
@@ -140,7 +170,7 @@
<select id="ticket-canned-response" name="canned_response_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-canned-response-select>
<option value="">Choose a saved response...</option>
@foreach ($cannedResponses as $response)
<option value="{{ $response->id }}" data-body="{{ e($response->body) }}">{{ $response->name }}</option>
<option value="{{ $response->id }}">{{ $response->name }}</option>
@endforeach
</select>
</div>
@@ -212,12 +242,11 @@
const editor = form?.querySelector('[data-helpdesk-rich-editor]');
const textarea = form?.querySelector('textarea[name="commentBody"]');
const selected = select?.selectedOptions?.[0];
const body = normaliseCannedHtml(selected?.dataset?.body || '');
const cannedResponses = getCannedResponseBodies();
const body = normaliseCannedHtml(cannedResponses[String(selected?.value || '')] || selected?.dataset?.body || '');
if (editor && textarea && body.trim() !== '') {
editor.innerHTML = editor.innerHTML.trim() === ''
? body
: editor.innerHTML.replace(/\s+$/, '') + '<br><br>' + body;
insertHtmlAtEnd(editor, body);
syncRichEditor(editor);
editor.focus();
}
@@ -343,6 +372,33 @@
collapseButton.classList.toggle('inline-flex', hasExpandedComments && ! hasExpandableComments);
}
function getCannedResponseBodies() {
const source = document.getElementById('helpdesk-canned-response-bodies');
if (! source) {
return {};
}
try {
return JSON.parse(source.textContent || '{}') || {};
} catch (error) {
console.error('Unable to read canned response bodies.', error);
return {};
}
}
function insertHtmlAtEnd(editor, html) {
const current = editor.innerHTML.trim();
const cleaned = html.trim();
if (current === '' || current === '<br>') {
editor.innerHTML = cleaned;
return;
}
editor.innerHTML = current + '<p><br></p>' + cleaned;
}
function decodeHtmlEntities(value) {
const textarea = document.createElement('textarea');
textarea.innerHTML = value;
@@ -350,9 +406,13 @@
}
function normaliseCannedHtml(value) {
let decoded = decodeHtmlEntities(value);
let decoded = decodeHtmlEntities(value || '');
for (let i = 0; i < 5; i += 1) {
if (! decoded.includes('&lt;') && ! decoded.includes('&gt;') && ! decoded.includes('&amp;lt;')) {
break;
}
while (decoded.includes('&lt;') || decoded.includes('&gt;') || decoded.includes('&amp;lt;')) {
const next = decodeHtmlEntities(decoded);
if (next === decoded) {
@@ -362,7 +422,22 @@
decoded = next;
}
return decoded;
decoded = decoded.trim();
if (decoded === '') {
return '';
}
if (/<[a-z][\s\S]*>/i.test(decoded)) {
return decoded;
}
return decoded
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter((paragraph) => paragraph !== '')
.map((paragraph) => '<p>' + escapeHtml(paragraph).replace(/\n/g, '<br>') + '</p>')
.join('');
}
function syncRichEditor(editor) {
@@ -64,7 +64,7 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
'name' => 'Browser canned response',
'slug' => 'browser-canned-response',
'visibility' => 'agents',
'body' => '<p>Inserted canned browser response</p>',
'body' => '<p>Hello {{ requester.name }}</p><p>Inserted canned browser response for {{ ticket.reference }}</p>',
'is_active' => true,
'sort_order' => 1,
]);
@@ -76,19 +76,19 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->assertSee('Browser response ticket')
->select('canned_response_id', (string) $cannedResponse->id)
->press('@insert-canned-response')
->assertInputValue('commentBody', '<p>Inserted canned browser response</p>');
$this->setHelpdeskEditor($browser, 'ticket-comment-editor', '<p>First browser response</p>');
$browser
->waitForText('Hello Response Browser User', 10)
->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello')
->assertInputValue('commentBody', '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>')
->press('@post-comment')
->waitForText('First browser response', 10)
->assertSee('First browser response');
->waitForText('Hello Response Browser User', 10)
->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello');
$comment = Comment::query()
->where('ticket_id', $ticket->id)
->whereNull('parent_id')
->where('body', '<p>First browser response</p>')
->where('body', 'like', '%Hello Response Browser User%')
->firstOrFail();
$browser->press('@reply-to-comment-'.$comment->id)
@@ -102,13 +102,13 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'body' => '<p>First browser response</p>',
'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>',
]);
$parentId = Comment::query()
->where('ticket_id', $ticket->id)
->whereNull('parent_id')
->where('body', '<p>First browser response</p>')
->where('body', 'like', '%Inserted canned browser response%')
->value('id');
$this->assertDatabaseHas('helpdesk_comments', [