From 62593c3703be1f364eabbe870923664f1637bf63 Mon Sep 17 00:00:00 2001 From: jjroelofs <904576+jjroelofs@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:47:53 +0100 Subject: [PATCH 1/3] Add search and filters to the RL experiments overview /admin/reports/rl gets a Name or ID search plus Source and Activity filters above the list. The filter form redirects to the overview with the values in the URL, so filtered lists can be bookmarked, sorted and paged. The bulk delete confirmation and its cancel link keep the filters. A search for "0" counts as a filter, and unknown or malformed parameters are ignored. The Add experiment button and intro text move from the table form to the page so the filters sit between them and the table. --- CHANGELOG.md | 4 + docs/getting-started/configuration.md | 5 + rl.routing.yml | 2 +- scripts/e2e/overview-rows.php | 26 ++++ scripts/e2e/test-experiments-overview.sh | 73 ++++++++++ src/Controller/ReportsController.php | 18 ++- src/Form/ExperimentDeleteMultipleForm.php | 3 +- src/Form/ExperimentsFilterForm.php | 155 ++++++++++++++++++++++ src/Form/ExperimentsOverviewForm.php | 59 ++++++-- 9 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 scripts/e2e/overview-rows.php create mode 100755 scripts/e2e/test-experiments-overview.sh create mode 100644 src/Form/ExperimentsFilterForm.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf502e..5aed2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ - New submodule `rl_menu_link` for A/B testing menu link labels (works for both `menu_link_content` entities and YAML-defined links). **Multilingual: same per-language scoping as rl_page_title.** +- The experiments overview at `/admin/reports/rl` has a search box (matches + the experiment name or ID) and Source and Activity filters. Filters live in + the URL, combine with sorting and paging, and survive the bulk delete + confirmation. ### Architecture 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. ![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 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..22c06a4 --- /dev/null +++ b/src/Form/ExperimentsFilterForm.php @@ -0,0 +1,155 @@ +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'][] = 'form--inline'; + $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, + }; + } + } From 42cd54e339b1c6503a97dc09a3987ef6946bc74d Mon Sep 17 00:00:00 2001 From: jjroelofs <904576+jjroelofs@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:32:09 +0100 Subject: [PATCH 2/3] Align the experiments filter labels, fields and buttons The inline form layout floated each item, so the search label sat higher than the select labels and the Filter and Reset buttons aligned to the label row instead of the fields. The filter now uses a small flex layout that bottom-aligns every field and button and keeps the rows aligned when the form wraps on narrow screens. --- css/experiments-filter.css | 24 ++++++++++++++++++++++++ rl.libraries.yml | 6 ++++++ src/Form/ExperimentsFilterForm.php | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 css/experiments-filter.css 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/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/src/Form/ExperimentsFilterForm.php b/src/Form/ExperimentsFilterForm.php index 22c06a4..7abcc4d 100644 --- a/src/Form/ExperimentsFilterForm.php +++ b/src/Form/ExperimentsFilterForm.php @@ -83,7 +83,8 @@ public function buildForm(array $form, FormStateInterface $form_state): array { natcasesort($sources); $sources = array_combine($sources, $sources); - $form['#attributes']['class'][] = 'form--inline'; + $form['#attributes']['class'][] = 'rl-experiments-filter'; + $form['#attached']['library'][] = 'rl/experiments-filter'; $form['search'] = [ '#type' => 'textfield', '#title' => $this->t('Name or ID'), From bfee292a56959817ea313def60ef9242ceaff677 Mon Sep 17 00:00:00 2001 From: jjroelofs <904576+jjroelofs@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:44:58 +0100 Subject: [PATCH 3/3] Drop the changelog entry The changelog file is being removed from the module in a separate change. --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aed2ee..9cf502e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,6 @@ - New submodule `rl_menu_link` for A/B testing menu link labels (works for both `menu_link_content` entities and YAML-defined links). **Multilingual: same per-language scoping as rl_page_title.** -- The experiments overview at `/admin/reports/rl` has a search box (matches - the experiment name or ID) and Source and Activity filters. Filters live in - the URL, combine with sorting and paging, and survive the bulk delete - confirmation. ### Architecture