diff --git a/README.md b/README.md index f1ed4f5..8e8a486 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ return [ TiMacDonald\Pulse\Recorders\ValidationErrors::class => [ 'enabled' => env('PULSE_VALIDATION_ERRORS_ENABLED', true), 'sample_rate' => env('PULSE_VALIDATION_ERRORS_SAMPLE_RATE', 1), + 'capture_messages' => true, 'ignore' => [ // '#^/login$#', // '#^/register$#', diff --git a/art/screenshot.jpg b/art/screenshot.jpg deleted file mode 100644 index dc86b55..0000000 Binary files a/art/screenshot.jpg and /dev/null differ diff --git a/art/screenshot.png b/art/screenshot.png new file mode 100644 index 0000000..990046a Binary files /dev/null and b/art/screenshot.png differ diff --git a/dist/validation.css b/dist/validation.css index 0b7e25f..543ae00 100644 --- a/dist/validation.css +++ b/dist/validation.css @@ -1 +1 @@ -.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.block{display:block}.flex{display:flex}.table{display:table}.h-2{height:.5rem}.h-6{height:1.5rem}.w-6{width:1.5rem}.max-w-32{max-width:8rem}.max-w-\[1px\]{max-width:1px}.flex-col{flex-direction:column}.gap-2{gap:.5rem}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.break-words{overflow-wrap:break-word}.text-center{text-align:center}.text-right{text-align:right}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.first\:h-0:first-child{height:0px}.dark\:text-gray-100:is(.dark *){--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}@media (min-width: 768px){.md\:max-w-64{max-width:16rem}} +.mt-2{margin-top:.5rem}.block{display:block}.flex{display:flex}.h-2{height:.5rem}.h-6{height:1.5rem}.w-32{width:8rem}.w-6{width:1.5rem}.max-w-\[1px\]{max-width:1px}.gap-2{gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.first\:h-0:first-child{height:0px}.dark\:text-gray-100:is(.dark *){--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))} diff --git a/phpunit.xml b/phpunit.xml index 037dedb..4983dfe 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,7 @@ + diff --git a/resources/views/validation-errors.blade.php b/resources/views/validation-errors.blade.php index c7cc9ee..7bf6f5d 100644 --- a/resources/views/validation-errors.blade.php +++ b/resources/views/validation-errors.blade.php @@ -12,53 +12,39 @@ - @if ($validationErrors->isEmpty()) + @if ($errors->isEmpty()) @else - Input - Via + Error Count - @foreach ($validationErrors->take(100) as $validationError) - - - -
- {{ $validationError->name }} + @foreach ($errors->take(100) as $error) + + + +
+ [{{ $error->name }}{{ $error->bag ? '@'.$error->bag : '' }}] {{ $error->message }}
- @if ($validationError->bag !== 'default') -
- {{ '@'.$validationError->bag }} -
- @endif -
- -
-
-
- - - {{ $validationError->uri }} - -
-
- @if ($validationError->action) -

- {{ $validationError->action }} -

- @endif +
+ + + {{ $error->uri }} +
- - +

+ {{ $error->action }} +

+
+ @if ($config['sample_rate'] < 1) - ~{{ number_format($validationError->count * (1 / $config['sample_rate'])) }} + ~{{ number_format($error->count * (1 / $config['sample_rate'])) }} @else - {{ number_format($validationError->count) }} + {{ number_format($error->count) }} @endif @@ -66,7 +52,7 @@ - @if ($validationErrors->count() > 100) + @if ($errors->count() > 100)
Limited to 100 entries
@endif @endif diff --git a/seed.php b/seed.php new file mode 100644 index 0000000..728283d --- /dev/null +++ b/seed.php @@ -0,0 +1,114 @@ +count(); + } + $count = 622; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'PATCH', + '/episodes', + 'App\Http\Controllers\EpisodeController@update', + 'default', + 'version', + 'The version field is required.' + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + $count = 588; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'POST', + '/shows', + 'App\Http\Controllers\ShowController@store', + 'default', + 'website', + 'The website field must start with one of the following: http://, https://.' + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + $count = 582; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'POST', + '/episodes', + 'App\Http\Controllers\EpisodeController@store', + 'default', + 'version', + 'The version field is required.' + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + $count = 288; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'DELETE', + '/episodes', + 'App\Http\Controllers\EpisodeController@destroy', + 'default', + 'title_confirmation', + 'The title confirmation does not match the episode title.', + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + $count = 205; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'POST', + '/episodes', + 'App\Http\Controllers\EpisodeController@store', + 'default', + 'custom_feed_url', + 'The custom feed url field must be a valid URL.', + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + $count = 107; + for ($i = 0; $i < $count; $i++) { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'PATCH', + '/users', + 'App\Http\Controllers\UserController@update', + 'default', + 'password', + 'The given password has appeared in a data leak. Please choose a different password.', + ], flags: JSON_THROW_ON_ERROR), + )->count(); + } + + Pulse::ingest(); +}); + diff --git a/src/Cards/ValidationErrors.php b/src/Cards/ValidationErrors.php index e906286..848fb02 100644 --- a/src/Cards/ValidationErrors.php +++ b/src/Cards/ValidationErrors.php @@ -25,21 +25,26 @@ class ValidationErrors extends Card */ public function render(): Renderable { - [$validationErrors, $time, $runAt] = $this->remember( + [$errors, $time, $runAt] = $this->remember( fn () => Pulse::aggregate( 'validation_error', ['count'], $this->periodAsInterval(), )->map(function ($row) { - [$method, $uri, $action, $bag, $name] = json_decode($row->key, flags: JSON_THROW_ON_ERROR); + [$method, $uri, $action, $bag, $name, $message] = json_decode($row->key, flags: JSON_THROW_ON_ERROR) + [5 => null]; return (object) [ - 'bag' => $bag, + 'bag' => match ($bag) { + 'default' => null, + default => $bag, + }, 'uri' => $uri, 'name' => $name, 'action' => $action, 'method' => $method, + 'message' => $message, 'count' => $row->count, + 'key_hash' => md5($row->key), ]; }), ); @@ -47,8 +52,14 @@ public function render(): Renderable return View::make('timacdonald::validation-errors', [ 'time' => $time, 'runAt' => $runAt, - 'validationErrors' => $validationErrors, - 'config' => Config::get('pulse.recorders.'.ValidationErrorsRecorder::class), + 'errors' => $errors, + 'config' => [ + 'enabled' => true, + 'sample_rate' => 1, + 'capture_messages' => true, + 'ignore' => [], + ...Config::get('pulse.recorders.'.ValidationErrorsRecorder::class, []), + ], ]); } diff --git a/src/Recorders/ValidationErrors.php b/src/Recorders/ValidationErrors.php index edb7cae..ea80f1a 100644 --- a/src/Recorders/ValidationErrors.php +++ b/src/Recorders/ValidationErrors.php @@ -78,7 +78,6 @@ protected function parseValidationErrors(Request $request, SymfonyResponse $resp { return $this->parseSessionValidationErrors($request, $response) ?? $this->parseJsonValidationErrors($request, $response) - ?? $this->parseInertiaValidationErrors($request, $response) ?? $this->parseUnknownValidationErrors($request, $response) ?? collect([]); } @@ -98,6 +97,14 @@ protected function parseSessionValidationErrors(Request $request, SymfonyRespons return null; } + if ($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true)) { + return collect($errors->getBags()) + ->flatMap(fn ($bag, $bagName) => collect($bag->messages()) + ->flatMap(fn ($messages, $inputName) => array_map( + fn ($message) => [$bagName, $inputName, $message], $messages) + )); + } + return collect($errors->getBags())->flatMap( fn ($bag, $bagName) => array_map(fn ($inputName) => [$bagName, $inputName], $bag->keys()) ); @@ -121,36 +128,13 @@ protected function parseJsonValidationErrors(Request $request, SymfonyResponse $ return null; } - return collect($errors)->keys()->map(fn ($inputName) => ['default', $inputName]); - } - - /** - * Parse Inertia validation errors. - * - * @return null|\Illuminate\Support\Collection - */ - protected function parseInertiaValidationErrors(Request $request, SymfonyResponse $response): ?Collection - { - if ( - $request->isMethodSafe() || - ! $request->header('X-Inertia') || - ! $response instanceof JsonResponse || - ! is_array($response->original) || - ! array_key_exists('props', $response->original) || - ! is_array($response->original['props']) || - ! array_key_exists('errors', $response->original['props']) || - ! ($errors = $response->original['props']['errors']) instanceof stdClass - ) { - return null; + if ($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true)) { + return collect($errors)->flatMap(fn ($messages, $inputName) => array_map( + fn ($message) => ['default', $inputName, $message], $messages) + ); } - if (is_string(($errors = collect($errors))->first())) { - return $errors->keys()->map(fn ($inputName) => ['default', $inputName]); - } - - return $errors->flatMap( - fn ($bag, $bagName) => collect($bag)->keys()->map(fn ($inputName) => [$bagName, $inputName]) - ); + return collect($errors)->keys()->map(fn ($inputName) => ['default', $inputName]); } /** @@ -164,6 +148,10 @@ protected function parseUnknownValidationErrors(Request $request, SymfonyRespons return null; } - return collect([['default', '__laravel_unknown']]); + return collect([[ + 'default', + '__laravel_unknown', + ...($this->config->get('pulse.recorders.'.static::class.'.capture_messages', true) ? ['__laravel_unknown'] : []) + ]]); } } diff --git a/tests/CardTest.php b/tests/CardTest.php new file mode 100644 index 0000000..0869dea --- /dev/null +++ b/tests/CardTest.php @@ -0,0 +1,68 @@ +count(); + Pulse::ingest(); + + Livewire::test(ValidationErrors::class, ['lazy' => false]) + ->assertViewHas('errors', function ($errors) { + expect($errors)->toHaveCount(1); + + expect($errors[0])->toEqual(literal( + method: 'POST', + uri: '/register', + action: 'App\Http\Controllers\RegisterController@store', + bag: 'default', + name: 'email', + message: null, + count: 1, + )); + + return true; + }); +}); + +it('optionally supports', function () { + Pulse::record( + type: 'validation_error', + key: json_encode([ + 'POST', + '/register', + 'App\Http\Controllers\RegisterController@store', + 'default', + 'email', + 'The email field is required.', + ], flags: JSON_THROW_ON_ERROR), + )->count(); + Pulse::ingest(); + + Livewire::test(ValidationErrors::class, ['lazy' => false]) + ->assertViewHas('errors', function ($errors) { + expect($errors)->toHaveCount(1); + + expect($errors[0])->toEqual(literal( + method: 'POST', + uri: '/register', + action: 'App\Http\Controllers\RegisterController@store', + bag: 'default', + name: 'email', + message: 'The email field is required.', + count: 1, + )); + + return true; + }); +}); diff --git a/tests/RecorderTest.php b/tests/RecorderTest.php index 7132636..413d092 100644 --- a/tests/RecorderTest.php +++ b/tests/RecorderTest.php @@ -292,7 +292,7 @@ ->withErrors(['email' => 'The email field is required.'], 'custom_2'); })->middleware(['web', InertiaMiddleware::class]); - $response = post('users'); + $response = post('users', [], ['X-Inertia' => '1']); $response->assertStatus(302); $response->assertInvalid('email'); @@ -321,3 +321,112 @@ expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 12, 'count')); expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); }); + +it('can capture messages for session based validation errors', function () { + Route::post('users', fn () => Request::validate([ + 'email' => 'required', + ]))->middleware('web'); + + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); + $response = post('users'); + + $response->assertStatus(302); + $response->assertInvalid('email'); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->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(array_fill(0, 4, '["POST","\/users","Closure","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('can capture messages for API based validation errors', function () { + Route::post('users', fn () => Request::validate([ + 'email' => 'required', + ]))->middleware('api'); + + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); + $response = postJson('users'); + + $response->assertStatus(422); + $response->assertInvalid('email'); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->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(array_fill(0, 4, '["POST","\/users","Closure","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('can capture messages for inertia based validation errors', function () { + Route::post('users', fn () => Request::validate([ + 'email' => 'required', + ]))->middleware(['web', InertiaMiddleware::class]); + + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); + $response = post('users', [], ['X-Inertia' => '1']); + + $response->assertStatus(302); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->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(array_fill(0, 4, '["POST","\/users","Closure","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('can capture message for inertia based validation errors for mutliple bags', function () { + Route::post('users', function () { + return Redirect::back()->withErrors(['email' => 'The email field is required.']) + ->withErrors(['email' => 'The email field is required.'], 'custom_1') + ->withErrors(['email' => 'The email field is required.'], 'custom_2'); + })->middleware(['web', InertiaMiddleware::class]); + + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); + $response = post('users', [], ['X-Inertia' => '1']); + + $response->assertStatus(302); + $response->assertInvalid('email'); + $response->assertInvalid('email', 'custom_1'); + $response->assertInvalid('email', 'custom_2'); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries[0]->key)->toBe('["POST","\/users","Closure","default","email","The email field is required."]'); + expect($entries[1]->key)->toBe('["POST","\/users","Closure","custom_1","email","The email field is required."]'); + expect($entries[2]->key)->toBe('["POST","\/users","Closure","custom_2","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","email","The email field is required."]', + '["POST","\/users","Closure","custom_1","email","The email field is required."]', + '["POST","\/users","Closure","custom_2","email","The email field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","custom_1","email","The email field is required."]', + '["POST","\/users","Closure","custom_2","email","The email field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","custom_1","email","The email field is required."]', + '["POST","\/users","Closure","custom_2","email","The email field is required."]', + '["POST","\/users","Closure","default","email","The email field is required."]', + '["POST","\/users","Closure","custom_1","email","The email field is required."]', + '["POST","\/users","Closure","custom_2","email","The email field is required."]', + ]); + expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 12, 'count')); + expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); +}); + +it('can capture messages for generic validation errors', function () { + Route::post('users', fn () => response('

An error occurred.

', 422))->middleware('web'); + + Config::set('pulse.recorders.'.ValidationErrors::class.'.capture_messages', true); + $response = post('users'); + + $response->assertStatus(422); + $entries = Pulse::ignore(fn () => DB::table('pulse_entries')->whereType('validation_error')->get()); + expect($entries)->toHaveCount(1); + expect($entries[0]->key)->toBe('["POST","\/users","Closure","default","__laravel_unknown","__laravel_unknown"]'); + $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","\/users","Closure","default","__laravel_unknown","__laravel_unknown"]')); + expect($aggregates->pluck('aggregate')->all())->toBe(array_fill(0, 4, 'count')); + expect($aggregates->pluck('value')->every(fn ($value) => $value == 1.0))->toBe(true); +});