diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index e258f72065b1..4641e2c7f54c 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -111,7 +111,10 @@ export function dashboardStateToParams( queryObj.to = formatISO(dashboardState.to) } if (dashboardState.filters) { - queryObj.filters = serializeApiFilters(dashboardState.filters) + queryObj.filters = serializeApiFilters( + dashboardState.filters, + dashboardState.engagedSessionsOnly + ) } if (dashboardState.with_imported) { queryObj.with_imported = String(dashboardState.with_imported) diff --git a/assets/js/dashboard/dashboard-state-context.tsx b/assets/js/dashboard/dashboard-state-context.tsx index 5c021b4f7501..0dde40062f3f 100644 --- a/assets/js/dashboard/dashboard-state-context.tsx +++ b/assets/js/dashboard/dashboard-state-context.tsx @@ -1,4 +1,10 @@ -import React, { createContext, useMemo, useContext, ReactNode } from 'react' +import React, { + createContext, + useMemo, + useContext, + ReactNode, + useState +} from 'react' import { useLocation } from 'react-router' import { useMountedEffect } from './custom-hooks' import * as api from './api' @@ -25,6 +31,7 @@ import { useSegmentsContext } from './filtering/segments-context' const dashboardStateContextDefaultValue = { dashboardState: dashboardStateDefaultValue, + setEngagedSessionsOnly: (_enabled: boolean) => {}, otherSearch: {} as Record, expandedSegment: null as (SavedSegment & { segment_data: SegmentData }) | null } @@ -49,6 +56,7 @@ export default function DashboardStateContextProvider({ SavedSegment & { segment_data: SegmentData } >('expandedSegment') const site = useSiteContext() + const [engagedSessionsOnly, setEngagedSessionsOnly] = useState(false) const { compare_from, @@ -113,7 +121,8 @@ export default function DashboardStateContextProvider({ : defaultValues.with_imported, filters, resolvedFilters, - labels: (labels as FilterClauseLabels) || defaultValues.labels + labels: (labels as FilterClauseLabels) || defaultValues.labels, + engagedSessionsOnly } }, [ compare_from, @@ -129,7 +138,8 @@ export default function DashboardStateContextProvider({ with_imported, site, expandedSegment, - segmentsContext.segments + segmentsContext.segments, + engagedSessionsOnly ]) useClearExpandedSegmentModeOnFilterClear({ expandedSegment, dashboardState }) @@ -148,6 +158,7 @@ export default function DashboardStateContextProvider({ + setEngagedSessionsOnly(!dashboardState.engagedSessionsOnly) + } + className={classNames( + popover.items.classNames.navigationLink, + popover.items.classNames.hoverLink + )} + > + Only sessions with engagement + + + ) +} + function DashboardOptionsMenuItems() { const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() const { selectedInterval, onIntervalClick, availableIntervals } = useGraphIntervalContext() const imports = useImportsIncludedContext() @@ -111,6 +133,7 @@ function DashboardOptionsMenuItems() { exportStatus={exportStatus} setExportStatus={setExportStatus} /> + {site.engagedSessionsFilterAvailable && } {imports.status === 'visible' && ( <> diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index d4668180756a..9b87012b2349 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -31,6 +31,7 @@ describe('parseSiteFromDataset', () => { data-legacy-time-on-page-cutoff="2022-01-01T00:00:00Z" data-embedded="" data-is-dbip="false" + data-engaged-sessions-filter-available="true" data-current-user-role="owner" data-current-user-id="1" data-flags="{}" @@ -60,6 +61,7 @@ describe('parseSiteFromDataset', () => { embedded: false, background: undefined, isDbip: false, + engagedSessionsFilterAvailable: true, flags: {}, shared: false, isConsolidatedView: false, diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index e2f2d67556b8..6ad00a2d1972 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -26,6 +26,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { embedded: dataset.embedded === 'true', background: dataset.background, isDbip: dataset.isDbip === 'true', + engagedSessionsFilterAvailable: + dataset.engagedSessionsFilterAvailable === 'true', flags: JSON.parse(dataset.flags!), shared: !!dataset.sharedLinkAuth, isConsolidatedView: dataset.isConsolidatedView === 'true', @@ -61,6 +63,7 @@ export const siteContextDefaultValue = { embedded: false, background: undefined as string | undefined, isDbip: false, + engagedSessionsFilterAvailable: false, flags: {} as FeatureFlags, shared: false, isConsolidatedView: false, diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 77a27af2df0d..5dcc72a945cb 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -85,7 +85,10 @@ export function createStatsQuery( dimensions: reportParams.dimensions || [], metrics: reportParams.metrics, filters: [ - ...remapToApiFilters(dashboardState.filters), + ...remapToApiFilters( + dashboardState.filters, + dashboardState.engagedSessionsOnly + ), ...(reportParams.alwaysOnFilters ?? []) ], order_by: reportParams.order_by || null, diff --git a/assets/js/dashboard/stats/csv-export/csv-export-body.ts b/assets/js/dashboard/stats/csv-export/csv-export-body.ts index b03731702b24..f85c10f56af9 100644 --- a/assets/js/dashboard/stats/csv-export/csv-export-body.ts +++ b/assets/js/dashboard/stats/csv-export/csv-export-body.ts @@ -147,7 +147,10 @@ export function createCsvExportRequestBody( return { date_range: createDateRange(dashboardState), relative_date: dashboardState.date ? formatISO(dashboardState.date) : null, - filters: remapToApiFilters(dashboardState.filters), + filters: remapToApiFilters( + dashboardState.filters, + dashboardState.engagedSessionsOnly + ), include: { imports: dashboardState.with_imported }, reports: reports } diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js index dc5d72fada37..eca2b4317766 100644 --- a/assets/js/dashboard/util/filters.js +++ b/assets/js/dashboard/util/filters.js @@ -39,6 +39,11 @@ export const FILTER_OPERATIONS = { has_not_done: 'has_not_done' } +const ENGAGED_SESSIONS_API_FILTER = [ + 'has_done', + ['is', 'event:name', ['engagement']] +] + export const FILTER_OPERATIONS_DISPLAY_NAMES = { [FILTER_OPERATIONS.is]: 'is', [FILTER_OPERATIONS.isNot]: 'is not', @@ -240,8 +245,11 @@ function remapApiFilterKey(apiFilterKey) { return apiFilterKey // maybe throw? } -export function remapToApiFilters(filters) { - return filters.map(remapToApiFilter) +export function remapToApiFilters(filters, engagedSessionsOnly = false) { + const apiFilters = filters.map(remapToApiFilter) + return engagedSessionsOnly + ? [...apiFilters, ENGAGED_SESSIONS_API_FILTER] + : apiFilters } export function remapFromApiFilters(apiFilters) { @@ -260,8 +268,8 @@ export function remapFromApiFilters(apiFilters) { }) } -export function serializeApiFilters(filters) { - return JSON.stringify(remapToApiFilters(filters)) +export function serializeApiFilters(filters, engagedSessionsOnly = false) { + return JSON.stringify(remapToApiFilters(filters, engagedSessionsOnly)) } function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) { diff --git a/assets/js/dashboard/util/filters.test.ts b/assets/js/dashboard/util/filters.test.ts index 20b8080567f5..624da99c641e 100644 --- a/assets/js/dashboard/util/filters.test.ts +++ b/assets/js/dashboard/util/filters.test.ts @@ -79,4 +79,10 @@ describe(`${serializeApiFilters.name}`, () => { JSON.stringify([['has_not_done', ['is', 'event:goal', ['Signup']]]]) ) }) + + it('adds the engaged sessions filter in API format', () => { + expect(serializeApiFilters([], true)).toEqual( + JSON.stringify([['has_done', ['is', 'event:name', ['engagement']]]]) + ) + }) }) diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 8d25852950e1..8477ef8dd1c1 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -48,6 +48,7 @@ export const DEFAULT_SITE: PlausibleSite = { embedded: false, background: '', isDbip: false, + engagedSessionsFilterAvailable: false, flags: {}, shared: false, isConsolidatedView: false, diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 2a308338f388..70e68bb89c54 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -39,6 +39,9 @@ data-background={@conn.assigns[:background]} data-is-dbip={to_string(@dbip?)} data-current-user-role={@site_role} + data-engaged-sessions-filter-available={ + to_string(Plausible.Auth.super_admin?(@conn.assigns[:current_user])) + } data-current-user-id={ if user = @conn.assigns[:current_user], do: user.id, else: Jason.encode!(nil) } diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index d6c249a5165f..ec4da70bd9a9 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -596,6 +596,10 @@ defmodule PlausibleWeb.StatsControllerTest do resp = html_response(conn, 200) assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + + assert text_of_attr(resp, @react_container, "data-engaged-sessions-filter-available") == + "true" + assert text_of_attr(resp, @react_container, "data-show-email-reports-cta") == "true" end