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 {