Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions app/Filament/Resources/SupportTicketResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -183,18 +193,45 @@ public static function renderTicketMessage(string $message): string
return str_replace('<p>', '<p style="margin: 0 0 1rem 0;">', $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 <<<JS
window.navigator.clipboard.writeText({$markdown})
\$tooltip('Copied as Markdown', { theme: \$store.theme, timeout: 1500 })
JS;
}

protected static function convertAsciiTablesToHtml(string $message): string
{
return self::convertAsciiTables($message, self::renderAsciiTable(...));
}

/**
* @param callable(list<string>): ?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) {
Expand Down Expand Up @@ -225,7 +262,11 @@ protected static function convertAsciiTablesToHtml(string $message): string
return implode("\n", $result);
}

protected static function renderAsciiTable(array $lines): ?string
/**
* @param list<string> $lines
* @return array{rows: list<list<string>>, hasHeader: bool}|null
*/
protected static function parseAsciiTable(array $lines): ?array
{
$rows = [];
$separatorAfterRow = [];
Expand All @@ -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<string> $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<string> $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;';
Expand Down
94 changes: 94 additions & 0 deletions tests/Feature/SupportTicketTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1343,6 +1344,99 @@ public function render_ticket_message_treats_first_row_as_header_when_separated(
$this->assertMatchesRegularExpression('/<tr><td[^>]*>camera<\/td><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
{
Expand Down
Loading