diff --git a/css/experiments-filter.css b/css/experiments-filter.css new file mode 100644 index 0000000..2871d91 --- /dev/null +++ b/css/experiments-filter.css @@ -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; +} diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index bc05001..d913a0a 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -66,6 +66,11 @@ per-experiment performance, traffic distribution, and confidence levels.  +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 diff --git a/rl.libraries.yml b/rl.libraries.yml index caf99a9..1b42c1c 100644 --- a/rl.libraries.yml +++ b/rl.libraries.yml @@ -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: diff --git a/rl.routing.yml b/rl.routing.yml index 401e8fa..91d7136 100644 --- a/rl.routing.yml +++ b/rl.routing.yml @@ -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' diff --git a/scripts/e2e/overview-rows.php b/scripts/e2e/overview-rows.php new file mode 100644 index 0000000..7814674 --- /dev/null +++ b/scripts/e2e/overview-rows.php @@ -0,0 +1,26 @@ +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"; diff --git a/scripts/e2e/test-experiments-overview.sh b/scripts/e2e/test-experiments-overview.sh new file mode 100755 index 0000000..d051787 --- /dev/null +++ b/scripts/e2e/test-experiments-overview.sh @@ -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 diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php index 911c283..40faa68 100644 --- a/src/Controller/ReportsController.php +++ b/src/Controller/ReportsController.php @@ -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; @@ -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' => '
' . $this->t('Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '
', + ], + 'filters' => $this->formBuilder()->getForm(ExperimentsFilterForm::class), + 'overview' => $this->formBuilder()->getForm(ExperimentsOverviewForm::class), + ]; } /** diff --git a/src/Form/ExperimentDeleteMultipleForm.php b/src/Form/ExperimentDeleteMultipleForm.php index 8493f02..7151045 100644 --- a/src/Form/ExperimentDeleteMultipleForm.php +++ b/src/Form/ExperimentDeleteMultipleForm.php @@ -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)), ]); } diff --git a/src/Form/ExperimentsFilterForm.php b/src/Form/ExperimentsFilterForm.php new file mode 100644 index 0000000..7abcc4d --- /dev/null +++ b/src/Form/ExperimentsFilterForm.php @@ -0,0 +1,156 @@ +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', + ); + } + +} diff --git a/src/Form/ExperimentsOverviewForm.php b/src/Form/ExperimentsOverviewForm.php index ab9ca7a..96cdacb 100644 --- a/src/Form/ExperimentsOverviewForm.php +++ b/src/Form/ExperimentsOverviewForm.php @@ -83,6 +83,13 @@ public function buildForm(array $form, FormStateInterface $form_state): array { // IDs may contain brackets or be "0"; use safe, non-numeric form keys. $experiments['experiment_' . hash('sha256', $experiment->experiment_id)] = $experiment; } + $registered = $experiments; + $filters = ExperimentsFilterForm::getFilters($this->getRequest()); + // Ignore a source that no longer exists, as the filter form shows "Any". + if (!in_array($filters['source'], array_column($registered, 'module'), TRUE)) { + $filters['source'] = ''; + } + $experiments = array_filter($experiments, fn (object $experiment): bool => $this->matchesFilters($experiment, $filters)); $sort = TableSort::getContextFromRequest($header, $this->getRequest()); $field = $sort['sql']; uasort($experiments, static function (object $a, object $b) use ($field, $sort): int { @@ -100,20 +107,19 @@ public function buildForm(array $form, FormStateInterface $form_state): array { // tableselect validation can process them. Never trust submitted labels. $submitted = $form_state->getUserInput()['experiments'] ?? []; if ($can_administer && $this->getRequest()->isMethod('POST') && is_array($submitted)) { - $page += array_intersect_key($experiments, $submitted); + $page += array_intersect_key($registered, $submitted); } $experiments = $page; - $form['add'] = [ - '#type' => 'link', - '#title' => $this->t('Add experiment'), - '#url' => Url::fromRoute('rl.experiment.add'), - '#attributes' => ['class' => ['button', 'button--primary']], - '#access' => $can_administer, - ]; - $form['description'] = [ - '#markup' => '' . $this->t('Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '
', - ]; + $filtered = ExperimentsFilterForm::isFiltered($filters); + if ($filtered) { + $form['summary'] = [ + '#markup' => '' . $this->t('Showing @shown of @total experiments.', [ + '@shown' => $pager->getTotalItems(), + '@total' => count($registered), + ]) . '
', + ]; + } $form['selection_help'] = [ '#markup' => '' . $this->t('Select experiments on this page to apply an action.') . '
', '#access' => $can_administer && !empty($experiments), @@ -173,7 +179,7 @@ public function buildForm(array $form, FormStateInterface $form_state): array { $form['experiments'] = [ '#type' => $can_administer ? 'tableselect' : 'table', '#header' => $header, - '#empty' => $this->t('No experiments found.'), + '#empty' => $filtered ? $this->t('No experiments match the filters.') : $this->t('No experiments found.'), ]; if ($can_administer) { // Core tableselect uses this title for accessible checkbox labels. @@ -218,10 +224,37 @@ public function submitForm(array &$form, FormStateInterface $form_state): void { $this->tempStoreFactory->get('rl_experiment_delete_multiple')->set($token, $form_state->get('selection')); // Defer the destination until after confirmation, as in a multi-step form. $form_state->setIgnoreDestination(); - $query = array_intersect_key($this->getRequest()->query->all(), array_flip(['order', 'sort', 'page', 'destination'])); + $keep = array_merge(['order', 'sort', 'page', 'destination'], ExperimentsFilterForm::FILTER_KEYS); + $query = array_intersect_key($this->getRequest()->query->all(), array_flip($keep)); $form_state->setRedirect('rl.experiment.delete_multiple', ['selection' => $token], [ 'query' => $query, ]); } + /** + * Checks an experiment against the search, source and activity filters. + * + * @param object $experiment + * An experiment row with a display name and statistics. + * @param string[] $filters + * The filters from ExperimentsFilterForm::getFilters(). + */ + protected function matchesFilters(object $experiment, array $filters): bool { + if ($filters['search'] !== '') { + $haystacks = [$experiment->experiment_name, $experiment->experiment_id]; + if (!array_filter($haystacks, static fn (string $text): bool => mb_stripos($text, $filters['search']) !== FALSE)) { + return FALSE; + } + } + if ($filters['source'] !== '' && $experiment->module !== $filters['source']) { + return FALSE; + } + $has_impressions = (int) $experiment->total_turns > 0; + return match ($filters['activity']) { + 'active' => $has_impressions, + 'idle' => !$has_impressions, + default => TRUE, + }; + } + }