diff --git a/src/Actions/DuplicateExperiment.php b/src/Actions/DuplicateExperiment.php new file mode 100644 index 0000000..00a7173 --- /dev/null +++ b/src/Actions/DuplicateExperiment.php @@ -0,0 +1,60 @@ +each(function ($experiment) { + $copy = ExperimentFacade::make() + ->title(__('Copy of :title', ['title' => $experiment->title()])) + ->type($experiment->type()) + ->goals($experiment->goals()) + ->data(Arr::removeNullValues($experiment->data()->all())) + ->published(false); + + $copy->save(); + + $this->lastCreated = $copy; + }); + + return trans_choice('Experiment duplicated|Experiments duplicated', $items->count()); + } + + public function redirect($items, $values) + { + if ($items->count() === 1) { + return cp_route('ab.experiments.edit', $this->lastCreated->id()); + } + + return false; + } + + public function icon(): string + { + return 'copy'; + } + + public static function title() + { + return __('Duplicate'); + } + + public function visibleTo($item) + { + return $item instanceof Experiment; + } + + public function authorize($user, $item) + { + return $user->can('create a/b experiments'); + } +} diff --git a/src/Experiment/Experiment.php b/src/Experiment/Experiment.php index 677d5cc..cbb86a7 100644 --- a/src/Experiment/Experiment.php +++ b/src/Experiment/Experiment.php @@ -284,6 +284,6 @@ public function variants(): Collection public function fresh() { - return \Thoughtco\StatamicABTester\Facades\Experiment::find($this->id); + return ExperimentFacade::find($this->id); } } diff --git a/src/Goal/Goal.php b/src/Goal/Goal.php index 801c990..70c7635 100644 --- a/src/Goal/Goal.php +++ b/src/Goal/Goal.php @@ -124,6 +124,6 @@ public function toArray() public function fresh() { - return \Thoughtco\StatamicABTester\Facades\Goal::find($this->id); + return GoalFacade::find($this->id); } } diff --git a/tests/Controllers/ExperimentActionsControllerTest.php b/tests/Controllers/ExperimentActionsControllerTest.php index 46655c4..b64fb26 100644 --- a/tests/Controllers/ExperimentActionsControllerTest.php +++ b/tests/Controllers/ExperimentActionsControllerTest.php @@ -1,6 +1,6 @@ post(cp_route('ab.experiments.actions'), []) ->assertSessionHasErrors(['action']); }); + + it('duplicates a manual experiment', function () { + $experiment = tap(Experiment::make('original') + ->title('My Experiment') + ->type('manual') + ->goals(['goal-1']) + ->data([ + 'manual_fields' => [ + ['handle' => 'control', 'label' => 'Control', 'weight' => 50], + ['handle' => 'variant_a', 'label' => 'Variant A', 'weight' => 50], + ], + ]) + ->published(true)) + ->save(); + + $this->post(cp_route('ab.experiments.actions'), [ + 'action' => 'duplicate_experiment', + 'selections' => [$experiment->id()], + 'values' => [], + ])->assertOk(); + + expect(Experiment::all()->count())->toBe(2); + + $copy = Experiment::all()->reject(fn ($e) => $e->id() === $experiment->id())->first(); + expect($copy->title())->toBe('Copy of My Experiment'); + expect($copy->type())->toBe('manual'); + expect($copy->goals())->toBe(['goal-1']); + expect($copy->published())->toBeFalse(); + expect($copy->completedAt())->toBeNull(); + expect($copy->get('manual_fields'))->toBe($experiment->get('manual_fields')); + }); + + it('duplicates an item experiment preserving experiment_fields and traffic_split', function () { + $experiment = tap(Experiment::make('item-exp') + ->title('Item Experiment') + ->type('item') + ->goals(['goal-1']) + ->data([ + 'item_id' => 'abc-123', + 'experiment_fields' => ['fields' => ['title'], 'values' => ['title' => 'Variant Title']], + 'traffic_split' => 30, + ]) + ->published(true)) + ->save(); + + $this->post(cp_route('ab.experiments.actions'), [ + 'action' => 'duplicate_experiment', + 'selections' => [$experiment->id()], + 'values' => [], + ])->assertOk(); + + $copy = Experiment::all()->reject(fn ($e) => $e->id() === $experiment->id())->first(); + expect($copy->title())->toBe('Copy of Item Experiment'); + expect($copy->type())->toBe('item'); + expect($copy->get('item_id'))->toBe('abc-123'); + expect($copy->get('traffic_split'))->toBe(30); + expect($copy->get('experiment_fields'))->toBe($experiment->get('experiment_fields')); + expect($copy->published())->toBeFalse(); + }); + + it('redirects to edit page when duplicating a single experiment', function () { + $experiment = tap(Experiment::make('dup-redirect') + ->title('Redirect Test') + ->type('manual') + ->goals([])) + ->save(); + + $response = $this->post(cp_route('ab.experiments.actions'), [ + 'action' => 'duplicate_experiment', + 'selections' => [$experiment->id()], + 'values' => [], + ])->assertOk(); + + $copyId = Experiment::all() + ->reject(fn ($e) => $e->id() === $experiment->id()) + ->first() + ->id(); + + expect($response->json('redirect'))->toBe(cp_route('ab.experiments.edit', $copyId)); + }); + + it('duplicates multiple experiments in bulk without redirecting', function () { + $exp1 = tap(Experiment::make('bulk-1')->title('Experiment One')->type('manual')->goals([]))->save(); + $exp2 = tap(Experiment::make('bulk-2')->title('Experiment Two')->type('manual')->goals([]))->save(); + + $response = $this->post(cp_route('ab.experiments.actions'), [ + 'action' => 'duplicate_experiment', + 'selections' => [$exp1->id(), $exp2->id()], + 'values' => [], + ])->assertOk(); + + expect(Experiment::all()->count())->toBe(4); + expect($response->json('redirect'))->toBeFalsy(); + }); + + it('does not copy completed_at when duplicating a completed experiment', function () { + $experiment = tap(Experiment::make('completed-exp') + ->title('Completed Experiment') + ->type('manual') + ->goals([]) + ->completedAt(now())) + ->save(); + + $this->post(cp_route('ab.experiments.actions'), [ + 'action' => 'duplicate_experiment', + 'selections' => [$experiment->id()], + 'values' => [], + ])->assertOk(); + + $copy = Experiment::all()->reject(fn ($e) => $e->id() === $experiment->id())->first(); + expect($copy->completedAt())->toBeNull(); + expect($copy->published())->toBeFalse(); + }); }); diff --git a/tests/Controllers/ExperimentsControllerTest.php b/tests/Controllers/ExperimentsControllerTest.php index c57ec3e..9cfabcf 100644 --- a/tests/Controllers/ExperimentsControllerTest.php +++ b/tests/Controllers/ExperimentsControllerTest.php @@ -8,6 +8,7 @@ use Statamic\Facades\Entry; use Statamic\Facades\User; use Thoughtco\StatamicABTester\Facades\Experiment; +use Thoughtco\StatamicABTester\Models\AbTestResult; beforeEach(function () { $this->actingAs(User::make()->makeSuper()->save()); @@ -248,7 +249,7 @@ // to control exact counts: 200 hits each, 10% vs 20% conversion $insertRows = function (string $type, int|string $variant, int $count) use ($experiment) { for ($i = 0; $i < $count; $i++) { - \Thoughtco\StatamicABTester\Models\AbTestResult::create([ + AbTestResult::create([ 'experiment_id' => $experiment->id(), 'variation' => $variant, 'type' => $type, @@ -282,7 +283,7 @@ $insertRows = function (string $type, int|string $variant, int $count) use ($experiment) { for ($i = 0; $i < $count; $i++) { - \Thoughtco\StatamicABTester\Models\AbTestResult::create([ + AbTestResult::create([ 'experiment_id' => $experiment->id(), 'variation' => $variant, 'type' => $type, @@ -326,7 +327,7 @@ it('stores traffic_split when creating an item experiment', function () { // Create blueprint and collection for item experiment - $blueprint = \Statamic\Facades\Blueprint::make('article'); + $blueprint = Blueprint::make('article'); $blueprint->setContents([ 'fields' => [ ['handle' => 'title', 'field' => ['type' => 'text']], @@ -334,11 +335,11 @@ ])->setNamespace('collections.articles'); $blueprint->save(); - $collection = \Statamic\Facades\Collection::make('articles'); + $collection = Collection::make('articles'); $collection->entryBlueprints(['article']); $collection->save(); - $entry = tap(\Statamic\Facades\Entry::make() + $entry = tap(Entry::make() ->collection('articles') ->blueprint('article') ->slug('test-article')