diff --git a/README.md b/README.md index 4605ab5..9bb9e44 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Finally, get to improving your user experience. At LaraconUS I gave a [talk on h - Support Inertia validation errors - Support Livewire validation errors - Fallback for undetectable validation errors (based on 422 response status) +- Capture generic validation exceptions for custom response types ## Ignore specific error messages @@ -91,3 +92,35 @@ public function boot(): void }); } ``` + +## Capture validation errors for custom response formats + +If you are returning custom response formats, you may see `__laravel_unknown` in the dashboard instead of the input names and error messages. This is because the package parses the response body to determine the validation errors. When the body is in an unrecognised format it is unable to parse the keys and messages from the response. + +You should instead dispatch the `ValidationExceptionOccurred` event to pass the validation messages to the card's recorder. You may do this wherever you are converting your exceptions into responses. This usually happens in the `app/Exceptions/Handler`: + +```php + Event::dispatch(new ValidationExceptionOccurred($request, $e))); + } + + // custom exception rendering logic... + } +} +``` diff --git a/src/Recorders/ValidationErrors.php b/src/Recorders/ValidationErrors.php index df4f5c0..8846f99 100644 --- a/src/Recorders/ValidationErrors.php +++ b/src/Recorders/ValidationErrors.php @@ -21,7 +21,7 @@ use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Livewire\Livewire; use Livewire\LivewireManager; -use TiMacDonald\Pulse\LivewireValidationError; +use TiMacDonald\Pulse\ValidationExceptionOccurred; /** * @internal @@ -41,6 +41,7 @@ class ValidationErrors */ public array $listen = [ RequestHandled::class, + ValidationExceptionOccurred::class, ]; /** @@ -57,7 +58,17 @@ public function register(callable $record, Application $app): void { $this->afterResolving($app, 'livewire', function (LivewireManager $livewire) use ($record, $app) { $livewire->listen('exception', function (Component $component, Throwable $exception) use ($record, $app) { - $exception instanceof ValidationException && $record(new LivewireValidationError($app['request'], $exception)); + if (! $exception instanceof ValidationException) { + return; + } + + with($app['request'], function (Request $request) use ($record, $exception) { + // Livewire can reuse the same request instance when polling or + // performing grouped requests. + $request->attributes->remove('pulse_validation_messages_recorded'); + + $record(new ValidationExceptionOccurred($request, $exception)); + }); }); }); } @@ -65,7 +76,7 @@ public function register(callable $record, Application $app): void /** * Record validation errors. */ - public function record(LivewireValidationError|RequestHandled $event): void + public function record(ValidationExceptionOccurred|RequestHandled $event): void { if ( $event->request->route() === null || @@ -75,12 +86,18 @@ public function record(LivewireValidationError|RequestHandled $event): void } $this->pulse->lazy(function () use ($event) { + if ($event->request->attributes->has('pulse_validation_messages_recorded')) { + return; + } + [$path, $via] = $this->resolveRoutePath($event->request); if ($this->shouldIgnore($path)) { return; } + $event->request->attributes->set('pulse_validation_messages_recorded', true); + $path = $this->group($path); $this->parseValidationErrors($event)->each(fn ($values) => $this->pulse->record( @@ -95,9 +112,9 @@ public function record(LivewireValidationError|RequestHandled $event): void * * @return \Illuminate\Support\Collection */ - protected function parseValidationErrors(LivewireValidationError|RequestHandled $event): Collection + protected function parseValidationErrors(ValidationExceptionOccurred|RequestHandled $event): Collection { - if ($event instanceof LivewireValidationError) { + if ($event instanceof ValidationExceptionOccurred) { return $this->parseValidationExceptionMessages($event->request, $event->exception); } diff --git a/src/LivewireValidationError.php b/src/ValidationExceptionOccurred.php similarity index 88% rename from src/LivewireValidationError.php rename to src/ValidationExceptionOccurred.php index 835e8f9..2cb9594 100644 --- a/src/LivewireValidationError.php +++ b/src/ValidationExceptionOccurred.php @@ -5,7 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; -class LivewireValidationError +class ValidationExceptionOccurred { public function __construct( public Request $request, diff --git a/tests/RecorderTest.php b/tests/RecorderTest.php index 19d86e7..a3d4b8f 100644 --- a/tests/RecorderTest.php +++ b/tests/RecorderTest.php @@ -3,10 +3,12 @@ use Illuminate\Http\JsonResponse as IlluminateJsonResponse; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Inertia\Middleware as InertiaMiddleware; use Laravel\Pulse\Entry; use Laravel\Pulse\Facades\Pulse; @@ -15,6 +17,7 @@ use Symfony\Component\HttpFoundation\JsonResponse as SymfonyJsonResponse; use Tests\TestClasses\DummyComponent; use TiMacDonald\Pulse\Recorders\ValidationErrors; +use TiMacDonald\Pulse\ValidationExceptionOccurred; use function Pest\Laravel\get; use function Orchestra\Testbench\Pest\defineEnvironment; @@ -584,3 +587,40 @@ expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 4, 'count')); expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); }); + +it('captures validation errors from custom event', function () { + Route::post('users', function () { + try { + Request::validate([ + 'name' => 'required', + 'email' => 'required', + ]); + } catch (ValidationException $e) { + Event::dispatch(new ValidationExceptionOccurred(request(), $e)); + + throw $e; + } + })->middleware('web'); + + $response = post('users'); + + $response->assertStatus(302); + $response->assertInvalid(['name', 'email']); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(2); + expect($entries[0]->key)->toBe('["POST","\/users","Closure","default","name","The name field is required."]'); + expect($entries[1]->key)->toBe('["POST","\/users","Closure","default","email","The email field is required."]'); + $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->whereType('validation_error')->orderBy('period')->get()); + expect($aggregates->pluck('key')->all())->toBe([ + '["POST","\/users","Closure","default","name","The name field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","default","name","The name field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","default","name","The name field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","default","name","The name field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + ]); + expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 8, 'count')); + expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); +});