Skip to content

Commit afe8fc1

Browse files
simonhampclaude
andcommitted
Render support ticket message markdown and ASCII tables as HTML
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 afe8fc1

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)