Skip to content

Commit 44fadb8

Browse files
simonhampclaude
andauthored
Render support ticket message markdown and ASCII tables as HTML (#375)
Filament's TextEntry markdown rendering was collapsing single newlines, which mangled pasted artisan output (ASCII tables) into a single line on the admin support ticket view. Replace the bare `->markdown()` with a custom renderer that: - Detects consecutive `+`/`|` lines and converts them into real HTML tables (with a header when separator-delimited, otherwise tbody-only so key/value layouts stay aligned) - Enables soft-break -> `<br />` conversion so other line breaks survive - Applies inline styles (width: auto, padding, borders, zebra stripes, paragraph spacing) using semi-transparent neutrals so the result works in both light and dark Filament themes without depending on Tailwind Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c0c2c6c commit 44fadb8

2 files changed

Lines changed: 195 additions & 1 deletion

File tree

app/Filament/Resources/SupportTicketResource.php

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Filament\Tables;
1414
use Filament\Tables\Table;
1515
use Illuminate\Support\HtmlString;
16+
use Illuminate\Support\Str;
1617

1718
class SupportTicketResource extends Resource
1819
{
@@ -77,7 +78,10 @@ public static function infolist(Schema $schema): Schema
7778
->label('Subject'),
7879
Infolists\Components\TextEntry::make('message')
7980
->label('Message')
80-
->markdown(),
81+
->formatStateUsing(fn (?string $state): ?HtmlString => $state === null
82+
? null
83+
: new HtmlString(self::renderTicketMessage($state)))
84+
->html(),
8185
Infolists\Components\TextEntry::make('attachments')
8286
->label('Attachments')
8387
->formatStateUsing(function (SupportTicket $record): HtmlString {
@@ -168,6 +172,127 @@ public static function getRelations(): array
168172
return [];
169173
}
170174

175+
public static function renderTicketMessage(string $message): string
176+
{
177+
$html = Str::markdown(self::convertAsciiTablesToHtml($message), [
178+
'renderer' => [
179+
'soft_break' => "<br />\n",
180+
],
181+
]);
182+
183+
return str_replace('<p>', '<p style="margin: 0 0 1rem 0;">', $html);
184+
}
185+
186+
protected static function convertAsciiTablesToHtml(string $message): string
187+
{
188+
$lines = preg_split('/\R/', $message) ?: [];
189+
$result = [];
190+
$buffer = [];
191+
192+
$flush = function () use (&$result, &$buffer): void {
193+
if ($buffer === []) {
194+
return;
195+
}
196+
197+
$rendered = self::renderAsciiTable($buffer);
198+
199+
if ($rendered === null) {
200+
foreach ($buffer as $bufferedLine) {
201+
$result[] = $bufferedLine;
202+
}
203+
} else {
204+
$result[] = '';
205+
$result[] = $rendered;
206+
$result[] = '';
207+
}
208+
209+
$buffer = [];
210+
};
211+
212+
foreach ($lines as $line) {
213+
if (preg_match('/^\s*[+|]/', $line)) {
214+
$buffer[] = $line;
215+
216+
continue;
217+
}
218+
219+
$flush();
220+
$result[] = $line;
221+
}
222+
223+
$flush();
224+
225+
return implode("\n", $result);
226+
}
227+
228+
protected static function renderAsciiTable(array $lines): ?string
229+
{
230+
$rows = [];
231+
$separatorAfterRow = [];
232+
233+
foreach ($lines as $line) {
234+
$trimmed = ltrim($line);
235+
236+
if (str_starts_with($trimmed, '+')) {
237+
$separatorAfterRow[count($rows)] = true;
238+
239+
continue;
240+
}
241+
242+
if (str_starts_with($trimmed, '|')) {
243+
$rows[] = self::splitAsciiTableRow($trimmed);
244+
}
245+
}
246+
247+
if ($rows === []) {
248+
return null;
249+
}
250+
251+
$hasHeader = count($rows) > 1 && isset($separatorAfterRow[1]);
252+
253+
$tableStyle = 'border-collapse: collapse; width: auto; margin: 0 0 1rem 0; border: 1px solid rgba(127, 127, 127, 0.25);';
254+
$cellStyle = 'padding: 0.25rem 0.75rem; border: 1px solid rgba(127, 127, 127, 0.2); text-align: left; vertical-align: top;';
255+
$headerCellStyle = $cellStyle.' font-weight: 600; background: rgba(127, 127, 127, 0.12);';
256+
$stripeStyle = 'background: rgba(127, 127, 127, 0.06);';
257+
258+
$html = '<table style="'.$tableStyle.'">';
259+
260+
if ($hasHeader) {
261+
$html .= '<thead><tr>';
262+
foreach ($rows[0] as $cell) {
263+
$html .= '<th style="'.$headerCellStyle.'">'.e($cell).'</th>';
264+
}
265+
$html .= '</tr></thead>';
266+
$bodyRows = array_slice($rows, 1);
267+
} else {
268+
$bodyRows = $rows;
269+
}
270+
271+
$html .= '<tbody>';
272+
foreach ($bodyRows as $index => $row) {
273+
$rowStyle = $index % 2 === 1 ? ' style="'.$stripeStyle.'"' : '';
274+
$html .= '<tr'.$rowStyle.'>';
275+
foreach ($row as $cell) {
276+
$html .= '<td style="'.$cellStyle.'">'.e($cell).'</td>';
277+
}
278+
$html .= '</tr>';
279+
}
280+
$html .= '</tbody></table>';
281+
282+
return $html;
283+
}
284+
285+
/**
286+
* @return list<string>
287+
*/
288+
protected static function splitAsciiTableRow(string $line): array
289+
{
290+
$line = trim($line);
291+
$line = trim($line, '|');
292+
293+
return array_map('trim', explode('|', $line));
294+
}
295+
171296
public static function getPages(): array
172297
{
173298
return [

tests/Feature/SupportTicketTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Tests\Feature;
44

5+
use App\Filament\Resources\SupportTicketResource;
56
use App\Filament\Resources\SupportTicketResource\Pages\ViewSupportTicket;
67
use App\Filament\Resources\SupportTicketResource\Widgets\TicketRepliesWidget;
78
use App\Livewire\Customer\Support\Create;
@@ -1268,6 +1269,74 @@ public function admin_view_page_shows_name_and_email_when_user_has_name(): void
12681269
->assertSee($namedUser->email);
12691270
}
12701271

1272+
#[Test]
1273+
public function admin_view_page_renders_message_markdown_and_ascii_tables_as_html_tables(): void
1274+
{
1275+
$admin = User::factory()->create(['email' => 'admin@test.com']);
1276+
config(['filament.users' => ['admin@test.com']]);
1277+
1278+
$message = "**What I was trying to do:**\nDeploy quickly\n\n"
1279+
."**Environment:**\n+--------------------+---------------+\n"
1280+
."| Package Version | 3.3.3 |\n"
1281+
."| PHP Version (Host) | 8.4.16 |\n"
1282+
.'+--------------------+---------------+';
1283+
1284+
$ticket = SupportTicket::factory()->create(['message' => $message]);
1285+
1286+
$html = Livewire::actingAs($admin)
1287+
->test(ViewSupportTicket::class, ['record' => $ticket->getRouteKey()])
1288+
->assertOk()
1289+
->assertSeeHtml('<strong>What I was trying to do:</strong>')
1290+
->html();
1291+
1292+
$this->assertMatchesRegularExpression('/<td[^>]*>Package Version<\/td>/', $html);
1293+
$this->assertMatchesRegularExpression('/<td[^>]*>3\.3\.3<\/td>/', $html);
1294+
}
1295+
1296+
#[Test]
1297+
public function render_ticket_message_converts_ascii_table_without_header_to_html_table(): void
1298+
{
1299+
$message = "Intro line\n+---+---+\n| a | b |\n| c | d |\n+---+---+\nOutro line";
1300+
1301+
$html = SupportTicketResource::renderTicketMessage($message);
1302+
1303+
$this->assertStringContainsString('Intro line', $html);
1304+
$this->assertStringNotContainsString('<thead>', $html);
1305+
$this->assertMatchesRegularExpression('/<tr><td[^>]*>a<\/td><td[^>]*>b<\/td><\/tr>/', $html);
1306+
$this->assertMatchesRegularExpression('/<tr style="[^"]*"><td[^>]*>c<\/td><td[^>]*>d<\/td><\/tr>/', $html);
1307+
$this->assertStringContainsString('Outro line', $html);
1308+
}
1309+
1310+
#[Test]
1311+
public function render_ticket_message_treats_first_row_as_header_when_separated(): void
1312+
{
1313+
$message = "+----------+---------+\n| Package | Version |\n+----------+---------+\n| camera | 1.0.2 |\n+----------+---------+";
1314+
1315+
$html = SupportTicketResource::renderTicketMessage($message);
1316+
1317+
$this->assertMatchesRegularExpression('/<thead><tr><th[^>]*>Package<\/th><th[^>]*>Version<\/th><\/tr><\/thead>/', $html);
1318+
$this->assertMatchesRegularExpression('/<tr><td[^>]*>camera<\/td><td[^>]*>1\.0\.2<\/td><\/tr>/', $html);
1319+
}
1320+
1321+
#[Test]
1322+
public function render_ticket_message_applies_paragraph_spacing(): void
1323+
{
1324+
$html = SupportTicketResource::renderTicketMessage("First paragraph\n\nSecond paragraph");
1325+
1326+
$this->assertStringContainsString('<p style="margin: 0 0 1rem 0;">First paragraph</p>', $html);
1327+
$this->assertStringContainsString('<p style="margin: 0 0 1rem 0;">Second paragraph</p>', $html);
1328+
}
1329+
1330+
#[Test]
1331+
public function render_ticket_message_converts_single_newlines_to_line_breaks(): void
1332+
{
1333+
$message = "Line one\nLine two";
1334+
1335+
$html = SupportTicketResource::renderTicketMessage($message);
1336+
1337+
$this->assertStringContainsString("Line one<br />\nLine two", $html);
1338+
}
1339+
12711340
#[Test]
12721341
public function guests_cannot_access_ticket_index(): void
12731342
{

0 commit comments

Comments
 (0)