add abtest feature
This commit is contained in:
parent
e91a395c34
commit
3d6f6bb7b3
30
src/core/Ab/AbContainer.php
Normal file
30
src/core/Ab/AbContainer.php
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Ab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* class AbContainer.
|
||||||
|
*
|
||||||
|
* @author Simon Vieille <simon@deblan.fr>
|
||||||
|
*/
|
||||||
|
class AbContainer
|
||||||
|
{
|
||||||
|
protected array $tests = [];
|
||||||
|
|
||||||
|
public function add(AbTest $test): self
|
||||||
|
{
|
||||||
|
$this->tests[$test->getName()] = $test;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
return isset($this->tests[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name): AbTest
|
||||||
|
{
|
||||||
|
return $this->tests[$name];
|
||||||
|
}
|
||||||
|
}
|
125
src/core/Ab/AbTest.php
Normal file
125
src/core/Ab/AbTest.php
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Ab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* class AbTest.
|
||||||
|
*
|
||||||
|
* @author Simon Vieille <simon@deblan.fr>
|
||||||
|
*/
|
||||||
|
class AbTest
|
||||||
|
{
|
||||||
|
protected $results;
|
||||||
|
protected string $name;
|
||||||
|
protected array $variations = [];
|
||||||
|
protected array $probabilities = [];
|
||||||
|
protected int $duration = 3600 * 24;
|
||||||
|
|
||||||
|
public function __construct(string $name)
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResult()
|
||||||
|
{
|
||||||
|
return $this->result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setResult(string $result): self
|
||||||
|
{
|
||||||
|
$this->result = $result;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidVariation($variation): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($variation, $this->variations);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addVariation(string $name, $value, int $probability = null): self
|
||||||
|
{
|
||||||
|
$this->variations[$name] = $value;
|
||||||
|
$this->probabilities[$name] = $probability;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVariation($variation)
|
||||||
|
{
|
||||||
|
return $this->variations[$variation];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResultValue()
|
||||||
|
{
|
||||||
|
return $this->getVariation($this->getResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDuration(int $duration): self
|
||||||
|
{
|
||||||
|
$this->duration = $duration;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDuration(): int
|
||||||
|
{
|
||||||
|
return $this->duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): self
|
||||||
|
{
|
||||||
|
$this->result = $this->chance();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function chance(): string
|
||||||
|
{
|
||||||
|
$sum = 0;
|
||||||
|
$empty = 0;
|
||||||
|
|
||||||
|
foreach ($this->probabilities as $name => $value) {
|
||||||
|
$sum += $value;
|
||||||
|
|
||||||
|
if (empty($value)) {
|
||||||
|
++$empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sum > 100) {
|
||||||
|
throw new \LogicException('Test Error: Total variation probabilities is bigger than 100%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sum < 100) {
|
||||||
|
foreach ($this->probabilities as $name => $value) {
|
||||||
|
if (empty($value)) {
|
||||||
|
$this->probabilities[$name] = (100 - $sum) / $empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
krsort($this->probabilities);
|
||||||
|
|
||||||
|
$number = \rand(0, (int) \array_sum($this->probabilities) * 10);
|
||||||
|
$starter = 0;
|
||||||
|
$return = '';
|
||||||
|
|
||||||
|
foreach ($this->probabilities as $key => $val) {
|
||||||
|
$starter += $val * 10;
|
||||||
|
|
||||||
|
if ($number <= $starter) {
|
||||||
|
$return = $key;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
}
|
|
@ -166,6 +166,16 @@ class Node implements EntityInterface
|
||||||
*/
|
*/
|
||||||
private $securityOperator = 'or';
|
private $securityOperator = 'or';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="boolean", options={"default"=0})
|
||||||
|
*/
|
||||||
|
private $hasAbTest = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ORM\Column(type="string", length=255, nullable=true)
|
||||||
|
*/
|
||||||
|
private $abTestCode;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->children = new ArrayCollection();
|
$this->children = new ArrayCollection();
|
||||||
|
@ -673,4 +683,28 @@ class Node implements EntityInterface
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getHasAbTest(): ?bool
|
||||||
|
{
|
||||||
|
return $this->hasAbTest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasAbTest(bool $hasAbTest): self
|
||||||
|
{
|
||||||
|
$this->hasAbTest = $hasAbTest;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAbTestCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->abTestCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAbTestCode(?string $abTestCode): self
|
||||||
|
{
|
||||||
|
$this->abTestCode = $abTestCode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
29
src/core/Event/Ab/AbTestEvent.php
Normal file
29
src/core/Event/Ab/AbTestEvent.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Event\Ab;
|
||||||
|
|
||||||
|
use App\Core\Ab\AbTest;
|
||||||
|
use Symfony\Contracts\EventDispatcher\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* class AbTestEvent.
|
||||||
|
*
|
||||||
|
* @author Simon Vieille <simon@deblan.fr>
|
||||||
|
*/
|
||||||
|
class AbTestEvent extends Event
|
||||||
|
{
|
||||||
|
public const INIT_EVENT = 'ab_test.init';
|
||||||
|
public const RUN_EVENT = 'ab_test.run';
|
||||||
|
|
||||||
|
protected AbTest $test;
|
||||||
|
|
||||||
|
public function __construct(AbTest $test)
|
||||||
|
{
|
||||||
|
$this->test = $test;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTest(): AbTest
|
||||||
|
{
|
||||||
|
return $this->test;
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,7 +21,7 @@ class PasswordRequestEvent extends Event
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUser(): USer
|
public function getUser(): User
|
||||||
{
|
{
|
||||||
return $this->user;
|
return $this->user;
|
||||||
}
|
}
|
||||||
|
|
93
src/core/EventListener/AbListener.php
Normal file
93
src/core/EventListener/AbListener.php
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\EventListener;
|
||||||
|
|
||||||
|
use App\Core\Ab\AbContainer;
|
||||||
|
use App\Core\Ab\AbTest;
|
||||||
|
use App\Core\Event\Ab\AbTestEvent;
|
||||||
|
use App\Core\Repository\Site\NodeRepository;
|
||||||
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* class AbListener.
|
||||||
|
*
|
||||||
|
* @author Simon Vieille <simon@deblan.fr>
|
||||||
|
*/
|
||||||
|
class AbListener
|
||||||
|
{
|
||||||
|
protected NodeRepository $nodeRepository;
|
||||||
|
protected EventDispatcherInterface $eventDispatcher;
|
||||||
|
protected AbContainer $container;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
NodeRepository $nodeRepository,
|
||||||
|
AbContainer $container,
|
||||||
|
EventDispatcherInterface $eventDispatcher
|
||||||
|
) {
|
||||||
|
$this->nodeRepository = $nodeRepository;
|
||||||
|
$this->eventDispatcher = $eventDispatcher;
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelRequest(RequestEvent $event)
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
if (!$request->attributes->has('_node')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = $this->nodeRepository->findOneBy([
|
||||||
|
'id' => $request->attributes->get('_node'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$node->getHasAbTest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$node->getAbTestCode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieName = md5('ab_test_'.$node->getAbTestCode());
|
||||||
|
$cookieValue = $request->cookies->get($cookieName);
|
||||||
|
|
||||||
|
$abTest = new AbTest($node->getAbTestCode());
|
||||||
|
$event = new AbTestEvent($abTest);
|
||||||
|
$this->container->add($abTest);
|
||||||
|
|
||||||
|
$this->eventDispatcher->dispatch($event, AbTestEvent::INIT_EVENT);
|
||||||
|
|
||||||
|
if (!$abTest->isValidVariation($cookieValue)) {
|
||||||
|
$abTest->run();
|
||||||
|
$result = $abTest->getResult();
|
||||||
|
|
||||||
|
$attributes = array_merge($request->attributes->get('ab_test_cookies', []), [
|
||||||
|
$cookieName => ['value' => $result, 'duration' => $abTest->getDuration()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->attributes->set('ab_test_cookies', $attributes);
|
||||||
|
|
||||||
|
$this->eventDispatcher->dispatch($event, AbTestEvent::RUN_EVENT);
|
||||||
|
} else {
|
||||||
|
$abTest->setResult($cookieValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onKernelResponse(ResponseEvent $event)
|
||||||
|
{
|
||||||
|
$cookies = $event->getRequest()->attributes->get('ab_test_cookies', []);
|
||||||
|
|
||||||
|
foreach ($cookies as $name => $value) {
|
||||||
|
$cookie = Cookie::create($name, $value['value'], time() + $value['duration']);
|
||||||
|
$event->getResponse()->headers->setCookie($cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
src/core/EventSubscriber/AbEventSubscriber.php
Normal file
32
src/core/EventSubscriber/AbEventSubscriber.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Core\Event\Ab\AbTestEvent;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* class AbEventSubscriber.
|
||||||
|
*
|
||||||
|
* @author Simon Vieille <simon@deblan.fr>
|
||||||
|
*/
|
||||||
|
abstract class AbEventSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
protected static int $priority = 0;
|
||||||
|
|
||||||
|
public static function getSubscribedEvents()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
AbTestEvent::INIT_EVENT => ['onInit', self::$priority],
|
||||||
|
AbTestEvent::RUN_EVENT => ['onRun', self::$priority],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onInit(AbTestEvent $event)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onRun(AbTestEvent $event)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -154,6 +154,37 @@ class NodeType extends AbstractType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$builder->add(
|
||||||
|
'hasAbTest',
|
||||||
|
CheckboxType::class,
|
||||||
|
[
|
||||||
|
'label' => 'Enable AB Testing',
|
||||||
|
'required' => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder->add(
|
||||||
|
'abTestCode',
|
||||||
|
TextType::class,
|
||||||
|
[
|
||||||
|
'label' => 'Code',
|
||||||
|
'required' => $builder->getData()->getHasAbTest(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder->add(
|
||||||
|
'securityOperator',
|
||||||
|
ChoiceType::class,
|
||||||
|
[
|
||||||
|
'label' => 'Condition',
|
||||||
|
'required' => true,
|
||||||
|
'choices' => [
|
||||||
|
'At least one role' => 'or',
|
||||||
|
'All roles' => 'and',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
'New page' => 'new',
|
'New page' => 'new',
|
||||||
'Use an existing page' => 'existing',
|
'Use an existing page' => 'existing',
|
||||||
|
|
|
@ -28,3 +28,4 @@ require('./modules/file-picker.js')()
|
||||||
require('./modules/analytics.js')()
|
require('./modules/analytics.js')()
|
||||||
require('./modules/page.js')()
|
require('./modules/page.js')()
|
||||||
require('./modules/sidebar.js')()
|
require('./modules/sidebar.js')()
|
||||||
|
require('./modules/node.js')()
|
||||||
|
|
|
@ -147,7 +147,7 @@ const doInitEditor = () => {
|
||||||
editorContainer.attr('id', id)
|
editorContainer.attr('id', id)
|
||||||
element.hide()
|
element.hide()
|
||||||
|
|
||||||
let data = {time: null, blocks: []};
|
let data = { time: null, blocks: [] }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(element.val())
|
const value = JSON.parse(element.val())
|
||||||
|
|
20
src/core/Resources/assets/js/modules/node.js
Normal file
20
src/core/Resources/assets/js/modules/node.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
const $ = require('jquery')
|
||||||
|
|
||||||
|
const abTestChecker = () => {
|
||||||
|
$('body').on('change', '#node_hasAbTest', () => {
|
||||||
|
const checkbox = document.querySelector('#node_hasAbTest')
|
||||||
|
const code = document.querySelector('#node_abTestCode')
|
||||||
|
|
||||||
|
code.parentNode.parentNode.classList.toggle('d-none', !checkbox.checked)
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
code.setAttribute('required', 'required')
|
||||||
|
} else {
|
||||||
|
code.removeAttribute('required')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = () => {
|
||||||
|
$(abTestChecker)
|
||||||
|
}
|
|
@ -217,6 +217,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
{{ form_row(form.hasAbTest) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 {% if not form.vars.data.abTestCode %}d-none{% endif %}">
|
||||||
|
{{ form_row(form.abTestCode) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if form.securityRoles is defined %}
|
{% if form.securityRoles is defined %}
|
||||||
<div class="pb-3">
|
<div class="pb-3">
|
||||||
<details>
|
<details>
|
||||||
|
|
48
src/core/Twig/Extension/AbTestExtension.php
Normal file
48
src/core/Twig/Extension/AbTestExtension.php
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core\Twig\Extension;
|
||||||
|
|
||||||
|
use App\Core\Ab\AbContainer;
|
||||||
|
use App\Core\Ab\AbTest;
|
||||||
|
use Twig\Extension\AbstractExtension;
|
||||||
|
use Twig\TwigFunction;
|
||||||
|
|
||||||
|
class AbTestExtension extends AbstractExtension
|
||||||
|
{
|
||||||
|
protected AbContainer $container;
|
||||||
|
|
||||||
|
public function __construct(AbContainer $container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFunctions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new TwigFunction('ab_test_exists', [$this, 'exists']),
|
||||||
|
new TwigFunction('ab_test', [$this, 'get']),
|
||||||
|
new TwigFunction('ab_test_result', [$this, 'result']),
|
||||||
|
new TwigFunction('ab_test_value', [$this, 'value']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $name): bool
|
||||||
|
{
|
||||||
|
return $this->container->has($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $name): AbTest
|
||||||
|
{
|
||||||
|
return $this->container->get($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function result(string $name): string
|
||||||
|
{
|
||||||
|
return $this->get($name)->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function value(string $name)
|
||||||
|
{
|
||||||
|
return $this->get($name)->getResultValue();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue