diff --git a/assets/js/dashboard/components/dropdown.tsx b/assets/js/dashboard/components/dropdown.tsx
index 308720f52000..e9d25dbcff64 100644
--- a/assets/js/dashboard/components/dropdown.tsx
+++ b/assets/js/dashboard/components/dropdown.tsx
@@ -18,32 +18,57 @@ import {
export const ToggleDropdownButton = forwardRef<
HTMLDivElement,
{
+ variant?: 'ghost' | 'button'
+ className?: string
currentOption: ReactNode
children: ReactNode
onClick: () => void
dropdownContainerProps: AriaAttributes
}
->(({ currentOption, children, onClick, dropdownContainerProps }, ref) => {
- return (
-
-
- {children}
-
- )
-})
+>(
+ (
+ {
+ className,
+ currentOption,
+ children,
+ onClick,
+ dropdownContainerProps,
+ ...props
+ },
+ ref
+ ) => {
+ const { variant } = { variant: 'button', ...props }
+ const wrapperClass = { ghost: '', button: 'min-w-32 md:w-48 md:relative' }[
+ variant
+ ]
+ const sharedButtonClass =
+ 'flex items-center rounded text-xs md:text-sm leading-tight px-2 py-2 md:px-3'
+ const buttonClass = {
+ ghost:
+ 'text-gray-500 hover:text-gray-800 hover:bg-gray-200 dark:hover:text-gray-200 dark:hover:bg-gray-900',
+ button:
+ 'w-full justify-between bg-white dark:bg-gray-800 shadow text-gray-800 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-900'
+ }[variant]
+
+ return (
+
+
+ {children}
+
+ )
+ }
+)
export const DropdownMenuWrapper = forwardRef<
HTMLDivElement,
diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js
deleted file mode 100644
index 455d760222c6..000000000000
--- a/assets/js/dashboard/filters.js
+++ /dev/null
@@ -1,373 +0,0 @@
-/** @format */
-
-import React, { Fragment, useEffect, useState } from 'react'
-import { useQueryContext } from './query-context'
-import { useSiteContext } from './site-context'
-import { filterRoute } from './router'
-import {
- AppNavigationLink,
- useAppNavigate
-} from './navigation/use-app-navigate'
-import {
- AdjustmentsVerticalIcon,
- MagnifyingGlassIcon,
- XMarkIcon,
- PencilSquareIcon
-} from '@heroicons/react/20/solid'
-import classNames from 'classnames'
-import { Menu, Transition } from '@headlessui/react'
-
-import {
- FILTER_GROUP_TO_MODAL_TYPE,
- cleanLabels,
- FILTER_MODAL_TO_FILTER_GROUP,
- formatFilterGroup,
- EVENT_PROPS_PREFIX,
- plainFilterText,
- styledFilterText
-} from './util/filters'
-
-const WRAPSTATE = { unwrapped: 0, waiting: 1, wrapped: 2 }
-
-function removeFilter(filterIndex, navigate, query) {
- const newFilters = query.filters.filter(
- (_filter, index) => filterIndex != index
- )
- const newLabels = cleanLabels(newFilters, query.labels)
-
- navigate({
- search: (search) => ({
- ...search,
- filters: newFilters,
- labels: newLabels
- })
- })
-}
-
-function clearAllFilters(navigate) {
- navigate({
- search: (search) => ({
- ...search,
- filters: null,
- labels: null
- })
- })
-}
-
-function AppliedFilterPillVertical({ filterIndex, filter }) {
- const { query } = useQueryContext()
- const navigate = useAppNavigate()
- const [_operation, filterKey, _clauses] = filter
-
- const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
-
- return (
-
-
-
search}
- className="group flex w-full justify-between items-center"
- style={{ width: 'calc(100% - 1.5rem)' }}
- >
-
- {styledFilterText(query, filter)}
-
-
-
-
removeFilter(filterIndex, navigate, query)}
- >
-
-
-
-
- )
-}
-
-function OpenFilterGroupOptionsButton({ option }) {
- return (
-
- {({ active }) => (
- search}
- className={classNames(
- active
- ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100'
- : 'text-gray-800 dark:text-gray-300',
- 'block px-4 py-2 text-sm font-medium'
- )}
- >
- {formatFilterGroup(option)}
-
- )}
-
- )
-}
-
-function DropdownContent({ wrapped }) {
- const navigate = useAppNavigate()
- const site = useSiteContext()
- const { query } = useQueryContext()
- const [addingFilter, setAddingFilter] = useState(false)
-
- if (wrapped === WRAPSTATE.unwrapped || addingFilter) {
- let filterModals = { ...FILTER_MODAL_TO_FILTER_GROUP }
- if (!site.propsAvailable) delete filterModals.props
-
- return (
- <>
- {Object.keys(filterModals).map((option) => (
-
- ))}
- >
- )
- }
-
- return (
- <>
- setAddingFilter(true)}
- >
- + Add filter
-
- {query.filters.map((filter, index) => (
-
- ))}
-
- clearAllFilters(navigate)}
- >
- Clear All Filters
-
-
- >
- )
-}
-
-function Filters() {
- const navigate = useAppNavigate()
- const { query } = useQueryContext()
-
- const [wrapped, setWrapped] = useState(WRAPSTATE.waiting)
- const [viewport, setViewport] = useState(1080)
-
- useEffect(() => {
- handleResize()
-
- window.addEventListener('resize', handleResize, false)
-
- return () => {
- window.removeEventListener('resize', handleResize, false)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
- useEffect(() => {
- setWrapped(WRAPSTATE.waiting)
- }, [query, viewport])
-
- useEffect(() => {
- if (wrapped === WRAPSTATE.waiting) {
- updateDisplayMode()
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [wrapped])
-
- function handleResize() {
- setViewport(window.innerWidth || 639)
- }
-
- // Checks if the filter container is wrapping items
- function updateDisplayMode() {
- const container = document.getElementById('filters')
- const children = (container && [...container.childNodes]) || []
-
- // Always wrap on mobile
- if (query.filters.length > 0 && viewport <= 768) {
- setWrapped(WRAPSTATE.wrapped)
- return
- }
-
- setWrapped(WRAPSTATE.unwrapped)
-
- // Check for different y value between all child nodes - this indicates a wrap
- children.forEach((child) => {
- const currentChildY = child.getBoundingClientRect().top
- const firstChildY = children[0].getBoundingClientRect().top
- if (currentChildY !== firstChildY) {
- setWrapped(WRAPSTATE.wrapped)
- }
- })
- }
-
- function AppliedFilterPillHorizontal({ filterIndex, filter }) {
- const { query } = useQueryContext()
- const [_operation, filterKey, _clauses] = filter
- const type = filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
- return (
-
- search}
- >
-
- {styledFilterText(query, filter)}
-
-
- removeFilter(filterIndex, navigate, query)}
- >
-
-
-
- )
- }
-
- function renderDropdownButton() {
- if (wrapped === false) {
- const filterCount = query.filters.length
- return (
- <>
-
- {filterCount} Filter{filterCount === 1 ? '' : 's'}
- >
- )
- }
-
- return (
- <>
-
- {/* This would have been a good use-case for JSX! But in the interest of keeping the breakpoint width logic with TailwindCSS, this is a better long-term way to deal with it. */}
- Filter
- Filter
- >
- )
- }
-
- function trackFilterMenu() {
- if (window.trackCustomEvent) {
- window.trackCustomEvent('Filter Menu: Open')
- }
- }
-
- function renderDropDown() {
- return (
-
- )
- }
-
- function renderFilterList() {
- // The filters are rendered even when `wrapped === WRAPSTATE.waiting`.
- // Otherwise, if they don't exist in the DOM, we can't check whether
- // the flex-wrap is actually putting them on multiple lines.
- if (true) {
- return (
-
-
- {query.filters.map((filter, index) => (
-
- ))}
-
- {!!query.filters.length && (
- <>
-
({
- ...search,
- filters: null,
- labels: null
- })}
- >
-
-
-
{'|'}
-
- >
- )}
-
- )
- }
-
- return null
- }
-
- return (
- <>
- {renderFilterList()}
-
- {renderDropDown()}
- >
- )
-}
-
-export default Filters
diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx
index c578524da4cd..35c3148610c0 100644
--- a/assets/js/dashboard/index.tsx
+++ b/assets/js/dashboard/index.tsx
@@ -10,6 +10,7 @@ import Locations from './stats/locations'
import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours'
+import { FiltersBar } from './nav-menu/filters-bar'
function DashboardStats({
importedDataInView,
@@ -53,7 +54,10 @@ function Dashboard() {
return (
-
+
}
+ />
{
+ const dropdownRef = useRef(null)
+ const [opened, setOpened] = useState(false)
+ const site = useSiteContext()
+ const modalKeys = site.propsAvailable
+ ? Object.keys(FILTER_MODAL_TO_FILTER_GROUP)
+ : Object.keys(FILTER_MODAL_TO_FILTER_GROUP).filter((k) => k !== 'props')
+
+ useOnClickOutside({
+ ref: dropdownRef,
+ active: opened,
+ handler: () => setOpened(false)
+ })
+ return (
+ setOpened((opened) => !opened)}
+ currentOption={
+
+
+ Filter
+
+ }
+ >
+ {opened && (
+
+ )}
+
+ )
+}
diff --git a/assets/js/dashboard/nav-menu/filter-pill.tsx b/assets/js/dashboard/nav-menu/filter-pill.tsx
new file mode 100644
index 000000000000..854cf63b16bc
--- /dev/null
+++ b/assets/js/dashboard/nav-menu/filter-pill.tsx
@@ -0,0 +1,41 @@
+/** @format */
+
+import React, { ReactNode } from 'react'
+import { AppNavigationLink } from '../navigation/use-app-navigate'
+import { filterRoute } from '../router'
+import { XMarkIcon } from '@heroicons/react/20/solid'
+
+export function FilterPill({
+ plainText,
+ children,
+ modalToOpen,
+ onRemoveClick
+}: {
+ plainText: string
+ modalToOpen: string
+ children: ReactNode
+ onRemoveClick: () => void
+}) {
+ return (
+
+
search}
+ >
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/assets/js/dashboard/nav-menu/filter-pills-list.tsx b/assets/js/dashboard/nav-menu/filter-pills-list.tsx
new file mode 100644
index 000000000000..37f4f5c32914
--- /dev/null
+++ b/assets/js/dashboard/nav-menu/filter-pills-list.tsx
@@ -0,0 +1,161 @@
+/** @format */
+
+import React from 'react'
+import { useQueryContext } from '../query-context'
+import { FilterPill } from './filter-pill'
+import {
+ cleanLabels,
+ EVENT_PROPS_PREFIX,
+ FILTER_GROUP_TO_MODAL_TYPE,
+ plainFilterText,
+ remapToApiFilters,
+ styledFilterText
+} from '../util/filters'
+import {
+ AppNavigationLink,
+ useAppNavigate
+} from '../navigation/use-app-navigate'
+import { XMarkIcon } from '@heroicons/react/20/solid'
+import { useMutation } from '@tanstack/react-query'
+import { useSiteContext } from '../site-context'
+import { DashboardQuery } from '../query'
+
+export function FilterPillsList() {
+ const site = useSiteContext()
+ const { query } = useQueryContext()
+ const navigate = useAppNavigate()
+
+ const saveAs = useMutation({
+ mutationFn: (data: {
+ name: string
+ personal: boolean
+ segment_data: { filters: DashboardQuery['filters'] }
+ }) => {
+ return fetch(
+ `/internal-api/${encodeURIComponent(site.domain)}/segments`,
+ {
+ method: 'POST',
+ body: JSON.stringify(data),
+ headers: { 'content-type': 'application/json' }
+ }
+ ).then((res) => res.json())
+ },
+ onSuccess: async (d) => {
+ navigate({
+ search: (search) => ({
+ ...search,
+ filters: [['is', 'segment', [d.id]]],
+ labels: { [d.id]: [d.name] }
+ })
+ })
+ }
+ })
+ const save = useMutation({
+ mutationFn: ({
+ id,
+ ...data
+ }: {
+ id: number
+ name?: string
+ personal?: boolean
+ segment_data: { filters: DashboardQuery['filters'] }
+ }) => {
+ return fetch(
+ `/internal-api/${encodeURIComponent(site.domain)}/segments/${id}`,
+ {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ headers: { 'content-type': 'application/json' }
+ }
+ )
+ },
+ onSuccess: (_d, _id) => {
+ navigate({
+ search: (search) => ({
+ ...search,
+ filters: query.filters.filter((f) => f[1] === 'segment')
+ })
+ })
+ }
+ })
+
+ const segmentInFilters = query.filters.find((f) => f[1] === 'segment')
+
+ return (
+
+
+ {query.filters.map((filter, index) => (
+
+ navigate({
+ search: (search) => ({
+ ...search,
+ filters: query.filters.filter((_, i) => i !== index),
+ labels: cleanLabels(query.filters, query.labels)
+ })
+ })
+ }
+ >
+ {styledFilterText(query, filter)}
+
+ ))}
+
+ {!!query.filters.length && (
+ <>
+
({
+ ...search,
+ filters: null,
+ labels: null
+ })}
+ >
+
+
+
{'|'}
+ {!segmentInFilters && (
+
+ )}
+ {segmentInFilters && (
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx
new file mode 100644
index 000000000000..2ccb27445d41
--- /dev/null
+++ b/assets/js/dashboard/nav-menu/filters-bar.tsx
@@ -0,0 +1,9 @@
+import React from "react"
+import { useQueryContext } from "../query-context"
+import { FilterPillsList } from "./filter-pills-list";
+
+export const FiltersBar = () => {
+ const {query} = useQueryContext();
+ if (query.filters.length === 0) {return null}
+ return
+}
\ No newline at end of file
diff --git a/assets/js/dashboard/nav-menu/top-bar.test.tsx b/assets/js/dashboard/nav-menu/top-bar.test.tsx
index 8a2868859eed..be7ab45561a8 100644
--- a/assets/js/dashboard/nav-menu/top-bar.test.tsx
+++ b/assets/js/dashboard/nav-menu/top-bar.test.tsx
@@ -68,23 +68,22 @@ test('user can open and close filters dropdown', async () => {
)
})
- const toggleFilters = screen.getByRole('button', { name: /Filter/ })
+ const toggleFilters = screen.getByRole('button', { name: 'Filter' })
await userEvent.click(toggleFilters)
- expect(screen.queryAllByRole('menuitem').map((el) => el.textContent)).toEqual(
- [
- 'Page',
- 'Source',
- 'Location',
- 'Screen size',
- 'Browser',
- 'Operating System',
- 'UTM tags',
- 'Goal',
- 'Hostname'
- ]
- )
+ expect(screen.queryAllByRole('link').map((el) => el.textContent)).toEqual([
+ 'Page',
+ 'Source',
+ 'Location',
+ 'Screen size',
+ 'Browser',
+ 'Operating System',
+ 'UTM tags',
+ 'Goal',
+ 'Hostname',
+ 'Segment'
+ ])
await userEvent.click(toggleFilters)
- expect(screen.queryAllByRole('menuitem')).toEqual([])
+ expect(screen.queryAllByRole('link')).toEqual([])
})
test('current visitors renders when visitors are present and disappears after visitors are null', async () => {
diff --git a/assets/js/dashboard/nav-menu/top-bar.tsx b/assets/js/dashboard/nav-menu/top-bar.tsx
index 615ea00efc47..afde1644d622 100644
--- a/assets/js/dashboard/nav-menu/top-bar.tsx
+++ b/assets/js/dashboard/nav-menu/top-bar.tsx
@@ -1,20 +1,21 @@
/** @format */
-import React, { useRef } from 'react'
+import React, { ReactNode, useRef } from 'react'
import SiteSwitcher from '../site-switcher'
import { useSiteContext } from '../site-context'
import { useUserContext } from '../user-context'
import CurrentVisitors from '../stats/current-visitors'
import QueryPeriodPicker from '../datepicker'
-import Filters from '../filters'
import classNames from 'classnames'
import { useInView } from 'react-intersection-observer'
+import { FilterMenu } from './filter-menu'
interface TopBarProps {
showCurrentVisitors: boolean
+ extraBar?: ReactNode
}
-export function TopBar({ showCurrentVisitors }: TopBarProps) {
+export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
const site = useSiteContext()
const user = useUserContext()
const tooltipBoundary = useRef(null)
@@ -31,7 +32,7 @@ export function TopBar({ showCurrentVisitors }: TopBarProps) {
'sticky fullwidth-shadow bg-gray-50 dark:bg-gray-850'
)}
>
-
+
+ {!!extraBar && (
+
{extraBar}
+ )}
>
)
diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js
index 6605c3073cf4..b45c12bd082b 100644
--- a/assets/js/dashboard/util/filters.js
+++ b/assets/js/dashboard/util/filters.js
@@ -77,8 +77,8 @@ const ESCAPED_PIPE = '\\|'
export function getLabel(labels, filterKey, value) {
if (['country', 'region', 'city', 'segment'].includes(filterKey)) {
return labels[value]
- }
-
+ }
+
return value
}
@@ -203,7 +203,9 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
const filteredBy = Object.fromEntries(
filters
.flatMap(([_operation, filterKey, clauses]) =>
- ['country', 'region', 'city'].includes(filterKey) ? clauses : []
+ ['country', 'region', 'city', 'segment'].includes(filterKey)
+ ? clauses
+ : []
)
.map((value) => [value, true])
)
@@ -216,7 +218,7 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
if (
mergedFilterKey &&
- ['country', 'region', 'city'].includes(mergedFilterKey)
+ ['country', 'region', 'city', 'segment'].includes(mergedFilterKey)
) {
result = {
...result,
@@ -227,7 +229,6 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
return result
}
-
function remapFilterKey(filterKey) {
const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])
const NO_PREFIX_KEYS = new Set(['segment'])
@@ -240,12 +241,14 @@ function remapFilterKey(filterKey) {
return `visit:${filterKey}`
}
-export function serializeApiFilters(filters) {
- const apiFilters = filters.map(([operation, filterKey, clauses]) => {
+export function remapToApiFilters(filters) {
+ return filters.map(([operation, filterKey, clauses]) => {
return [operation, remapFilterKey(filterKey), clauses]
})
+}
- return JSON.stringify(apiFilters)
+export function serializeApiFilters(filters) {
+ return JSON.stringify(remapToApiFilters(filters))
}
export function fetchSuggestions(apiPath, query, input, additionalFilter) {
diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts
index c33eae9c862f..8ef93661873e 100644
--- a/assets/js/types/query-api.d.ts
+++ b/assets/js/types/query-api.d.ts
@@ -63,7 +63,7 @@ export type CustomPropertyFilterDimensions = string;
export type GoalDimension = "event:goal";
export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
export type FilterTree = FilterEntry | FilterAndOr | FilterNot;
-export type FilterEntry = FilterWithoutGoals | FilterWithGoals;
+export type FilterEntry = FilterWithoutGoals | FilterWithGoals | FilterForSegment;
/**
* @minItems 3
* @maxItems 3
@@ -91,6 +91,15 @@ export type FilterWithGoals = [
* filter operation
*/
export type FilterOperationWithGoals = "is" | "contains";
+/**
+ * @minItems 3
+ * @maxItems 3
+ */
+export type FilterForSegment = [FilterOperationForSegments, "segment", Clauses];
+/**
+ * filter operation
+ */
+export type FilterOperationForSegments = "is";
/**
* @minItems 2
* @maxItems 2
diff --git a/lib/plausible/stats/breakdown.ex b/lib/plausible/stats/breakdown.ex
index cde1fd64ec7f..8fcc3d75fefb 100644
--- a/lib/plausible/stats/breakdown.ex
+++ b/lib/plausible/stats/breakdown.ex
@@ -18,8 +18,6 @@ defmodule Plausible.Stats.Breakdown do
{limit, page},
_opts \\ []
) do
- get_available_segments = fn -> Repo.preload(site, :segments).segments end
-
transformed_metrics = transform_metrics(metrics, dimension)
transformed_order_by = transform_order_by(order_by || [], dimension)
@@ -39,7 +37,7 @@ defmodule Plausible.Stats.Breakdown do
legacy_breakdown: true,
remove_unavailable_revenue_metrics: true
)
- |> QueryOptimizer.optimize(get_available_segments: get_available_segments)
+ |> QueryOptimizer.optimize()
QueryRunner.run(site, query_with_metrics)
|> build_breakdown_result(query_with_metrics, metrics)
diff --git a/lib/plausible/stats/filters/query_parser.ex b/lib/plausible/stats/filters/query_parser.ex
index 7a85dec90aaa..79ff7b6feb6d 100644
--- a/lib/plausible/stats/filters/query_parser.ex
+++ b/lib/plausible/stats/filters/query_parser.ex
@@ -42,6 +42,7 @@ defmodule Plausible.Stats.Filters.QueryParser do
{:ok, pagination} <- parse_pagination(Map.get(params, "pagination", %{})),
{preloaded_goals, revenue_currencies} <-
preload_needed_goals(site, metrics, filters, dimensions),
+ preloaded_segments = preload_needed_segments(site, filters),
query = %{
metrics: metrics,
filters: filters,
@@ -52,7 +53,8 @@ defmodule Plausible.Stats.Filters.QueryParser do
include: include,
pagination: pagination,
preloaded_goals: preloaded_goals,
- revenue_currencies: revenue_currencies
+ revenue_currencies: revenue_currencies,
+ preloaded_segments: preloaded_segments
},
:ok <- validate_order_by(query),
:ok <- validate_custom_props_access(site, query),
@@ -302,6 +304,14 @@ defmodule Plausible.Stats.Filters.QueryParser do
end
end
+ def preload_needed_segments(site, filters) do
+ if Plausible.Stats.Filters.Segments.has_segment_filters?(filters) do
+ Plausible.Repo.preload(site, :segments).segments
+ else
+ []
+ end
+ end
+
def preload_needed_goals(site, metrics, filters, dimensions) do
goal_filters? =
Enum.any?(filters, fn [_, filter_key | _rest] -> filter_key == "event:goal" end)
diff --git a/lib/plausible/stats/filters/segments.ex b/lib/plausible/stats/filters/segments.ex
index bbded3522f75..a55dd7279110 100644
--- a/lib/plausible/stats/filters/segments.ex
+++ b/lib/plausible/stats/filters/segments.ex
@@ -1,4 +1,7 @@
defmodule Plausible.Stats.Filters.Segments do
+ @moduledoc """
+ Module containing the business logic of segments
+ """
alias Plausible.Stats.Filters
alias Plausible.Stats.Filters.FiltersParser
@@ -7,7 +10,7 @@ defmodule Plausible.Stats.Filters.Segments do
do: Filters.filtering_on_dimension?(filters, FiltersParser.segment_filter_key())
@spec expand_segments_to_constituent_filters(list(), list()) ::
- {:ok, list()} | {:error, any()}
+ list()
def expand_segments_to_constituent_filters(filters, segments) do
case segment_filter_index = find_top_level_segment_filter_index(filters) do
nil ->
@@ -30,9 +33,6 @@ defmodule Plausible.Stats.Filters.Segments do
{:error, :segment_invalid} ->
raise "Segment invalid with id #{inspect(segment_id)}."
-
- _ ->
- raise "Failed to expand segment to filters"
end
end)
)
@@ -51,7 +51,7 @@ defmodule Plausible.Stats.Filters.Segments do
end)
end
- @spec get_segment_data(list(), integer()) :: {:ok, list()} | {:error, :segment_not_found}
+ @spec get_segment_data(list(), integer()) :: {:ok, map()} | {:error, :segment_not_found}
defp get_segment_data(segments, segment_id) do
case Enum.find(segments, fn segment -> segment.id == segment_id end) do
nil -> {:error, :segment_not_found}
@@ -59,7 +59,7 @@ defmodule Plausible.Stats.Filters.Segments do
end
end
- @spec validate_segment_data(list()) :: {:ok, list()} | {:error, :segment_invalid}
+ @spec validate_segment_data(map()) :: {:ok, list()} | {:error, :segment_invalid}
def validate_segment_data(segment_data) do
with {:ok, filters} <- FiltersParser.parse_filters(segment_data["filters"]),
# segments are not permitted within segments
diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex
index 7b856fc9ec3f..bcf20de2e09c 100644
--- a/lib/plausible/stats/legacy/legacy_query_builder.ex
+++ b/lib/plausible/stats/legacy/legacy_query_builder.ex
@@ -19,6 +19,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
|> put_dimensions(params)
|> put_interval(params)
|> put_parsed_filters(params)
+ |> put_preloaded_segments(site)
|> put_preloaded_goals(site)
|> put_order_by(params)
|> Query.put_experimental_reduced_joins(site, params)
@@ -31,6 +32,16 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
query
end
+ defp put_preloaded_segments(query, site) do
+ preloaded_segments =
+ Plausible.Stats.Filters.QueryParser.preload_needed_segments(
+ site,
+ query.filters
+ )
+
+ struct!(query, preloaded_segments: preloaded_segments)
+ end
+
defp put_preloaded_goals(query, site) do
{preloaded_goals, revenue_currencies} =
Plausible.Stats.Filters.QueryParser.preload_needed_goals(
diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex
index 1624e193e43b..b372d0d9293b 100644
--- a/lib/plausible/stats/query.ex
+++ b/lib/plausible/stats/query.ex
@@ -19,6 +19,7 @@ defmodule Plausible.Stats.Query do
legacy_breakdown: false,
remove_unavailable_revenue_metrics: false,
preloaded_goals: [],
+ preloaded_segments: [],
revenue_currencies: %{},
include: Plausible.Stats.Filters.QueryParser.default_include(),
debug_metadata: %{},
diff --git a/lib/plausible/stats/query_optimizer.ex b/lib/plausible/stats/query_optimizer.ex
index 34ab63ccfc88..555628532c2c 100644
--- a/lib/plausible/stats/query_optimizer.ex
+++ b/lib/plausible/stats/query_optimizer.ex
@@ -17,8 +17,8 @@ defmodule Plausible.Stats.QueryOptimizer do
5. Removes revenue metrics from dashboard queries if not requested, present or unavailable for the site.
"""
- def optimize(query, opts \\ []) do
- Enum.reduce(pipeline(), query, fn step, acc -> step.(acc, opts) end)
+ def optimize(query) do
+ Enum.reduce(pipeline(), query, fn step, acc -> step.(acc) end)
end
@doc """
@@ -46,16 +46,16 @@ defmodule Plausible.Stats.QueryOptimizer do
defp pipeline() do
[
- &expand_segments_to_filters/2,
- &update_group_by_time/2,
- &add_missing_order_by/2,
- &update_time_in_order_by/2,
- &extend_hostname_filters_to_visit/2,
- &remove_revenue_metrics_if_unavailable/2
+ &expand_segments_to_filters/1,
+ &update_group_by_time/1,
+ &add_missing_order_by/1,
+ &update_time_in_order_by/1,
+ &extend_hostname_filters_to_visit/1,
+ &remove_revenue_metrics_if_unavailable/1
]
end
- defp add_missing_order_by(%Query{order_by: nil} = query, _opts) do
+ defp add_missing_order_by(%Query{order_by: nil} = query) do
order_by =
case time_dimension(query) do
nil -> [{hd(query.metrics), :desc}]
@@ -65,13 +65,12 @@ defmodule Plausible.Stats.QueryOptimizer do
%Query{query | order_by: order_by}
end
- defp add_missing_order_by(query, _opts), do: query
+ defp add_missing_order_by(query), do: query
defp update_group_by_time(
%Query{
utc_time_range: %DateTimeRange{first: first, last: last}
- } = query,
- _opts
+ } = query
) do
dimensions =
query.dimensions
@@ -83,7 +82,7 @@ defmodule Plausible.Stats.QueryOptimizer do
%Query{query | dimensions: dimensions}
end
- defp update_group_by_time(query, _opts), do: query
+ defp update_group_by_time(query), do: query
defp resolve_time_dimension(first, last) do
cond do
@@ -94,7 +93,7 @@ defmodule Plausible.Stats.QueryOptimizer do
end
end
- defp update_time_in_order_by(query, _opts) do
+ defp update_time_in_order_by(query) do
order_by =
query.order_by
|> Enum.map(fn
@@ -120,7 +119,7 @@ defmodule Plausible.Stats.QueryOptimizer do
# To avoid showing referrers across hostnames when event:hostname
# filter is present for breakdowns, add entry/exit page hostname
# filters
- defp extend_hostname_filters_to_visit(query, _opts) do
+ defp extend_hostname_filters_to_visit(query) do
# Note: Only works since event:hostname is only allowed as a top level filter
hostname_filters =
query.filters
@@ -178,15 +177,12 @@ defmodule Plausible.Stats.QueryOptimizer do
)
end
- defp expand_segments_to_filters(query, opts) do
- if Filters.Segments.has_segment_filters?(query.filters) do
- get_available_segments = Keyword.fetch!(opts, :get_available_segments)
- available_segments = get_available_segments.()
-
+ defp expand_segments_to_filters(query) do
+ if length(query.preloaded_segments) > 0 do
filters =
Filters.Segments.expand_segments_to_constituent_filters(
query.filters,
- available_segments
+ query.preloaded_segments
)
%Query{query | filters: filters}
@@ -196,7 +192,7 @@ defmodule Plausible.Stats.QueryOptimizer do
end
on_ee do
- defp remove_revenue_metrics_if_unavailable(query, _opts) do
+ defp remove_revenue_metrics_if_unavailable(query) do
if query.remove_unavailable_revenue_metrics and map_size(query.revenue_currencies) == 0 do
Query.set(query,
metrics: query.metrics -- Plausible.Stats.Goal.Revenue.revenue_metrics()
@@ -206,6 +202,6 @@ defmodule Plausible.Stats.QueryOptimizer do
end
end
else
- defp remove_revenue_metrics_if_unavailable(query, _opts), do: query
+ defp remove_revenue_metrics_if_unavailable(query), do: query
end
end
diff --git a/test/plausible/stats/query_parser_test.exs b/test/plausible/stats/query_parser_test.exs
index 90abf4700215..5a2e0a63d19d 100644
--- a/test/plausible/stats/query_parser_test.exs
+++ b/test/plausible/stats/query_parser_test.exs
@@ -49,7 +49,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
assert {:ok, result} = parse(site, schema_type, params, @now)
return_value = Map.take(result, [:preloaded_goals, :revenue_currencies])
- result = Map.drop(result, [:preloaded_goals, :revenue_currencies])
+ result = Map.drop(result, [:preloaded_goals, :preloaded_segments, :revenue_currencies])
assert result == expected_result
return_value