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
4 changes: 2 additions & 2 deletions resources/js/pages/experiments/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const applyVariant = async (variant) => {

<ui-button :href="routes.edit" class="btn-primary" v-text="__('Edit')" v-if="! experiment.completed_at" />

<ui-badge color="red" v-if="experiment.completed_at">Completed</ui-badge>
<ui-badge color="red" v-if="experiment.completed_at">{{ __('Completed') }}</ui-badge>
</ui-header>

<template v-if="! hasResults">
Expand Down Expand Up @@ -216,7 +216,7 @@ const applyVariant = async (variant) => {
<template #footer>
<div class="flex items-center justify-end space-x-3 pt-3 pb-1">
<ui-modal-close>
<ui-button text="Cancel" variant="ghost" />
<ui-button :text="__('Cancel')" variant="ghost" />
</ui-modal-close>
</div>
</template>
Expand Down
40 changes: 40 additions & 0 deletions resources/views/widgets/ab-tester.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<ui-widget title="{{ __('A/B Experiments') }}" icon="labs-idea-experimental-flask">
@if ($experiments->isEmpty())
<div class="flex flex-col items-center justify-center gap-2 py-10 text-center">
<ui-description>{{ __('No experiments are currently running.') }}</ui-description>
<a href="{{ $indexUrl }}" class="text-sm">{{ __('Create an experiment') }} &rarr;</a>
</div>
@else
<ui-table class="px-4 mt-2">
<ui-table-columns>
<ui-table-column>{{ __('Experiment') }}</ui-table-column>
<ui-table-column>{{ __('Hits') }}</ui-table-column>
<ui-table-column>{{ __('Leader') }}</ui-table-column>
</ui-table-columns>
<ui-table-rows>
@foreach ($experiments as $exp)
<ui-table-row>
<ui-table-cell>
<a href="{{ $exp['url'] }}" class="font-medium">{{ $exp['title'] }}</a>
</ui-table-cell>
<ui-table-cell>{{ number_format($exp['total_hits']) }}</ui-table-cell>
<ui-table-cell>
@if ($exp['leader_label'] !== null)
<span>{{ $exp['leader_label'] }}</span>
<ui-badge color="green" class="ml-2">{{ $exp['leader_rate'] }}%</ui-badge>
@else
<span>&mdash;</span>
@endif
</ui-table-cell>
</ui-table-row>
@endforeach
</ui-table-rows>
</ui-table>

<template #footer>
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-right">
<a href="{{ $indexUrl }}" class="text-sm">{{ __('View all experiments') }} &rarr;</a>
</div>
</template>
@endif
</ui-widget>
57 changes: 57 additions & 0 deletions src/Widgets/ABTesterWidget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Thoughtco\StatamicABTester\Widgets;

use Illuminate\Support\Facades\DB;
use Statamic\Widgets\Widget;
use Thoughtco\StatamicABTester\Facades\Experiment;

class ABTesterWidget extends Widget
{
protected static $handle = 'ab_tester';

public function html()
{
$activeExperiments = Experiment::query()
->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();
}
}
131 changes: 131 additions & 0 deletions tests/Widgets/ABTesterWidgetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

uses(\Thoughtco\StatamicABTester\Tests\TestCase::class);

use Thoughtco\StatamicABTester\Facades\Experiment;
use Thoughtco\StatamicABTester\Models\AbTestResult;
use Thoughtco\StatamicABTester\Widgets\ABTesterWidget;

function makeWidget(): ABTesterWidget
{
$widget = new ABTesterWidget;
$widget->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('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('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)->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)->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'));
});
});
Loading