Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions css/experiments-filter.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @file
* Lays out the experiments overview filter as one aligned row.
*/

.rl-experiments-filter {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
}

/* Reset the theme margins so labels, fields and buttons share one grid. */
.rl-experiments-filter .form-item,
.rl-experiments-filter .form-actions,
.rl-experiments-filter .form-actions .button {
margin: 0;
}

.rl-experiments-filter .form-actions {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
5 changes: 5 additions & 0 deletions docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ per-experiment performance, traffic distribution, and confidence levels.

![RL experiment list showing impressions, conversions, and variant counts](../images/rl-experiment-list.jpg)

Above the list, search by experiment name or ID, or filter by **Source** (the
module that registered the experiment) and **Activity** (with or without
impressions). The filters combine, work with column sorting and paging, and
stay in the page address, so a filtered list can be bookmarked or shared.

Click **View** on any experiment to see its detail page with interactive
charts. The 2D line chart shows conversion scores over time; the 3D
posterior landscape visualises how Thompson Sampling distributions evolve
Expand Down
6 changes: 6 additions & 0 deletions rl.libraries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ experiment-highlight:
theme:
css/experiment-highlight.css: {}

experiments-filter:
version: 1.0
css:
component:
css/experiments-filter.css: {}

plotly:
version: 2.35.2
license:
Expand Down
2 changes: 1 addition & 1 deletion rl.routing.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
rl.reports.experiments:
path: '/admin/reports/rl'
defaults:
_form: '\Drupal\rl\Form\ExperimentsOverviewForm'
_controller: '\Drupal\rl\Controller\ReportsController::experimentsOverview'
_title: 'RL: A/B Testing'
requirements:
_permission: 'view rl reports'
Expand Down
26 changes: 26 additions & 0 deletions scripts/e2e/overview-rows.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/**
* @file
* Renders the RL experiments overview and filter forms for a query string.
*
* Usage: drush php:script overview-rows.php "search=hero&source=module"
* Prints the listed experiment names, the summary line and the reset state.
*/

use Drupal\rl\Form\ExperimentsFilterForm;
use Drupal\rl\Form\ExperimentsOverviewForm;
use Drupal\user\Entity\User;

parse_str($extra[0] ?? '', $query);
\Drupal::service('account_switcher')->switchTo(User::load(1));
// Keep the CLI request, which carries the session, and swap in the query.
\Drupal::requestStack()->push(\Drupal::request()->duplicate($query));

$overview = \Drupal::formBuilder()->getForm(ExperimentsOverviewForm::class);
$filters = \Drupal::formBuilder()->getForm(ExperimentsFilterForm::class);

print 'names: ' . implode('|', array_column($overview['#experiments'], 'name')) . "\n";
print 'summary: ' . (isset($overview['summary']) ? strip_tags((string) $overview['summary']['#markup']) : '-') . "\n";
print 'empty: ' . strip_tags((string) $overview['experiments']['#empty']) . "\n";
print 'reset: ' . (isset($filters['actions']['reset']) ? 'yes' : 'no') . "\n";
73 changes: 73 additions & 0 deletions scripts/e2e/test-experiments-overview.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# E2E tests for the experiments overview search and filters.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/_helpers.sh"

DRUSH="${DRUSH:-drush}"

# Prints one field of the overview for a query string; names come sorted.
overview() {
local query="$1" field="$2" out
out=$($DRUSH php:script "$SCRIPT_DIR/overview-rows.php" -- "$query" 2>&1)
local line
line=$(echo "$out" | sed -n "s/^$field: //p")
if [ "$field" = "names" ]; then
echo "$line" | tr '|' '\n' | sort | paste -sd'|' -
else
echo "$line"
fi
}

section "Setup"

$DRUSH rl:experiment:create e2e_filter_hero --module=e2e_filter_a --name="E2E Filter Hero Headline" > /dev/null
$DRUSH rl:experiment:create e2e_filter_pricing --module=e2e_filter_a --name="E2E Filter Pricing CTA" > /dev/null
$DRUSH rl:experiment:create e2e_filter_footer --module=e2e_filter_b --name="E2E Filter Footer Box" > /dev/null
$DRUSH php:eval '$s = \Drupal::service("rl.experiment_data_storage"); foreach (["e2e_filter_hero" => 3, "e2e_filter_footer" => 2] as $id => $n) { for ($i = 0; $i < $n; $i++) { $s->recordTurn($id, "v0"); } }'

HERO="E2E Filter Hero Headline"
PRICING="E2E Filter Pricing CTA"
FOOTER="E2E Filter Footer Box"
ALL="$FOOTER|$HERO|$PRICING"

section "Search"

assert_eq "search by name part" "$HERO" "$(overview 'search=filter%20hero' names)"
assert_eq "search ignores case" "$PRICING" "$(overview 'search=FILTER%20PRICING' names)"
assert_eq "search matches the ID" "$FOOTER" "$(overview 'search=e2e_filter_footer' names)"
assert_eq "search trims whitespace" "$HERO" "$(overview 'search=%20%20filter%20hero%20%20' names)"
assert_eq "no match lists nothing" "" "$(overview 'search=no-such-experiment' names)"
assert_has "no match explains itself" "No experiments match the filters." "$(overview 'search=no-such-experiment' empty)"

section "Source and activity"

assert_eq "source filter" "$HERO|$PRICING" "$(overview 'search=e2e_filter&source=e2e_filter_a' names)"
assert_eq "activity with impressions" "$FOOTER|$HERO" "$(overview 'search=e2e_filter&activity=active' names)"
assert_eq "activity without impressions" "$PRICING" "$(overview 'search=e2e_filter&activity=idle' names)"
assert_eq "filters combine" "$PRICING" "$(overview 'search=e2e_filter&source=e2e_filter_a&activity=idle' names)"
assert_eq "filters narrow, never widen" "" "$(overview 'search=e2e_filter&source=e2e_filter_b&activity=idle' names)"
assert_eq "unknown source is ignored" "$ALL" "$(overview 'search=e2e_filter&source=gone' names)"
assert_eq "unknown activity is ignored" "$ALL" "$(overview 'search=e2e_filter&activity=bogus' names)"

section "Summary and reset"

assert_has "summary counts matches" "Showing 2 of" "$(overview 'search=e2e_filter&source=e2e_filter_a' summary)"
assert_eq "no summary without filters" "-" "$(overview '' summary)"
assert_eq "reset shown while filtering" "yes" "$(overview 'search=filter%20hero' reset)"
assert_eq "reset hidden without filters" "no" "$(overview '' reset)"
assert_eq "a search for 0 counts as a filter" "yes" "$(overview 'search=0' reset)"

section "Malformed input"

assert_success "array search does not fail" $DRUSH php:script "$SCRIPT_DIR/overview-rows.php" -- 'search[]=x'
assert_success "nested array source does not fail" $DRUSH php:script "$SCRIPT_DIR/overview-rows.php" -- 'source[a][b]=x'

section "Cleanup"

for id in e2e_filter_hero e2e_filter_pricing e2e_filter_footer; do
assert_has "delete $id" "success: true" "$($DRUSH rl:experiment:delete "$id" 2>&1)"
done

print_summary
18 changes: 16 additions & 2 deletions src/Controller/ReportsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Drupal\Core\Url;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\rl\Decorator\ExperimentDecoratorManager;
use Drupal\rl\Form\ExperimentsFilterForm;
use Drupal\rl\Form\ExperimentsOverviewForm;
use Drupal\rl\Registry\ExperimentRegistryInterface;
use Drupal\rl\Service\ArmDataValidator;
Expand Down Expand Up @@ -123,13 +124,26 @@ public static function create(ContainerInterface $container): static {
}

/**
* Overview page showing all experiments.
* Overview page showing all experiments, with filters above the list.
*
* @return array
* A render array.
*/
public function experimentsOverview() {
return $this->formBuilder()->getForm(ExperimentsOverviewForm::class);
return [
'add' => [
'#type' => 'link',
'#title' => $this->t('Add experiment'),
'#url' => Url::fromRoute('rl.experiment.add'),
'#attributes' => ['class' => ['button', 'button--primary']],
'#access' => $this->currentUser()->hasPermission('administer rl experiments'),
],
'description' => [
'#markup' => '<p>' . $this->t('Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '</p>',
],
'filters' => $this->formBuilder()->getForm(ExperimentsFilterForm::class),
'overview' => $this->formBuilder()->getForm(ExperimentsOverviewForm::class),
];
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/Form/ExperimentDeleteMultipleForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ public function getConfirmText() {
* {@inheritdoc}
*/
public function getCancelUrl(): Url {
$keep = array_merge(['order', 'sort', 'page'], ExperimentsFilterForm::FILTER_KEYS);
return Url::fromRoute('rl.reports.experiments', [], [
'query' => array_intersect_key($this->getRequest()->query->all(), array_flip(['order', 'sort', 'page'])),
'query' => array_intersect_key($this->getRequest()->query->all(), array_flip($keep)),
]);
}

Expand Down
156 changes: 156 additions & 0 deletions src/Form/ExperimentsFilterForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

namespace Drupal\rl\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\rl\Registry\ExperimentRegistryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;

/**
* Filters the experiments overview by keywords, source and activity.
*
* Submitting redirects to the overview with the filters in the URL, so
* filtered lists can be bookmarked, paged, sorted and shared.
*/
final class ExperimentsFilterForm extends FormBase {

/**
* Query parameters that hold the filters.
*/
const FILTER_KEYS = ['search', 'source', 'activity'];

/**
* Constructs the experiments filter form.
*/
public function __construct(
protected ExperimentRegistryInterface $experimentRegistry,
) {}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static($container->get('rl.experiment_registry'));
}

/**
* Reads the filters from the query string.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return string[]
* The search, source and activity filters; empty strings when unset.
*/
public static function getFilters(Request $request): array {
$query = $request->query->all();
$filters = [];
foreach (self::FILTER_KEYS as $key) {
$filters[$key] = isset($query[$key]) && is_string($query[$key]) ? trim($query[$key]) : '';
}
if (!in_array($filters['activity'], ['active', 'idle'], TRUE)) {
$filters['activity'] = '';
}
return $filters;
}

/**
* Tells whether any filter is set; a search for "0" counts as set.
*
* @param string[] $filters
* The filters from ::getFilters().
*/
public static function isFiltered(array $filters): bool {
return count(array_filter($filters, static fn (string $value): bool => $value !== '')) > 0;
}

/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'rl_experiments_filter';
}

/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$filters = self::getFilters($this->getRequest());
$sources = array_filter(array_unique($this->experimentRegistry->getAll()));
natcasesort($sources);
$sources = array_combine($sources, $sources);

$form['#attributes']['class'][] = 'rl-experiments-filter';
$form['#attached']['library'][] = 'rl/experiments-filter';
$form['search'] = [
'#type' => 'textfield',
'#title' => $this->t('Name or ID'),
'#size' => 30,
'#maxlength' => 255,
'#default_value' => $filters['search'],
];
$form['source'] = [
'#type' => 'select',
'#title' => $this->t('Source'),
'#options' => $sources,
'#empty_option' => $this->t('- Any -'),
'#default_value' => isset($sources[$filters['source']]) ? $filters['source'] : '',
];
$form['activity'] = [
'#type' => 'select',
'#title' => $this->t('Activity'),
'#options' => [
'active' => $this->t('With impressions'),
'idle' => $this->t('No impressions yet'),
],
'#empty_option' => $this->t('- Any -'),
'#default_value' => $filters['activity'],
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Filter'),
];
if (self::isFiltered($filters)) {
$form['actions']['reset'] = [
'#type' => 'link',
'#title' => $this->t('Reset'),
'#url' => Url::fromRoute('rl.reports.experiments', [], ['query' => $this->getSortQuery()]),
'#attributes' => ['class' => ['button']],
];
}
return $form;
}

/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$query = [];
foreach (self::FILTER_KEYS as $key) {
$value = trim((string) $form_state->getValue($key));
if ($value !== '') {
$query[$key] = $value;
}
}
// A new filter starts on the first page and keeps the chosen sort order.
$form_state->setIgnoreDestination();
$form_state->setRedirect('rl.reports.experiments', [], [
'query' => $query + $this->getSortQuery(),
]);
}

/**
* Returns the sort parameters of the current request.
*/
protected function getSortQuery(): array {
return array_filter(
array_intersect_key($this->getRequest()->query->all(), array_flip(['order', 'sort'])),
'is_string',
);
}

}
Loading
Loading