diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ae930c..0fc0fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the API and the widget are documented here. The format fo ### Added -- Feedback page redesign: screenshot first with the sender's message as a sticky, hideable card; technical details on a second tab with copy buttons for every value and log entry and "Copy report" as plain text; console errors highlighted and counted on the tab. +- Feedback page redesign: screenshot first with the sender's message as a sticky, hideable card; technical details on a second tab with copy buttons for every value and log entry and "Copy report" as plain text; console errors highlighted. Repeated console entries and failed requests are listed one by one (at most 500 per log), each with its own copy button and in "Copy all", instead of a single row with ×N; no count badge on the tab. - Feedback pages behind signed, expiring links with all details, console and network log and the screenshot (inline and download). - `feedback:link {id}` creates team links that can complete and reopen the entry (`--by` is logged, `--read-only`, `--expires`). - The API returns a read-only share link after sending (`FEEDBACK_SHARE_LINK_MINUTES`, `0` turns it off); the widget shows it on the thank you screen with a copy button, `after_send` receives it as `url`. diff --git a/app/Http/Controllers/FeedbackPageController.php b/app/Http/Controllers/FeedbackPageController.php index b6069f3..97b2942 100644 --- a/app/Http/Controllers/FeedbackPageController.php +++ b/app/Http/Controllers/FeedbackPageController.php @@ -19,6 +19,9 @@ */ final class FeedbackPageController { + /** Upper bound of rows per log once repeated entries are spread out. */ + private const MAX_ROWS = 500; + public function __invoke(Request $request, FeedbackEntry $entry): Response { $expires = Carbon::createFromTimestamp($request->integer('expires')); @@ -27,8 +30,8 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response $shot = $entry->screenshot()->select(['entry_id', 'mime', 'width', 'height', 'bytes'])->first(); $nonce = Str::random(32); $environment = self::environment($entry); - $console = self::rows($entry->console_log, ['type', 'message', 'count', 'source', 'line', 'column', 'stack']); - $network = self::rows($entry->network_log, ['method', 'url', 'status', 'duration', 'count']); + $console = self::rows($entry->console_log, ['type', 'message', 'source', 'line', 'column', 'stack']); + $network = self::rows($entry->network_log, ['method', 'url', 'status', 'duration']); $customData = $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return response()->view('feedback.show', [ @@ -133,21 +136,20 @@ private static function report(FeedbackEntry $entry, array $environment, array $ public static function consoleLine(array $row): string { $where = $row['source'] === '' ? '' : ' ('.$row['source'].($row['line'] === '' ? '' : ':'.$row['line']).($row['column'] === '' ? '' : ':'.$row['column']).')'; - $times = (int) $row['count'] > 1 ? " ×{$row['count']}" : ''; - return "[{$row['type']}] {$row['message']}{$where}{$times}".($row['stack'] === '' ? '' : "\n{$row['stack']}"); + return "[{$row['type']}] {$row['message']}{$where}".($row['stack'] === '' ? '' : "\n{$row['stack']}"); } /** @param array $row */ public static function networkLine(array $row): string { - $times = (int) $row['count'] > 1 ? " ×{$row['count']}" : ''; - - return trim("{$row['method']} {$row['url']} → {$row['status']}".($row['duration'] === '' ? '' : " ({$row['duration']} ms)").$times); + return trim("{$row['method']} {$row['url']} → {$row['status']}".($row['duration'] === '' ? '' : " ({$row['duration']} ms)")); } /** - * Logs come from the browser: every cell becomes a plain string. + * Logs come from the browser: every cell becomes a plain string. The + * widget folds identical consecutive entries into one with a count, the + * page lists every occurrence (at most MAX_ROWS per log). * * @param array|null $log * @param list $keys @@ -169,7 +171,11 @@ private static function rows(?array $log, array $keys): array $row[$key] = is_scalar($value) ? (string) $value : ''; } - $rows[] = $row; + $times = max(1, is_numeric($item['count'] ?? null) ? (int) $item['count'] : 1); + + for ($i = 0; $i < $times && count($rows) < self::MAX_ROWS; $i++) { + $rows[] = $row; + } } return $rows; diff --git a/resources/views/feedback/show.blade.php b/resources/views/feedback/show.blade.php index e8ebb5a..9b82d36 100644 --- a/resources/views/feedback/show.blade.php +++ b/resources/views/feedback/show.blade.php @@ -47,7 +47,6 @@ [role=tab][aria-selected=true] { color: var(--ink); } [role=tab][aria-selected=true]::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--ink); } .count { font-size: 12px; padding: 1px 7px; border-radius: 999px; background: var(--sunken); color: var(--mute); } - .count.err { background: var(--err-bg); color: var(--err); } main { max-width: 1400px; margin: 0 auto; padding: 20px; } [role=tabpanel][hidden] { display: none; } @@ -179,11 +178,6 @@ @@ -268,8 +262,8 @@
  • {{ $row['type'] !== '' ? $row['type'] : 'log' }}
    {{ $row['message'] }} - @if ($row['source'] !== '' || (int) $row['count'] > 1) - + @if ($row['source'] !== '') + @endif @if ($row['stack'] !== '')
    Stack trace
    {{ $row['stack'] }}
    @@ -297,7 +291,9 @@
  • {{ $row['status'] !== '' && $row['status'] !== '0' ? $row['status'] : 'ERR' }}
    {{ $row['method'] }} {{ $row['url'] }} - + @if ($row['duration'] !== '') + + @endif
  • diff --git a/tests/Browser/FeedbackPageTest.php b/tests/Browser/FeedbackPageTest.php index ac6b695..bf9ddf2 100644 --- a/tests/Browser/FeedbackPageTest.php +++ b/tests/Browser/FeedbackPageTest.php @@ -35,7 +35,7 @@ function sentFeedback(FeedbackProject $project): FeedbackEntry ->assertSee('Die Schaltfläche «Speichern» reagiert nicht.') ->assertSee('Anna Muster') ->assertSee('anna@beispiel.ch') - ->assertSee('1 console error') + ->assertSee('2 console errors') ->assertVisible('img[alt^="Screenshot of"]') ->assertNoJavaScriptErrors(); diff --git a/tests/Feature/FeedbackLinkTest.php b/tests/Feature/FeedbackLinkTest.php index b8c1912..f313c5d 100644 --- a/tests/Feature/FeedbackLinkTest.php +++ b/tests/Feature/FeedbackLinkTest.php @@ -94,9 +94,13 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba ->assertSee('https://www.beispiel.ch/zwei') ->assertSee('
    Viewport
    ', false) ->assertSee('role="tab" id="tab-technical"', false) - ->assertSee('1 error') + ->assertDontSee('', false) + ->assertSee('2 console errors') + ->assertSee('Console 2', false) ->assertSee('Copy report') - ->assertSee('data-copy="[error] Boom ×2"', false) + ->assertSee('data-copy="[error] Boom"', false) + ->assertSee('data-copy="[error] Boom'."\n".'[error] Boom"', false) + ->assertDontSee('×2') ->assertSee('POST https://api.beispiel.ch/save → 500 (120 ms)') ->assertSee('1541 × 947') ->assertSee('Europe/Zurich') @@ -119,7 +123,7 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba ->toContain("Feedback {$entry->id} (Alpha)") ->toContain('Die Schaltfläche «Speichern» reagiert nicht.') ->toContain('Viewport: 1541 × 947') - ->toContain("Console:\n[error] Boom ×2") + ->toContain("Console:\n[error] Boom\n[error] Boom\n") ->toContain("Network:\nPOST https://api.beispiel.ch/save → 500 (120 ms)") ->toContain('"plan": "pro"'); });