From 10ed6fd158922d78c7a8495ec4fd16ecd0f3e217 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 5 Aug 2026 09:42:24 +0100 Subject: [PATCH] Add "Copy as Markdown" button to support ticket message Adds a hint action beside the Message label on the admin ticket view that copies the initial message to the clipboard as Markdown, converting any ASCII tables (native:doctor environment dumps) into Markdown tables so they render when pasted into GitHub issues or an LLM. The ASCII table walker is generalised so the HTML and Markdown renderers share one parser; HTML output is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../Resources/SupportTicketResource.php | 91 +++++++++++++++++- tests/Feature/SupportTicketTest.php | 94 +++++++++++++++++++ 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php index 66aec521b..292ac19a0 100644 --- a/app/Filament/Resources/SupportTicketResource.php +++ b/app/Filament/Resources/SupportTicketResource.php @@ -13,6 +13,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Support\HtmlString; +use Illuminate\Support\Js; use Illuminate\Support\Str; class SupportTicketResource extends Resource @@ -78,6 +79,15 @@ public static function infolist(Schema $schema): Schema ->label('Subject'), Infolists\Components\TextEntry::make('message') ->label('Message') + ->hintAction( + Actions\Action::make('copyMessageAsMarkdown') + ->label('Copy as Markdown') + ->icon('heroicon-m-clipboard-document') + ->color('gray') + ->link() + ->visible(fn (SupportTicket $record): bool => filled($record->message)) + ->actionJs(fn (SupportTicket $record): string => self::copyMessageAsMarkdownJs($record)) + ) ->formatStateUsing(fn (?string $state): ?HtmlString => $state === null ? null : new HtmlString(self::renderTicketMessage($state))) @@ -183,18 +193,45 @@ public static function renderTicketMessage(string $message): string return str_replace('

', '

', $html); } + /** + * Convert a ticket message to Markdown suitable for pasting elsewhere, turning any + * ASCII tables it contains into Markdown tables. + */ + public static function ticketMessageAsMarkdown(string $message): string + { + return trim(self::convertAsciiTables($message, self::renderAsciiTableAsMarkdown(...))); + } + + protected static function copyMessageAsMarkdownJs(SupportTicket $record): string + { + $markdown = Js::from(self::ticketMessageAsMarkdown($record->message)); + + return <<): ?string $renderTable + */ + protected static function convertAsciiTables(string $message, callable $renderTable): string { $lines = preg_split('/\R/', $message) ?: []; $result = []; $buffer = []; - $flush = function () use (&$result, &$buffer): void { + $flush = function () use (&$result, &$buffer, $renderTable): void { if ($buffer === []) { return; } - $rendered = self::renderAsciiTable($buffer); + $rendered = $renderTable($buffer); if ($rendered === null) { foreach ($buffer as $bufferedLine) { @@ -225,7 +262,11 @@ protected static function convertAsciiTablesToHtml(string $message): string return implode("\n", $result); } - protected static function renderAsciiTable(array $lines): ?string + /** + * @param list $lines + * @return array{rows: list>, hasHeader: bool}|null + */ + protected static function parseAsciiTable(array $lines): ?array { $rows = []; $separatorAfterRow = []; @@ -248,7 +289,49 @@ protected static function renderAsciiTable(array $lines): ?string return null; } - $hasHeader = count($rows) > 1 && isset($separatorAfterRow[1]); + return [ + 'rows' => $rows, + 'hasHeader' => count($rows) > 1 && isset($separatorAfterRow[1]), + ]; + } + + /** + * @param list $lines + */ + protected static function renderAsciiTableAsMarkdown(array $lines): ?string + { + $table = self::parseAsciiTable($lines); + + if ($table === null) { + return null; + } + + ['rows' => $rows, 'hasHeader' => $hasHeader] = $table; + + $columnCount = max(array_map('count', $rows)); + $renderRow = fn (array $cells): string => '| '.implode(' | ', array_pad($cells, $columnCount, '')).' |'; + + $header = $hasHeader ? array_shift($rows) : array_fill(0, $columnCount, ''); + + return implode("\n", [ + $renderRow($header), + $renderRow(array_fill(0, $columnCount, '---')), + ...array_map($renderRow, $rows), + ]); + } + + /** + * @param list $lines + */ + protected static function renderAsciiTable(array $lines): ?string + { + $table = self::parseAsciiTable($lines); + + if ($table === null) { + return null; + } + + ['rows' => $rows, 'hasHeader' => $hasHeader] = $table; $tableStyle = 'border-collapse: collapse; width: auto; margin: 0 0 1rem 0; border: 1px solid rgba(127, 127, 127, 0.25);'; $cellStyle = 'padding: 0.25rem 0.75rem; border: 1px solid rgba(127, 127, 127, 0.2); text-align: left; vertical-align: top;'; diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php index 00e7cf02f..1bf7bfece 100644 --- a/tests/Feature/SupportTicketTest.php +++ b/tests/Feature/SupportTicketTest.php @@ -23,6 +23,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Js; use Laravel\Cashier\Subscription; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; @@ -1343,6 +1344,99 @@ public function render_ticket_message_treats_first_row_as_header_when_separated( $this->assertMatchesRegularExpression('/]*>camera<\/td>]*>1\.0\.2<\/td><\/tr>/', $html); } + #[Test] + public function ticket_message_as_markdown_leaves_plain_markdown_untouched(): void + { + $message = "**What I was trying to do:**\nDeploy quickly\n\n**What happened instead:**\nIt crashed"; + + $this->assertSame($message, SupportTicketResource::ticketMessageAsMarkdown($message)); + } + + #[Test] + public function ticket_message_as_markdown_converts_headerless_ascii_table_to_markdown_table(): void + { + $message = "Intro line\n+--------------------+---------+\n| Package Version | 3.3.3 |\n" + ."| PHP Version (Host) | 8.4.16 |\n+--------------------+---------+\nOutro line"; + + $markdown = SupportTicketResource::ticketMessageAsMarkdown($message); + + $this->assertSame( + "Intro line\n\n| | |\n| --- | --- |\n| Package Version | 3.3.3 |\n| PHP Version (Host) | 8.4.16 |\n\nOutro line", + $markdown + ); + } + + #[Test] + public function ticket_message_as_markdown_uses_the_first_row_as_a_header_when_separated(): void + { + $message = "+----------+---------+\n| Package | Version |\n+----------+---------+\n" + ."| camera | 1.0.2 |\n| jump | 2.1.0 |\n+----------+---------+"; + + $markdown = SupportTicketResource::ticketMessageAsMarkdown($message); + + $this->assertSame( + "| Package | Version |\n| --- | --- |\n| camera | 1.0.2 |\n| jump | 2.1.0 |", + $markdown + ); + } + + #[Test] + public function ticket_message_as_markdown_pads_ragged_rows_to_the_widest_row(): void + { + $message = "+---+---+---+\n| a | b | c |\n| d | e |\n+---+---+---+"; + + $markdown = SupportTicketResource::ticketMessageAsMarkdown($message); + + $this->assertSame( + "| | | |\n| --- | --- | --- |\n| a | b | c |\n| d | e | |", + $markdown + ); + } + + #[Test] + public function ticket_message_as_markdown_leaves_pipe_lines_that_are_not_tables_alone(): void + { + $message = "Before\n+------+\nAfter"; + + $this->assertSame($message, SupportTicketResource::ticketMessageAsMarkdown($message)); + } + + #[Test] + public function admin_view_page_offers_a_copy_as_markdown_button_for_the_initial_message(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $ticket = SupportTicket::factory()->create([ + 'message' => "**Environment:**\n+---------+-------+\n| Package | 3.3.3 |\n+---------+-------+", + ]); + + $html = Livewire::actingAs($admin) + ->test(ViewSupportTicket::class, ['record' => $ticket->getRouteKey()]) + ->assertOk() + ->assertSee('Copy as Markdown') + ->html(); + + $expectedMarkdown = (string) Js::from("**Environment:**\n\n| | |\n| --- | --- |\n| Package | 3.3.3 |"); + + $this->assertStringContainsString('window.navigator.clipboard.writeText(', $html); + $this->assertStringContainsString($expectedMarkdown, $html); + } + + #[Test] + public function admin_view_page_hides_the_copy_as_markdown_button_when_there_is_no_message(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $ticket = SupportTicket::factory()->create(['message' => '']); + + Livewire::actingAs($admin) + ->test(ViewSupportTicket::class, ['record' => $ticket->getRouteKey()]) + ->assertOk() + ->assertDontSee('Copy as Markdown'); + } + #[Test] public function render_ticket_message_applies_paragraph_spacing(): void {