Skip to main content

Report browser

This package's position on admin screens is bring your own — the reports table is public API, and an application that wants a real console builds one against it. The browser on this page is for the other case: you want to read your feedback without building anything.

It is minimal on purpose and complete on purpose. Filter by mode, category and period; open one report with its screenshot; delete one with its attachment files cleaned up. Nothing else. If you need bulk actions, assignment, comments or export, that is the console you build yourself — and the table is still there for it.

It does nothing until you do two things

Installing the package does not expose it. That is the design, not an oversight.

1. Route it. The component is registered under a stable name and has no route of its own:

// routes/web.php
use Illuminate\Support\Facades\Route;

Route::get('/admin/feedback', function () {
return view('admin.feedback');
})->middleware(['auth']);
{{-- resources/views/admin/feedback.blade.php --}}
<x-app-layout>
@livewire('visual-feedback.report-browser')
</x-app-layout>

2. Open the gate. The package names a gate and deliberately does not define it. An undefined gate denies in Laravel, so a fresh install answers 403 rather than serving your users' feedback to anyone who guesses the component name:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

Gate::define('viewVisualFeedbackReports', function (User $user): bool {
return $user->isAdmin();
});
caution
A gate closure that takes a non-nullable $user is never called for a guest

That is Laravel's behavior, not this package's, and it is usually what you want: no signed-in user, no access. But it means a closure returning true still denies an anonymous visitor, which is confusing the first time you meet it.

If the surrounding route is already protected by something else and you genuinely want guests through, type the parameter as nullable:

Gate::define('viewVisualFeedbackReports', fn (?User $user): bool => true);

The check runs on every action, not once when the page loads. A Livewire component re-hydrates from client-supplied state on each request, so revoking access takes effect on the next click rather than the next full page load.

It needs the optional reports table

The browser reads the same table the database channel writes. That migration is opt-in — if you have not published and run it, the page says so instead of erroring:

php artisan vendor:publish --tag=visual-feedback-migrations
php artisan migrate

Nothing else is required. The browser has no configuration of its own.

Both view trees

It renders through whichever tree your application serves. On the framework-free tree it uses the same stylesheet and the same --vf-* custom properties as the widget, so overriding --vf-accent restyles both. On the WireKit tree it is built from WireKit's table, card, data-list and attachment components and inherits your design tokens.

Neither template carries an x- directive — every interaction is a Livewire round trip — and the browser works unchanged under a Content-Security-Policy that withholds unsafe-eval, with no separate variant to keep in step.

caution
A wire: expression is still an Alpine expression

This page claimed the opposite in 0.5.0, and it cost a release. "No x- directive" does not mean "no Alpine": Livewire rewrites a wire: action expression to $wire.<expression> and hands it to Alpine, so it meets the same grammar that the CSP build parses instead of evaluating.

The practical consequence is worth knowing if you write your own console against this component's methods, and it has two halves.

An action named after an operatordelete in instanceof new typeof void — is rewritten to $wire.<name>, which the CSP grammar rejects because a keyword is not an identifier. wire:click="delete(id)" is never evaluated; wire:click="$wire['delete'](id)" parses under both builds.

An action named after a literaltrue false null undefined — is on Livewire's skip list, so it is never rewritten. The expression parses and then calls the literal, which fails at runtime instead. Index access is the cure here too: wire:click="$wire['true'](id)" calls your method rather than the boolean.

Either way the button renders and does nothing, with nothing in the console pointing at the cause.

Screenshots in the detail pane

An attachment is shown as a picture only when your attachments disk declares a public url. On the shipped configuration it does not — the disk is private — so the pane shows the stored path instead.

That is deliberate. Rendering a preview would mean this package putting a download endpoint in front of your private files, and deciding who may read them; that is a larger decision than "let me look at my feedback" and it belongs to you. If you want previews, point visual-feedback.attachments.disk at a disk you have made public, exactly as you would for any other file your application serves.

Deleting

Deleting a report removes its attachment files first, then the row — the same order visual-feedback:prune uses, and for the same reason: a row deleted first leaves paths nobody can resolve, and the files stay on the disk for its lifetime.

It deletes exactly one report. There is no bulk delete, and retention remains the job of the scheduled prune command.