add abtest feature
This commit is contained in:
parent
e91a395c34
commit
3d6f6bb7b3
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="boolean", options={"default"=0})
|
||||
*/
|
||||
private $hasAbTest = false;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="string", length=255, nullable=true)
|
||||
*/
|
||||
private $abTestCode;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->children = new ArrayCollection();
|
||||
|
@ -673,4 +683,28 @@ class Node implements EntityInterface
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
public function getUser(): USer
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
'New page' => 'new',
|
||||
'Use an existing page' => 'existing',
|
||||
|
|
|
@ -28,3 +28,4 @@ require('./modules/file-picker.js')()
|
|||
require('./modules/analytics.js')()
|
||||
require('./modules/page.js')()
|
||||
require('./modules/sidebar.js')()
|
||||
require('./modules/node.js')()
|
||||
|
|
|
@ -147,7 +147,7 @@ const doInitEditor = () => {
|
|||
editorContainer.attr('id', id)
|
||||
element.hide()
|
||||
|
||||
let data = {time: null, blocks: []};
|
||||
let data = { time: null, blocks: [] }
|
||||
|
||||
try {
|
||||
const value = JSON.parse(element.val())
|
||||
|
|
|
@ -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 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 %}
|
||||
<div class="pb-3">
|
||||
<details>
|
||||
|
|
|
@ -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 New Issue