diff --git a/resources/js/pages/experiments/Show.vue b/resources/js/pages/experiments/Show.vue index 28973e1..02deb8a 100644 --- a/resources/js/pages/experiments/Show.vue +++ b/resources/js/pages/experiments/Show.vue @@ -10,7 +10,7 @@ const { width, height } = useWindowSize() const props = defineProps({ experiment: { type: Object, required: true }, hasResults: { type: Boolean, required: true, default: true }, - results: { type: Array, required: true, default: [] }, + results: { type: Object, required: true, default: [] }, significance: { type: Object, default: null }, routes: { type: Object, required: true }, }); @@ -61,7 +61,9 @@ const applyVariant = async (variant) => { - + + + {{ __('Export CSV') }} Completed diff --git a/routes/cp.php b/routes/cp.php index 1b3ade1..c6657d6 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -17,6 +17,7 @@ Route::post('/', [ExperimentsController::class, 'store'])->name('store'); Route::get('/{experiment}', [ExperimentsController::class, 'show'])->name('show'); + Route::get('/{experiment}/export', [ExperimentsController::class, 'export'])->name('export'); Route::post('/{experiment}/complete', [ExperimentsController::class, 'complete'])->name('complete'); Route::get('/{experiment}/edit', [ExperimentsController::class, 'edit'])->name('edit'); Route::delete('/{experiment}/delete', [ExperimentsController::class, 'destroy'])->name('delete'); diff --git a/src/Http/Controllers/ExperimentsController.php b/src/Http/Controllers/ExperimentsController.php index d48d865..136269d 100644 --- a/src/Http/Controllers/ExperimentsController.php +++ b/src/Http/Controllers/ExperimentsController.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Statamic\Exceptions\NotFoundHttpException; @@ -164,10 +165,44 @@ public function show($experiment) 'routes' => [ 'edit' => cp_route('ab.experiments.edit', $experiment->id()), 'complete' => cp_route('ab.experiments.complete', $experiment->id()), + 'export' => cp_route('ab.experiments.export', $experiment->id()), ], ]); } + public function export($experiment) + { + throw_unless($experiment = Experiment::find($experiment), NotFoundHttpException::class); + + $filename = Str::slug($experiment->title()).'-results.csv'; + + return response()->stream(function () use ($experiment) { + $handle = fopen('php://output', 'w'); + + fputcsv($handle, ['id', 'variation', 'type', 'goal_id', 'ip_address', 'user_id', 'created_at', 'data']); + + $experiment->resultsQuery()->chunk(500, function ($rows) use ($handle) { + foreach ($rows as $row) { + fputcsv($handle, [ + $row->id, + $row->variation, + $row->type, + $row->goal_id, + $row->ip_address, + $row->user_id, + $row->created_at, + json_encode($row->data), + ]); + } + }); + + fclose($handle); + }, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + public function store(Request $request) { $request->validate([ diff --git a/tests/Controllers/ExperimentExportTest.php b/tests/Controllers/ExperimentExportTest.php new file mode 100644 index 0000000..de11d1f --- /dev/null +++ b/tests/Controllers/ExperimentExportTest.php @@ -0,0 +1,119 @@ +actingAs(User::make()->makeSuper()->save()); +}); + +describe('Experiment CSV export', function () { + it('returns a CSV response', function () { + $experiment = tap(Experiment::make('export-test')->title('Export Test'))->save(); + + $response = $this->get(cp_route('ab.experiments.export', $experiment->id())); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); + $response->assertHeader('Content-Disposition', 'attachment; filename="export-test-results.csv"'); + }); + + it('includes the header row', function () { + $experiment = tap(Experiment::make('export-test')->title('Export Test'))->save(); + + $csv = $this->get(cp_route('ab.experiments.export', $experiment->id())) + ->streamedContent(); + + $lines = array_filter(explode("\n", $csv)); + $header = str_getcsv(reset($lines)); + + expect($header)->toBe(['id', 'variation', 'type', 'goal_id', 'ip_address', 'user_id', 'created_at', 'data']); + }); + + it('exports one data row per result record', function () { + $experiment = tap(Experiment::make('export-test')->title('Export Test'))->save(); + + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'success', 'data' => []]); + AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '2', 'type' => 'hit', 'data' => []]); + + $csv = $this->get(cp_route('ab.experiments.export', $experiment->id())) + ->streamedContent(); + + $lines = array_values(array_filter(explode("\n", trim($csv)))); + + // 1 header + 3 data rows + expect($lines)->toHaveCount(4); + }); + + it('exports the correct values for each row', function () { + $experiment = tap(Experiment::make('export-test')->title('Export Test'))->save(); + + AbTestResult::create([ + 'experiment_id' => $experiment->id(), + 'variation' => '1', + 'type' => 'hit', + 'goal_id' => null, + 'ip_address' => '127.0.0.1', + 'user_id' => null, + 'data' => ['page' => '/home'], + ]); + + $csv = $this->get(cp_route('ab.experiments.export', $experiment->id())) + ->streamedContent(); + + $lines = array_values(array_filter(explode("\n", trim($csv)))); + $dataRow = str_getcsv($lines[1]); + + expect($dataRow[1])->toBe('1'); // variation + expect($dataRow[2])->toBe('hit'); // type + expect($dataRow[3])->toBe(''); // goal_id (null → empty) + expect($dataRow[4])->toBe('127.0.0.1'); // ip_address + expect($dataRow[5])->toBe(''); // user_id (null → empty) + expect(json_decode($dataRow[7], true))->toBe(['page' => '/home']); // data + }); + + it('exports only results for the requested experiment', function () { + $expA = tap(Experiment::make('exp-a')->title('Exp A'))->save(); + $expB = tap(Experiment::make('exp-b')->title('Exp B'))->save(); + + AbTestResult::create(['experiment_id' => $expA->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $expB->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]); + AbTestResult::create(['experiment_id' => $expB->id(), 'variation' => '2', 'type' => 'hit', 'data' => []]); + + $csv = $this->get(cp_route('ab.experiments.export', $expA->id())) + ->streamedContent(); + + $lines = array_values(array_filter(explode("\n", trim($csv)))); + + // 1 header + 1 data row (only expA's result) + expect($lines)->toHaveCount(2); + }); + + it('exports only the header row when there are no results', function () { + $experiment = tap(Experiment::make('empty-export')->title('Empty Export'))->save(); + + $csv = $this->get(cp_route('ab.experiments.export', $experiment->id())) + ->streamedContent(); + + $lines = array_values(array_filter(explode("\n", trim($csv)))); + + expect($lines)->toHaveCount(1); + expect(str_getcsv($lines[0])[0])->toBe('id'); + }); + + it('returns 404 for a non-existent experiment', function () { + $this->get(cp_route('ab.experiments.export', 'does-not-exist')) + ->assertNotFound(); + }); + + it('slugifies the experiment title in the filename', function () { + $experiment = tap(Experiment::make('slug-test')->title('My Fancy Experiment'))->save(); + + $this->get(cp_route('ab.experiments.export', $experiment->id())) + ->assertHeader('Content-Disposition', 'attachment; filename="my-fancy-experiment-results.csv"'); + }); +});