From d9ecd57930c0c8513ede46fc6016ab202373a93b Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Thu, 12 Mar 2026 13:29:15 +0000 Subject: [PATCH 1/5] Add dashboard widget --- resources/views/widgets/ab-tester.blade.php | 40 ++++++ src/Widgets/ABTesterWidget.php | 57 ++++++++ .../Stache/ExperimentRepositoryTest.php | 4 +- tests/Tags/ABTagsTest.php | 2 +- tests/Widgets/ABTesterWidgetTest.php | 135 ++++++++++++++++++ 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 resources/views/widgets/ab-tester.blade.php create mode 100644 src/Widgets/ABTesterWidget.php create mode 100644 tests/Widgets/ABTesterWidgetTest.php diff --git a/resources/views/widgets/ab-tester.blade.php b/resources/views/widgets/ab-tester.blade.php new file mode 100644 index 0000000..c999c30 --- /dev/null +++ b/resources/views/widgets/ab-tester.blade.php @@ -0,0 +1,40 @@ + + @if ($experiments->isEmpty()) +
+ {{ __('No experiments are currently running.') }} + {{ __('Create an experiment') }} → +
+ @else + + + {{ __('Experiment') }} + {{ __('Hits') }} + {{ __('Leader') }} + + + @foreach ($experiments as $exp) + + + {{ $exp['title'] }} + + {{ number_format($exp['total_hits']) }} + + @if ($exp['leader_label'] !== null) + {{ $exp['leader_label'] }} + {{ $exp['leader_rate'] }}% + @else + + @endif + + + @endforeach + + + + + @endif +
diff --git a/src/Widgets/ABTesterWidget.php b/src/Widgets/ABTesterWidget.php new file mode 100644 index 0000000..34f60d0 --- /dev/null +++ b/src/Widgets/ABTesterWidget.php @@ -0,0 +1,57 @@ +whereNull('completed_at') + ->where('published', true) + ->where(fn ($q) => $q->whereNull('start_at')->orWhere('start_at', '<=', now())) + ->where(fn ($q) => $q->whereNull('end_at')->orWhere('end_at', '>=', now())) + ->get(); + + $experiments = $activeExperiments->map(function ($experiment) { + $variantResults = $experiment->resultsQuery() + ->select('variation', DB::raw('count(*) as total')) + ->groupBy('variation') + ->get() + ->map(function ($row) use ($experiment) { + $successes = $experiment->resultsQuery() + ->where('variation', $row->variation) + ->where('type', 'success') + ->count(); + + return [ + 'label' => $row->variation, + 'hits' => $row->total, + 'rate' => $row->total > 0 ? round($successes / $row->total * 100, 1) : 0, + ]; + }); + + $leader = $variantResults->sortByDesc('rate')->first(); + + return [ + 'title' => $experiment->title(), + 'url' => cp_route('ab.experiments.show', $experiment->id()), + 'total_hits' => $variantResults->sum('hits'), + 'leader_label' => $leader ? $leader['label'] : null, + 'leader_rate' => $leader ? $leader['rate'] : null, + ]; + }); + + return view('ab::widgets.ab-tester', [ + 'activeCount' => $activeExperiments->count(), + 'experiments' => $experiments, + 'indexUrl' => cp_route('ab.experiments.index'), + ])->render(); + } +} diff --git a/tests/Experiment/Stache/ExperimentRepositoryTest.php b/tests/Experiment/Stache/ExperimentRepositoryTest.php index 3730941..e7ea17c 100644 --- a/tests/Experiment/Stache/ExperimentRepositoryTest.php +++ b/tests/Experiment/Stache/ExperimentRepositoryTest.php @@ -3,8 +3,8 @@ uses(\Thoughtco\StatamicABTester\Tests\TestCase::class); use Statamic\Facades\File; -use Thoughtco\StatamicABTester\Experiment\Stache\Experiment; -use Thoughtco\StatamicABTester\Experiment\Stache\ExperimentQueryBuilder; +use Thoughtco\StatamicABTester\Events\Experiment\Stache\Experiment; +use Thoughtco\StatamicABTester\Events\Experiment\Stache\ExperimentQueryBuilder; use Thoughtco\StatamicABTester\Facades\Experiment as ExperimentApi; it('can make an experiment', function () { diff --git a/tests/Tags/ABTagsTest.php b/tests/Tags/ABTagsTest.php index d2e54f7..2c014bf 100644 --- a/tests/Tags/ABTagsTest.php +++ b/tests/Tags/ABTagsTest.php @@ -3,7 +3,7 @@ uses(\Thoughtco\StatamicABTester\Tests\TestCase::class); use Statamic\Facades; -use Thoughtco\StatamicABTester\Experiment\Stache\Experiment; +use Thoughtco\StatamicABTester\Events\Experiment\Stache\Experiment; it('returns a variant', function () { (new Experiment) diff --git a/tests/Widgets/ABTesterWidgetTest.php b/tests/Widgets/ABTesterWidgetTest.php new file mode 100644 index 0000000..593d2ae --- /dev/null +++ b/tests/Widgets/ABTesterWidgetTest.php @@ -0,0 +1,135 @@ +setConfig([]); + + return $widget; +} + +describe('ABTesterWidget', function () { + it('is registered with the correct handle', function () { + expect(ABTesterWidget::handle())->toBe('ab_tester'); + }); + + it('renders HTML', function () { + $html = makeWidget()->html(); + + expect($html)->toBeString()->not->toBeEmpty(); + }); + + it('shows a count of zero when there are no active experiments', function () { + $html = makeWidget()->html(); + + expect($html)->toContain('0'); + expect($html)->toContain('No experiments are currently running.'); + }); + + it('counts active experiments', function () { + tap(Experiment::make('active-1')->title('Active One')->published(true))->save(); + tap(Experiment::make('active-2')->title('Active Two')->published(true))->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain('2'); + expect($html)->toContain('Active One'); + expect($html)->toContain('Active Two'); + }); + + it('excludes completed experiments', function () { + tap(Experiment::make('active')->title('Active')->published(true))->save(); + + tap(Experiment::make('done')->title('Done')->published(true)) + ->completedAt(now()) + ->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain('1'); + expect($html)->toContain('Active'); + expect($html)->not->toContain('Done'); + }); + + it('excludes unpublished experiments', function () { + tap(Experiment::make('published')->title('Published')->published(true))->save(); + tap(Experiment::make('draft')->title('Draft')->published(false))->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain('1'); + expect($html)->toContain('Published'); + expect($html)->not->toContain('Draft'); + }); + + it('excludes experiments that have not started yet', function () { + tap(Experiment::make('future')->title('Future')->published(true)) + ->startAt(now()->addDay()) + ->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain('0'); + expect($html)->not->toContain('Future'); + }); + + it('excludes experiments past their end date', function () { + tap(Experiment::make('expired')->title('Expired')->published(true)) + ->endAt(now()->subDay()) + ->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain('0'); + expect($html)->not->toContain('Expired'); + }); + + it('shows total hits for each experiment', function () { + $experiment = tap(Experiment::make('hit-test')->title('Hit Test')->published(true))->save(); + + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '2', 'type' => 'hit', 'data' => []]); + + $html = makeWidget()->html(); + + expect($html)->toContain('3'); + }); + + it('shows the leading variant label', function () { + $experiment = tap(Experiment::make('leader-test')->title('Leader Test')->published(true))->save(); + + // variant 1: 1 hit, 1 success = 100% rate + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'control', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'control', 'type' => 'success', 'data' => []]); + // variant 2: 10 hits, 0 successes = 0% rate + for ($i = 0; $i < 10; $i++) { + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'variant_a', 'type' => 'hit', 'data' => []]); + } + + $html = makeWidget()->html(); + + // control has a higher conversion rate so it should appear as the leader + expect($html)->toContain('control'); + }); + + it('links each experiment to its show page', function () { + $experiment = tap(Experiment::make('link-test')->title('Link Test')->published(true))->save(); + + $html = makeWidget()->html(); + + expect($html)->toContain(cp_route('ab.experiments.show', $experiment->id())); + }); + + it('includes a link to the experiments index', function () { + $html = makeWidget()->html(); + + expect($html)->toContain(cp_route('ab.experiments.index')); + }); +}); From bcf68ba5a9cd9c9722b6bb728242ecc7e76f4fcd Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Thu, 12 Mar 2026 13:32:16 +0000 Subject: [PATCH 2/5] fix failing tests --- tests/Experiment/Stache/ExperimentRepositoryTest.php | 4 ++-- tests/Tags/ABTagsTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Experiment/Stache/ExperimentRepositoryTest.php b/tests/Experiment/Stache/ExperimentRepositoryTest.php index e7ea17c..3730941 100644 --- a/tests/Experiment/Stache/ExperimentRepositoryTest.php +++ b/tests/Experiment/Stache/ExperimentRepositoryTest.php @@ -3,8 +3,8 @@ uses(\Thoughtco\StatamicABTester\Tests\TestCase::class); use Statamic\Facades\File; -use Thoughtco\StatamicABTester\Events\Experiment\Stache\Experiment; -use Thoughtco\StatamicABTester\Events\Experiment\Stache\ExperimentQueryBuilder; +use Thoughtco\StatamicABTester\Experiment\Stache\Experiment; +use Thoughtco\StatamicABTester\Experiment\Stache\ExperimentQueryBuilder; use Thoughtco\StatamicABTester\Facades\Experiment as ExperimentApi; it('can make an experiment', function () { diff --git a/tests/Tags/ABTagsTest.php b/tests/Tags/ABTagsTest.php index 2c014bf..d2e54f7 100644 --- a/tests/Tags/ABTagsTest.php +++ b/tests/Tags/ABTagsTest.php @@ -3,7 +3,7 @@ uses(\Thoughtco\StatamicABTester\Tests\TestCase::class); use Statamic\Facades; -use Thoughtco\StatamicABTester\Events\Experiment\Stache\Experiment; +use Thoughtco\StatamicABTester\Experiment\Stache\Experiment; it('returns a variant', function () { (new Experiment) From f42e4c83ea274ba2f5b217b2e935acec68dda782 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Thu, 12 Mar 2026 13:35:23 +0000 Subject: [PATCH 3/5] fix some missing trans strings --- resources/js/pages/experiments/Show.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/experiments/Show.vue b/resources/js/pages/experiments/Show.vue index 28973e1..d6b7dd0 100644 --- a/resources/js/pages/experiments/Show.vue +++ b/resources/js/pages/experiments/Show.vue @@ -63,7 +63,7 @@ const applyVariant = async (variant) => { - Completed + {{ __('Completed') }}