Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 16 additions & 0 deletions src/LivewireValidationError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace TiMacDonald\Pulse;

use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class LivewireValidationError
{
public function __construct(
public Request $request,
public ValidationException $exception,
) {
// ...
}
}
68 changes: 52 additions & 16 deletions src/Recorders/ValidationErrors.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,34 @@
namespace TiMacDonald\Pulse\Recorders;

use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Http\Events\RequestHandled;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\ViewErrorBag;
use Illuminate\Validation\ValidationException;
use Laravel\Pulse\Concerns\ConfiguresAfterResolving;
use Laravel\Pulse\Pulse;
use Laravel\Pulse\Recorders\Concerns\Ignores;
use Laravel\Pulse\Recorders\Concerns\LivewireRoutes;
use Laravel\Pulse\Recorders\Concerns\Sampling;
use stdClass;
use Livewire\Component;
use Throwable;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Livewire\Livewire;
use Livewire\LivewireManager;
use TiMacDonald\Pulse\LivewireValidationError;

/**
* @internal
*/
class ValidationErrors
{
use Ignores, LivewireRoutes, Sampling;
use Ignores,
Sampling,
LivewireRoutes,
ConfiguresAfterResolving;

/**
* The events to listen for.
Expand All @@ -41,30 +51,34 @@ public function __construct(
//
}

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));
});
});
}

/**
* 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());
});
}
Expand All @@ -74,11 +88,15 @@ public function record(RequestHandled $event): void
*
* @return \Illuminate\Support\Collection<int, array{ 0: string, 1: string }>
*/
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([]);
}

Expand Down Expand Up @@ -110,6 +128,24 @@ protected function parseSessionValidationErrors(Request $request, SymfonyRespons
);
}

/**
* Parse validation exception errors.
*
* @return null|\Illuminate\Support\Collection<int, array{ 0: string, 1: string }>
*/
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.
*
Expand Down
38 changes: 38 additions & 0 deletions tests/RecorderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
20 changes: 20 additions & 0 deletions tests/TestClasses/DummyComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Tests\TestClasses;

use Livewire\Component;

class DummyComponent extends Component
{
public string $email = '';

public function save()
{
$this->validate(['email' => 'required']);
}

public function render()
{
return "<div>Dummy</div>";
}
}