From 3d6f6bb7b322edcb42a0d2a078da863754ac7fea Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Fri, 20 May 2022 13:50:04 +0200 Subject: [PATCH] add abtest feature --- src/core/Ab/AbContainer.php | 30 +++++ src/core/Ab/AbTest.php | 125 ++++++++++++++++++ src/core/Entity/Site/Node.php | 34 +++++ src/core/Event/Ab/AbTestEvent.php | 29 ++++ .../Event/Account/PasswordRequestEvent.php | 2 +- src/core/EventListener/AbListener.php | 93 +++++++++++++ .../EventSubscriber/AbEventSubscriber.php | 32 +++++ src/core/Form/Site/NodeType.php | 31 +++++ src/core/Resources/assets/js/admin.js | 1 + .../Resources/assets/js/modules/editorjs.js | 2 +- src/core/Resources/assets/js/modules/node.js | 20 +++ .../views/site/node_admin/_form.html.twig | 9 ++ src/core/Twig/Extension/AbTestExtension.php | 48 +++++++ 13 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 src/core/Ab/AbContainer.php create mode 100644 src/core/Ab/AbTest.php create mode 100644 src/core/Event/Ab/AbTestEvent.php create mode 100644 src/core/EventListener/AbListener.php create mode 100644 src/core/EventSubscriber/AbEventSubscriber.php create mode 100644 src/core/Resources/assets/js/modules/node.js create mode 100644 src/core/Twig/Extension/AbTestExtension.php diff --git a/src/core/Ab/AbContainer.php b/src/core/Ab/AbContainer.php new file mode 100644 index 0000000..0c947b8 --- /dev/null +++ b/src/core/Ab/AbContainer.php @@ -0,0 +1,30 @@ + + */ +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]; + } +} diff --git a/src/core/Ab/AbTest.php b/src/core/Ab/AbTest.php new file mode 100644 index 0000000..51984ba --- /dev/null +++ b/src/core/Ab/AbTest.php @@ -0,0 +1,125 @@ + + */ +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; + } +} diff --git a/src/core/Entity/Site/Node.php b/src/core/Entity/Site/Node.php index 883c197..cc821aa 100644 --- a/src/core/Entity/Site/Node.php +++ b/src/core/Entity/Site/Node.php @@ -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; + } } diff --git a/src/core/Event/Ab/AbTestEvent.php b/src/core/Event/Ab/AbTestEvent.php new file mode 100644 index 0000000..922f9cc --- /dev/null +++ b/src/core/Event/Ab/AbTestEvent.php @@ -0,0 +1,29 @@ + + */ +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; + } +} diff --git a/src/core/Event/Account/PasswordRequestEvent.php b/src/core/Event/Account/PasswordRequestEvent.php index bede88d..b5ec7a0 100644 --- a/src/core/Event/Account/PasswordRequestEvent.php +++ b/src/core/Event/Account/PasswordRequestEvent.php @@ -21,7 +21,7 @@ class PasswordRequestEvent extends Event $this->user = $user; } - public function getUser(): USer + public function getUser(): User { return $this->user; } diff --git a/src/core/EventListener/AbListener.php b/src/core/EventListener/AbListener.php new file mode 100644 index 0000000..256d915 --- /dev/null +++ b/src/core/EventListener/AbListener.php @@ -0,0 +1,93 @@ + + */ +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); + } + } +} diff --git a/src/core/EventSubscriber/AbEventSubscriber.php b/src/core/EventSubscriber/AbEventSubscriber.php new file mode 100644 index 0000000..0309564 --- /dev/null +++ b/src/core/EventSubscriber/AbEventSubscriber.php @@ -0,0 +1,32 @@ + + */ +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) + { + } +} diff --git a/src/core/Form/Site/NodeType.php b/src/core/Form/Site/NodeType.php index 923e7e1..41809e7 100644 --- a/src/core/Form/Site/NodeType.php +++ b/src/core/Form/Site/NodeType.php @@ -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', diff --git a/src/core/Resources/assets/js/admin.js b/src/core/Resources/assets/js/admin.js index e42bbd3..b619439 100644 --- a/src/core/Resources/assets/js/admin.js +++ b/src/core/Resources/assets/js/admin.js @@ -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')() diff --git a/src/core/Resources/assets/js/modules/editorjs.js b/src/core/Resources/assets/js/modules/editorjs.js index cd9f331..016e52a 100644 --- a/src/core/Resources/assets/js/modules/editorjs.js +++ b/src/core/Resources/assets/js/modules/editorjs.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()) diff --git a/src/core/Resources/assets/js/modules/node.js b/src/core/Resources/assets/js/modules/node.js new file mode 100644 index 0000000..f5ed2ed --- /dev/null +++ b/src/core/Resources/assets/js/modules/node.js @@ -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) +} diff --git a/src/core/Resources/views/site/node_admin/_form.html.twig b/src/core/Resources/views/site/node_admin/_form.html.twig index 0496509..25b53f3 100644 --- a/src/core/Resources/views/site/node_admin/_form.html.twig +++ b/src/core/Resources/views/site/node_admin/_form.html.twig @@ -217,6 +217,15 @@ +
+
+ {{ form_row(form.hasAbTest) }} +
+
+ {{ form_row(form.abTestCode) }} +
+
+ {% if form.securityRoles is defined %}
diff --git a/src/core/Twig/Extension/AbTestExtension.php b/src/core/Twig/Extension/AbTestExtension.php new file mode 100644 index 0000000..f3ebf8d --- /dev/null +++ b/src/core/Twig/Extension/AbTestExtension.php @@ -0,0 +1,48 @@ +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(); + } +}