diff --git a/assets/js/dashboard/extra/exploration/constants.ts b/assets/js/dashboard/extra/exploration/constants.ts index ee2363b1968f..a64a87b6a736 100644 --- a/assets/js/dashboard/extra/exploration/constants.ts +++ b/assets/js/dashboard/extra/exploration/constants.ts @@ -16,5 +16,6 @@ export const DIRECTION_OPTIONS: ExplorationDirectionOption[] = [ export const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page'] -export const MAX_VISIBLE_CANDIDATES = 10 +export const INITIAL_VISIBLE_CANDIDATES = 10 +export const SHOW_MORE_INCREMENT = 10 export const MIN_GRID_COLUMNS = 3 diff --git a/assets/js/dashboard/extra/exploration/exploration-column.tsx b/assets/js/dashboard/extra/exploration/exploration-column.tsx index ac6450d127fb..a598e8210464 100644 --- a/assets/js/dashboard/extra/exploration/exploration-column.tsx +++ b/assets/js/dashboard/extra/exploration/exploration-column.tsx @@ -8,14 +8,18 @@ import { } from '../../util/number-formatter' import { CursorIcon, FolderIcon } from '../../components/icons' import { popover } from '../../components/popover' -import { ChevronUpDownIcon } from '@heroicons/react/20/solid' +import { + ChevronUpDownIcon, + EllipsisHorizontalIcon +} from '@heroicons/react/20/solid' import { FlagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' import { roundedPercentage } from './helpers' import { journeyStepsEqual, JourneyStep, JourneySuggestion } from './journey' import { DIRECTION, DIRECTION_OPTIONS, - MAX_VISIBLE_CANDIDATES, + INITIAL_VISIBLE_CANDIDATES, + SHOW_MORE_INCREMENT, ExplorationDirection } from './constants' @@ -341,13 +345,40 @@ export function ExplorationColumn({ onFilterChange((e.target as HTMLInputElement).value) ) + // Track how many times the user has clicked "Show N more" for this column. + // Reset whenever the underlying results array reference changes so a new + // candidate list (filter change, journey change, etc.) starts collapsed. + const [expandCount, setExpandCount] = useState(0) + useEffect(() => { + setExpandCount(0) + }, [results]) + + // If the selected step lives beyond INITIAL_VISIBLE_CANDIDATES in a frozen + // column, make sure it is still visible by expanding the base window to + // include it. The user picked it from a list they could see, so it should + // remain visible after selection. + const selectedIndex = + selected && results.length > 0 + ? results.findIndex(({ step }) => journeyStepsEqual(step, selected)) + : -1 + const baseVisibleCount = Math.max( + INITIAL_VISIBLE_CANDIDATES, + selectedIndex >= 0 ? selectedIndex + 1 : 0 + ) + const visibleCount = Math.min( + results.length, + baseVisibleCount + expandCount * SHOW_MORE_INCREMENT + ) + const remainingCount = Math.max(0, results.length - visibleCount) + const showMoreCount = Math.min(SHOW_MORE_INCREMENT, remainingCount) + // When a step is selected but there are no candidate results, // synthesise a single-item list from the funnel data so // the selected step is still rendered in the column. const listItems = selected && results.length === 0 ? [{ step: selected, visitors: selectedVisitors ?? 0 }] - : results.slice(0, MAX_VISIBLE_CANDIDATES) + : results.slice(0, visibleCount) const stepMaxVisitors = maxVisitors ?? results[0]?.visitors @@ -427,6 +458,25 @@ export function ExplorationColumn({ onSelect={onSelectHandler} /> ))} + {showMoreCount > 0 && ( +
  • + +
  • + )} )} diff --git a/e2e/tests/dashboard/exploration.spec.ts b/e2e/tests/dashboard/exploration.spec.ts index 09203a9928a8..70726960ac7a 100644 --- a/e2e/tests/dashboard/exploration.spec.ts +++ b/e2e/tests/dashboard/exploration.spec.ts @@ -1387,3 +1387,129 @@ test('render various types of entries', async ({ page, request }) => { await firstColumn.getByTestId('exploration-row').nth(6).locator('svg').hover() await expect(page.getByRole('tooltip')).toHaveText(/Goal/) }) + +test('load more suggestions', async ({ page, request }) => { + const report = getReport(page) + const explorationTabButton = getExplorationTabButton(report) + const { domain } = await setupSite({ page, request }) + + const events1 = [...Array(25).keys()].map((i) => { + return { + name: 'pageview', + pathname: `/pageone${String(i).padStart(2, '0')}` + } + }) + + const events2 = [...Array(25).keys()].map((i) => { + return { + name: 'pageview', + pathname: `/pagetwo${String(i).padStart(2, '0')}` + } + }) + + await populateStats({ + request, + domain, + events: events1.concat(events2) + }) + + await page.goto('/' + domain, { waitUntil: 'commit' }) + + await explorationTabButton.scrollIntoViewIfNeeded() + await explorationTabButton.click() + + await expect(report.getByTestId('exploration-title')).toHaveText( + 'Explore user journeys' + ) + + await expect( + report.getByTestId('exploration-direction-forward') + ).toBeVisible() + + const firstColumn = report.getByTestId('exploration-column-0') + + const columnRow = (i: number) => + firstColumn.getByTestId('exploration-row').nth(i) + + const firstPage = [ + '/pageone00', + '/pageone01', + '/pageone02', + '/pageone03', + '/pageone04', + '/pageone05', + '/pageone06', + '/pageone07', + '/pageone08', + '/pageone09' + ] + + const secondPage = [ + '/pageone10', + '/pageone11', + '/pageone12', + '/pageone13', + '/pageone14', + '/pageone15', + '/pageone16', + '/pageone17', + '/pageone18', + '/pageone19' + ] + + await expect( + firstColumn.getByTestId('exploration-row').getByTestId('metric-label') + ).toHaveText(firstPage) + + await expect(columnRow(10)).toHaveText(/Show 10 more/) + await columnRow(10).click() + + await expect( + firstColumn.getByTestId('exploration-row').getByTestId('metric-label') + ).toHaveText(firstPage.concat(secondPage)) + + await expect(columnRow(20)).toHaveText(/Show 10 more/) + + await test.step('reset state when suggestions change', async () => { + await firstColumn.getByPlaceholder('Search').fill('pagetwo') + + const newFirstPage = [ + '/pagetwo00', + '/pagetwo01', + '/pagetwo02', + '/pagetwo03', + '/pagetwo04', + '/pagetwo05', + '/pagetwo06', + '/pagetwo07', + '/pagetwo08', + '/pagetwo09' + ] + + const newSecondPage = [ + '/pagetwo10', + '/pagetwo11', + '/pagetwo12', + '/pagetwo13', + '/pagetwo14', + '/pagetwo15', + '/pagetwo16', + '/pagetwo17', + '/pagetwo18', + '/pagetwo19' + ] + + await expect( + firstColumn.getByTestId('exploration-row').getByTestId('metric-label') + ).toHaveText(newFirstPage) + + await expect(columnRow(10)).toHaveText(/Show 10 more/) + await columnRow(10).click() + + await expect( + firstColumn.getByTestId('exploration-row').getByTestId('metric-label') + ).toHaveText(newFirstPage.concat(newSecondPage)) + + await expect(columnRow(20)).toHaveText(/Show 5 more/) + }) +}) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 73d3a1c14ee1..5eb92dd557cc 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -33,7 +33,7 @@ defmodule Plausible.Stats.Exploration do } @max_steps 20 - @max_candidates 20 + @max_candidates 50 @next_steps_defaults [ search_term: "", diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 08fde268c4da..5a03f31fbe4f 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -210,6 +210,8 @@ defmodule PlausibleWeb.Api.StatsController do end end + @exploration_max_candidates 50 + def exploration_next_with_funnel(conn, %{"journey" => steps} = params) do site = conn.assigns.site search_term = params["search_term"] || "" @@ -225,7 +227,8 @@ defmodule PlausibleWeb.Api.StatsController do Exploration.next_steps(site, query, journey, search_term: search_term, direction: direction, - include_wildcard?: include_wildcard? + include_wildcard?: include_wildcard?, + max_candidates: @exploration_max_candidates ), funnel <- maybe_include_funnel(include_funnel?, query, journey, direction) do json(conn, %{next: next_steps, funnel: funnel})