diff --git a/README.md b/README.md index 7190841..d249a02 100644 --- a/README.md +++ b/README.md @@ -58,4 +58,5 @@ Finally, get to improving your user experience. At LaraconUS I gave a [talk on h - Supports session based validation errors - Supports API validation errors - Support Inertia validation errors +- Support Livewire validation errors - Fallback for undetectable validation errors (based on 422 response status) diff --git a/src/LivewireValidationError.php b/src/LivewireValidationError.php new file mode 100644 index 0000000..835e8f9 --- /dev/null +++ b/src/LivewireValidationError.php @@ -0,0 +1,16 @@ +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)); + }); + }); + } + /** * Record validation errors. */ - public function record(RequestHandled $event): void + public function record(LivewireValidationError|RequestHandled $event): void { - [$request, $response] = [ - $event->request, - $event->response, - ]; - - $this->pulse->lazy(function () use ($request, $response) { + $this->pulse->lazy(function () use ($event) { if (! $this->shouldSample()) { return; } - [$path, $via] = $this->resolveRoutePath($request); + [$path, $via] = $this->resolveRoutePath($event->request); if ($this->shouldIgnore($path)) { return; } - $this->parseValidationErrors($request, $response)->each(fn ($values) => $this->pulse->record( + $this->parseValidationErrors($event)->each(fn ($values) => $this->pulse->record( 'validation_error', - json_encode([$request->method(), $path, $via, ...$values], flags: JSON_THROW_ON_ERROR), + json_encode([$event->request->method(), $path, $via, ...$values], flags: JSON_THROW_ON_ERROR), )->count()); }); } @@ -74,11 +88,15 @@ public function record(RequestHandled $event): void * * @return \Illuminate\Support\Collection */ - protected function parseValidationErrors(Request $request, SymfonyResponse $response): Collection + protected function parseValidationErrors(LivewireValidationError|RequestHandled $event): Collection { - return $this->parseSessionValidationErrors($request, $response) - ?? $this->parseJsonValidationErrors($request, $response) - ?? $this->parseUnknownValidationErrors($request, $response) + if ($event instanceof LivewireValidationError) { + return $this->parseValidationExceptionMessages($event->request, $event->exception); + } + + return $this->parseSessionValidationErrors($event->request, $event->response) + ?? $this->parseJsonValidationErrors($event->request, $event->response) + ?? $this->parseUnknownValidationErrors($event->request, $event->response) ?? collect([]); } @@ -110,6 +128,24 @@ protected function parseSessionValidationErrors(Request $request, SymfonyRespons ); } + /** + * Parse validation exception errors. + * + * @return null|\Illuminate\Support\Collection + */ + protected function parseValidationExceptionMessages(Request $request, ValidationException $exception): ?Collection + { + if ($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true)) { + return collect($exception->validator->errors()) + ->flatMap(fn ($messages, $inputName) => array_map( + fn ($message) => [$exception->errorBag, $inputName, $message], $messages) + ); + } + + return collect($exception->validator->errors()->keys()) + ->map(fn ($inputName) => [$exception->errorBag, $inputName]); + } + /** * Parse JSON validation errors. * diff --git a/tests/RecorderTest.php b/tests/RecorderTest.php index 86dc652..a9572f6 100644 --- a/tests/RecorderTest.php +++ b/tests/RecorderTest.php @@ -6,9 +6,12 @@ use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Str; use Inertia\Middleware as InertiaMiddleware; use Laravel\Pulse\Facades\Pulse; +use Livewire\Livewire; use Symfony\Component\HttpFoundation\JsonResponse as SymfonyJsonResponse; +use Tests\TestClasses\DummyComponent; use TiMacDonald\Pulse\Recorders\ValidationErrors; use function Pest\Laravel\post; @@ -105,6 +108,41 @@ expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); }); +it('captures validation error keys from livewire components', function () { + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); + Livewire::component('dummy', DummyComponent::class); + + Str::createRandomStringsUsing(fn () => 'random-string'); + Livewire::test(DummyComponent::class) + ->call('save') + ->assertHasErrors('email'); + + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->key)->toBe('["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email"]'); + $aggregates = Pulse::ignore(fn () => DB::table('pulse_aggregates')->whereType('validation_error')->orderBy('period')->get()); + expect($aggregates->pluck('key')->all())->toBe(array_fill(0, 4, '["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email"]')); + 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 error messages from livewire components', function () { + Livewire::component('dummy', DummyComponent::class); + + Str::createRandomStringsUsing(fn () => 'random-string'); + Livewire::test(DummyComponent::class) + ->call('save') + ->assertHasErrors('email'); + + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->key)->toBe('["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","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(array_fill(0, 4, '["POST","\/livewire-unit-test-endpoint\/random-string","via \/livewire\/update","default","email","The email field is required."]')); + expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 4, 'count')); + expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); +}); + it('does not capture validation errors from redirects when there is no session', function () { Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', false); Route::post('users', fn () => Request::validate([ diff --git a/tests/TestClasses/DummyComponent.php b/tests/TestClasses/DummyComponent.php new file mode 100644 index 0000000..585b5a2 --- /dev/null +++ b/tests/TestClasses/DummyComponent.php @@ -0,0 +1,20 @@ +validate(['email' => 'required']); + } + + public function render() + { + return "
Dummy
"; + } +}