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})