mirror of
https://github.com/plausible/analytics.git
synced 2024-11-22 02:27:57 +03:00
Squashed saved segments
This commit is contained in:
parent
cf4ba664ed
commit
32107cfbb5
@ -57,8 +57,15 @@ if (container && container.dataset) {
|
||||
<ThemeContextProvider>
|
||||
<SiteContextProvider site={site}>
|
||||
<UserContextProvider
|
||||
role={container.dataset.currentUserRole as Role}
|
||||
loggedIn={container.dataset.loggedIn === 'true'}
|
||||
user={
|
||||
container.dataset.loggedIn === 'true'
|
||||
? {
|
||||
loggedIn: true,
|
||||
role: container.dataset.currentUserRole! as Role,
|
||||
id: parseInt(container.dataset.currentUserId!, 10)
|
||||
}
|
||||
: { loggedIn: false, role: null, id: null }
|
||||
}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</UserContextProvider>
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
AppNavigationLink,
|
||||
AppNavigationTarget
|
||||
} from '../navigation/use-app-navigate'
|
||||
import { NavigateOptions } from 'react-router-dom'
|
||||
|
||||
export const ToggleDropdownButton = forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -129,22 +130,43 @@ export const DropdownNavigationLink = ({
|
||||
children,
|
||||
active,
|
||||
className,
|
||||
actions,
|
||||
path,
|
||||
params,
|
||||
search,
|
||||
navigateOptions,
|
||||
onLinkClick,
|
||||
...props
|
||||
}: AppNavigationTarget & {
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<AppNavigationLink
|
||||
{...props}
|
||||
navigateOptions?: NavigateOptions
|
||||
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
active?: boolean
|
||||
onLinkClick?: () => void
|
||||
actions?: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
{ 'font-bold': !!active },
|
||||
'flex items-center justify-between',
|
||||
`px-4 py-2 text-sm leading-tight hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100`
|
||||
'text-sm leading-tight hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100',
|
||||
!!actions && 'pr-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AppNavigationLink>
|
||||
<AppNavigationLink
|
||||
className={classNames(
|
||||
'flex items-center justify-between w-full py-2',
|
||||
actions ? 'pl-4' : 'px-4'
|
||||
)}
|
||||
path={path}
|
||||
params={params}
|
||||
search={search}
|
||||
onClick={onLinkClick}
|
||||
{...navigateOptions}
|
||||
>
|
||||
{children}
|
||||
</AppNavigationLink>
|
||||
{!!actions && actions}
|
||||
</div>
|
||||
)
|
||||
|
@ -21,9 +21,5 @@ const ClearFiltersKeybind = () => (
|
||||
)
|
||||
|
||||
export function DashboardKeybinds() {
|
||||
return (
|
||||
<>
|
||||
<ClearFiltersKeybind />
|
||||
</>
|
||||
)
|
||||
return <>{false && <ClearFiltersKeybind />}</> // temp disable
|
||||
}
|
||||
|
@ -201,7 +201,7 @@ function ComparisonMenu({
|
||||
<DropdownNavigationLink
|
||||
active={query.comparison === ComparisonMode.custom}
|
||||
search={(s) => s}
|
||||
onClick={toggleCompareMenuCalendar}
|
||||
onLinkClick={toggleCompareMenuCalendar}
|
||||
>
|
||||
{COMPARISON_MODES[ComparisonMode.custom]}
|
||||
</DropdownNavigationLink>
|
||||
@ -250,7 +250,7 @@ function QueryPeriodsMenu({
|
||||
key={label}
|
||||
active={isActive({ site, query })}
|
||||
search={search}
|
||||
onClick={onClick || closeMenu}
|
||||
onLinkClick={onClick || closeMenu}
|
||||
>
|
||||
{label}
|
||||
{!!keyboardKey && <KeybindHint>{keyboardKey}</KeybindHint>}
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
import { PlausibleSite, useSiteContext } from '../site-context'
|
||||
import { filterRoute } from '../router'
|
||||
import { useOnClickOutside } from '../util/use-on-click-outside'
|
||||
import { SegmentsList } from '../segments/segments-dropdown'
|
||||
|
||||
export function getFilterListItems({
|
||||
propsAvailable
|
||||
@ -26,7 +27,7 @@ export function getFilterListItems({
|
||||
keyof typeof FILTER_MODAL_TO_FILTER_GROUP
|
||||
>
|
||||
const keysToOmit: Array<keyof typeof FILTER_MODAL_TO_FILTER_GROUP> =
|
||||
propsAvailable ? [] : ['props']
|
||||
propsAvailable ? ['segment'] : ['segment', 'props']
|
||||
return allKeys
|
||||
.filter((k) => !keysToOmit.includes(k))
|
||||
.map((modalKey) => ({ modalKey, label: formatFilterGroup(modalKey) }))
|
||||
@ -63,9 +64,11 @@ export const FilterMenu = () => {
|
||||
>
|
||||
{opened && (
|
||||
<DropdownMenuWrapper id="filter-menu" className="md:left-auto md:w-56">
|
||||
<SegmentsList closeList={() => setOpened(false)} />
|
||||
<DropdownLinkGroup>
|
||||
{filterListItems.map(({ modalKey, label }) => (
|
||||
<DropdownNavigationLink
|
||||
onLinkClick={() => setOpened(false)}
|
||||
active={false}
|
||||
key={modalKey}
|
||||
path={filterRoute.path}
|
||||
|
@ -76,17 +76,18 @@ export const FilterPillsList = React.forwardRef<
|
||||
}
|
||||
plainText={plainFilterText(query, filter)}
|
||||
key={index}
|
||||
onRemoveClick={() =>
|
||||
onRemoveClick={() => {
|
||||
const newFilters = query.filters.filter(
|
||||
(_, i) => i !== index + indexAdjustment
|
||||
)
|
||||
navigate({
|
||||
search: (search) => ({
|
||||
...search,
|
||||
filters: query.filters.filter(
|
||||
(_, i) => i !== index + indexAdjustment
|
||||
),
|
||||
labels: cleanLabels(query.filters, query.labels)
|
||||
filters: newFilters,
|
||||
labels: cleanLabels(newFilters, query.labels)
|
||||
})
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{styledFilterText(query, filter)}
|
||||
</FilterPill>
|
||||
|
@ -11,6 +11,10 @@ import {
|
||||
} from '../components/dropdown'
|
||||
import { FilterPillsList, PILL_X_GAP } from './filter-pills-list'
|
||||
import { useQueryContext } from '../query-context'
|
||||
import { SaveSegmentAction } from '../segments/segment-actions'
|
||||
import { EditingSegmentState, isSegmentFilter } from '../segments/segments'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useUserContext } from '../user-context'
|
||||
|
||||
const SEE_MORE_GAP_PX = 16
|
||||
const SEE_MORE_WIDTH_PX = 36
|
||||
@ -91,12 +95,34 @@ type VisibilityState = {
|
||||
}
|
||||
|
||||
export const FiltersBar = () => {
|
||||
const user = useUserContext();
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const pillsRef = useRef<HTMLDivElement>(null)
|
||||
const actionsRef = useRef<HTMLDivElement>(null)
|
||||
const seeMoreRef = useRef<HTMLDivElement>(null)
|
||||
const [visibility, setVisibility] = useState<null | VisibilityState>(null)
|
||||
const { query } = useQueryContext()
|
||||
const { state: locationState } = useLocation() as {
|
||||
state?: EditingSegmentState
|
||||
}
|
||||
const [editingSegment, setEditingSegment] = useState<
|
||||
null | EditingSegmentState['editingSegment']
|
||||
>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (locationState?.editingSegment) {
|
||||
setEditingSegment(locationState?.editingSegment)
|
||||
}
|
||||
if (locationState?.editingSegment === null) {
|
||||
setEditingSegment(null)
|
||||
}
|
||||
}, [locationState?.editingSegment])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!query.filters.length) {
|
||||
setEditingSegment(null)
|
||||
}
|
||||
}, [query.filters.length])
|
||||
|
||||
const [opened, setOpened] = useState(false)
|
||||
|
||||
@ -146,7 +172,7 @@ export const FiltersBar = () => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex w-full mt-4',
|
||||
'flex w-full mt-3',
|
||||
visibility === null && 'invisible' // hide until we've calculated the positions
|
||||
)}
|
||||
ref={containerRef}
|
||||
@ -198,6 +224,27 @@ export const FiltersBar = () => {
|
||||
</ToggleDropdownButton>
|
||||
)}
|
||||
<ClearAction />
|
||||
{user.loggedIn && editingSegment === null &&
|
||||
!query.filters.some((f) => isSegmentFilter(f)) && (
|
||||
<>
|
||||
<VerticalSeparator />
|
||||
<SaveSegmentAction options={[{ type: 'create segment' }]} />
|
||||
</>
|
||||
)}
|
||||
{user.loggedIn && editingSegment !== null && (
|
||||
<>
|
||||
<VerticalSeparator />
|
||||
<SaveSegmentAction
|
||||
options={[
|
||||
{
|
||||
type: 'update segment',
|
||||
segment: editingSegment
|
||||
},
|
||||
{ type: 'create segment' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -206,7 +253,7 @@ export const FiltersBar = () => {
|
||||
export const ClearAction = () => (
|
||||
<AppNavigationLink
|
||||
title="Clear all filters"
|
||||
className="w-9 text-gray-500 hover:text-indigo-700 dark:hover:text-indigo-500 flex items-center justify-center"
|
||||
className="px-1 text-gray-500 hover:text-indigo-700 dark:hover:text-indigo-500 flex items-center justify-center"
|
||||
search={(search) => ({
|
||||
...search,
|
||||
filters: null,
|
||||
@ -216,3 +263,9 @@ export const ClearAction = () => (
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</AppNavigationLink>
|
||||
)
|
||||
|
||||
const VerticalSeparator = () => {
|
||||
return (
|
||||
<div className="border-gray-300 dark:border-gray-500 border-1 border-l h-9"></div>
|
||||
)
|
||||
}
|
||||
|
253
assets/js/dashboard/segments/segment-actions.tsx
Normal file
253
assets/js/dashboard/segments/segment-actions.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
/** @format */
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useAppNavigate } from '../navigation/use-app-navigate'
|
||||
import { useQueryContext } from '../query-context'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { DashboardQuery } from '../query'
|
||||
import { useSiteContext } from '../site-context'
|
||||
import {
|
||||
cleanLabels,
|
||||
plainFilterText,
|
||||
remapToApiFilters
|
||||
} from '../util/filters'
|
||||
import {
|
||||
EditingSegmentState,
|
||||
formatSegmentIdAsLabelKey,
|
||||
parseApiSegmentData,
|
||||
SavedSegment,
|
||||
SegmentType
|
||||
} from './segments'
|
||||
import { CreateSegmentModal, UpdateSegmentModal } from './segment-modals'
|
||||
import { useUserContext } from '../user-context'
|
||||
|
||||
type M = 'create segment' | 'update segment'
|
||||
type O =
|
||||
| { type: 'create segment' }
|
||||
| { type: 'update segment'; segment: SavedSegment }
|
||||
|
||||
export const SaveSegmentAction = ({ options }: { options: O[] }) => {
|
||||
const user = useUserContext()
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
const [modal, setModal] = useState<M | null>(null)
|
||||
const navigate = useAppNavigate()
|
||||
const openCreateSegment = useCallback(() => {
|
||||
return setModal('create segment')
|
||||
}, [])
|
||||
const openUpdateSegment = useCallback(() => {
|
||||
return setModal('update segment')
|
||||
}, [])
|
||||
const close = useCallback(() => {
|
||||
return setModal(null)
|
||||
}, [])
|
||||
const queryClient = useQueryClient()
|
||||
const createSegment = useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
type,
|
||||
segment_data
|
||||
}: {
|
||||
name: string
|
||||
type: 'personal' | 'site'
|
||||
segment_data: {
|
||||
filters: DashboardQuery['filters']
|
||||
labels: DashboardQuery['labels']
|
||||
}
|
||||
}) => {
|
||||
return fetch(
|
||||
`/internal-api/${encodeURIComponent(site.domain)}/segments`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
type,
|
||||
segment_data: {
|
||||
filters: remapToApiFilters(segment_data.filters),
|
||||
labels: cleanLabels(segment_data.filters, segment_data.labels)
|
||||
}
|
||||
}),
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((d) => ({
|
||||
...d,
|
||||
segment_data: parseApiSegmentData(d.segment_data)
|
||||
}))
|
||||
},
|
||||
onSuccess: async (d) => {
|
||||
navigate({
|
||||
search: (search) => {
|
||||
const filters = [['is', 'segment', [d.id]]]
|
||||
const labels = cleanLabels(filters, {}, 'segment', {
|
||||
[formatSegmentIdAsLabelKey(d.id)]: d.name
|
||||
})
|
||||
return {
|
||||
...search,
|
||||
filters,
|
||||
labels
|
||||
}
|
||||
},
|
||||
state: { editingSegment: null } as EditingSegmentState
|
||||
})
|
||||
close()
|
||||
queryClient.invalidateQueries({ queryKey: ['segments'] })
|
||||
}
|
||||
})
|
||||
|
||||
const patchSegment = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
segment_data
|
||||
}: {
|
||||
id: number
|
||||
name?: string
|
||||
type?: SegmentType
|
||||
segment_data?: {
|
||||
filters: DashboardQuery['filters']
|
||||
labels: DashboardQuery['labels']
|
||||
}
|
||||
}) => {
|
||||
return fetch(
|
||||
`/internal-api/${encodeURIComponent(site.domain)}/segments/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
type,
|
||||
...(segment_data && {
|
||||
segment_data: {
|
||||
filters: remapToApiFilters(segment_data.filters),
|
||||
labels: cleanLabels(segment_data.filters, segment_data.labels)
|
||||
}
|
||||
})
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((d) => ({
|
||||
...d,
|
||||
segment_data: parseApiSegmentData(d.segment_data)
|
||||
}))
|
||||
},
|
||||
onSuccess: async (d) => {
|
||||
navigate({
|
||||
search: (search) => {
|
||||
const filters = [['is', 'segment', [d.id]]]
|
||||
const labels = cleanLabels(filters, {}, 'segment', {
|
||||
[formatSegmentIdAsLabelKey(d.id)]: d.name
|
||||
})
|
||||
return {
|
||||
...search,
|
||||
filters,
|
||||
labels
|
||||
}
|
||||
},
|
||||
state: { editingSegment: null } as EditingSegmentState
|
||||
})
|
||||
close()
|
||||
queryClient.invalidateQueries({ queryKey: ['segments'] })
|
||||
}
|
||||
})
|
||||
|
||||
if (!user.loggedIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
const segmentNamePlaceholder = query.filters.reduce(
|
||||
(combinedName, filter) =>
|
||||
combinedName.length > 100
|
||||
? combinedName
|
||||
: `${combinedName}${combinedName.length ? ' and ' : ''}${plainFilterText(query, filter)}`,
|
||||
''
|
||||
)
|
||||
|
||||
const option = options.find((o) => o.type === modal)
|
||||
const buttonClass =
|
||||
'whitespace-nowrap rounded font-semibold text-sm leading-tight p-2 h-9 text-gray-500 hover:text-indigo-700 dark:hover:text-indigo-500 disabled:cursor-not-allowed'
|
||||
return (
|
||||
<div className="flex gap-x-2">
|
||||
{options.map((o) => {
|
||||
if (o.type === 'create segment') {
|
||||
return (
|
||||
<button
|
||||
key={o.type}
|
||||
className={buttonClass}
|
||||
onClick={openCreateSegment}
|
||||
>
|
||||
{options.find((o) => o.type === 'update segment')
|
||||
? 'Save as new segment'
|
||||
: 'Save as segment'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (o.type === 'update segment') {
|
||||
const canEdit =
|
||||
(o.segment.type === SegmentType.personal &&
|
||||
o.segment.owner_id === user.id) ||
|
||||
(o.segment.type === SegmentType.site &&
|
||||
['admin', 'owner', 'super_admin'].includes(user.role))
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={!canEdit}
|
||||
key={o.type}
|
||||
className={buttonClass}
|
||||
onClick={openUpdateSegment}
|
||||
>
|
||||
Update segment
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{modal === 'create segment' && (
|
||||
<CreateSegmentModal
|
||||
canTogglePersonal={['admin', 'owner', 'super_admin'].includes(
|
||||
user.role
|
||||
)}
|
||||
segment={options.find((o) => o.type === 'update segment')?.segment}
|
||||
namePlaceholder={segmentNamePlaceholder}
|
||||
close={close}
|
||||
onSave={({ name, type }) =>
|
||||
createSegment.mutate({
|
||||
name,
|
||||
type,
|
||||
segment_data: {
|
||||
filters: query.filters,
|
||||
labels: query.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{option?.type === 'update segment' && (
|
||||
<UpdateSegmentModal
|
||||
canTogglePersonal={['admin', 'owner', 'super_admin'].includes(
|
||||
user.role
|
||||
)}
|
||||
segment={option.segment}
|
||||
namePlaceholder={segmentNamePlaceholder}
|
||||
close={close}
|
||||
onSave={({ id, name, type }) =>
|
||||
patchSegment.mutate({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
segment_data: {
|
||||
filters: query.filters,
|
||||
labels: query.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
194
assets/js/dashboard/segments/segment-modals.tsx
Normal file
194
assets/js/dashboard/segments/segment-modals.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
/** @format */
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import ModalWithRouting from '../stats/modals/modal'
|
||||
import classNames from 'classnames'
|
||||
import { SavedSegment, SegmentType } from './segments'
|
||||
|
||||
const buttonClass =
|
||||
'h-12 text-md font-medium py-2 px-3 rounded border dark:border-gray-100 dark:text-gray-100'
|
||||
|
||||
export const CreateSegmentModal = ({
|
||||
segment,
|
||||
close,
|
||||
onSave,
|
||||
canTogglePersonal,
|
||||
namePlaceholder
|
||||
}: {
|
||||
segment?: SavedSegment
|
||||
close: () => void
|
||||
onSave: (input: Pick<SavedSegment, 'name' | 'type'>) => void
|
||||
canTogglePersonal: boolean
|
||||
namePlaceholder: string
|
||||
}) => {
|
||||
const [name, setName] = useState(
|
||||
segment?.name ? `Copy of ${segment.name}` : ''
|
||||
)
|
||||
const [type, setType] = useState<SegmentType>(SegmentType.personal)
|
||||
|
||||
return (
|
||||
<ModalWithRouting maxWidth="460px" className="p-6 min-h-fit" close={close}>
|
||||
<h1 className="text-xl font-extrabold dark:text-gray-100">
|
||||
Create segment
|
||||
</h1>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block mt-2 text-md font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Segment name
|
||||
</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
// ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
id="name"
|
||||
className="block mt-2 p-2 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
|
||||
/>
|
||||
<div className="mt-1 text-sm">
|
||||
Add a name to your segment to make it easier to find
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<button
|
||||
className={classNames(
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full transition-colors ease-in-out duration-200 focus:outline-none focus:ring',
|
||||
type === SegmentType.personal
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: 'bg-indigo-600',
|
||||
!canTogglePersonal && 'cursor-not-allowed'
|
||||
)}
|
||||
onClick={
|
||||
canTogglePersonal
|
||||
? () =>
|
||||
setType((current) =>
|
||||
current === SegmentType.personal
|
||||
? SegmentType.site
|
||||
: SegmentType.personal
|
||||
)
|
||||
: () => {}
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
'inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200',
|
||||
type === SegmentType.personal ? 'translate-x-0' : 'translate-x-5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="ml-2 font-medium leading-5 text-sm text-gray-900 dark:text-gray-100">
|
||||
Show this segment for all site users
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 flex gap-x-2 items-center justify-end">
|
||||
<button className={buttonClass} onClick={close}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
const trimmedName = name.trim()
|
||||
const saveableName = trimmedName.length
|
||||
? trimmedName
|
||||
: namePlaceholder
|
||||
onSave({ name: saveableName, type })
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</ModalWithRouting>
|
||||
)
|
||||
}
|
||||
|
||||
export const UpdateSegmentModal = ({
|
||||
close,
|
||||
onSave,
|
||||
segment,
|
||||
canTogglePersonal,
|
||||
namePlaceholder
|
||||
}: {
|
||||
close: () => void
|
||||
onSave: (input: Pick<SavedSegment, 'id' | 'name' | 'type'>) => void
|
||||
segment: SavedSegment
|
||||
canTogglePersonal: boolean
|
||||
namePlaceholder: string
|
||||
}) => {
|
||||
const [name, setName] = useState(segment.name)
|
||||
const [type, setType] = useState<SegmentType>(segment.type)
|
||||
|
||||
return (
|
||||
<ModalWithRouting maxWidth="460px" className="p-6 min-h-fit" close={close}>
|
||||
<h1 className="text-xl font-extrabold dark:text-gray-100">
|
||||
Update segment
|
||||
</h1>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block mt-2 text-md font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Segment name
|
||||
</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
id="name"
|
||||
className="block mt-2 p-2 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
|
||||
/>
|
||||
<div className="mt-1 text-sm">
|
||||
Add a name to your segment to make it easier to find
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<button
|
||||
className={classNames(
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full transition-colors ease-in-out duration-200 focus:outline-none focus:ring',
|
||||
type === SegmentType.personal
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: 'bg-indigo-600',
|
||||
!canTogglePersonal && 'cursor-not-allowed'
|
||||
)}
|
||||
onClick={
|
||||
canTogglePersonal
|
||||
? () =>
|
||||
setType((current) =>
|
||||
current === SegmentType.personal
|
||||
? SegmentType.site
|
||||
: SegmentType.personal
|
||||
)
|
||||
: () => {}
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
'inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200',
|
||||
type === SegmentType.personal ? 'translate-x-0' : 'translate-x-5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className="ml-2 font-medium leading-5 text-sm text-gray-900 dark:text-gray-100">
|
||||
Show this segment for all site users
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 flex gap-x-2 items-center justify-end">
|
||||
<button className={buttonClass} onClick={close}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
const trimmedName = name.trim()
|
||||
const saveableName = trimmedName.length
|
||||
? trimmedName
|
||||
: namePlaceholder
|
||||
onSave({ id: segment.id, name: saveableName, type })
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</ModalWithRouting>
|
||||
)
|
||||
}
|
376
assets/js/dashboard/segments/segments-dropdown.tsx
Normal file
376
assets/js/dashboard/segments/segments-dropdown.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
/** @format */
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
DropdownLinkGroup,
|
||||
DropdownNavigationLink
|
||||
} from '../components/dropdown'
|
||||
import { useQueryContext } from '../query-context'
|
||||
import { useSiteContext } from '../site-context'
|
||||
import {
|
||||
EditingSegmentState,
|
||||
formatSegmentIdAsLabelKey,
|
||||
isSegmentFilter,
|
||||
parseApiSegmentData,
|
||||
SavedSegment,
|
||||
SegmentData,
|
||||
SegmentType
|
||||
} from './segments'
|
||||
import {
|
||||
QueryFunction,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient
|
||||
} from '@tanstack/react-query'
|
||||
import { cleanLabels } from '../util/filters'
|
||||
import { useAppNavigate } from '../navigation/use-app-navigate'
|
||||
import classNames from 'classnames'
|
||||
import { Tooltip } from '../util/tooltip'
|
||||
import { formatDayShort, parseUTCDate } from '../util/date'
|
||||
import { useUserContext } from '../user-context'
|
||||
|
||||
export const SegmentsList = ({ closeList }: { closeList: () => void }) => {
|
||||
// const user = useUserContext();
|
||||
const { query } = useQueryContext()
|
||||
const site = useSiteContext()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['segments'],
|
||||
placeholderData: (previousData) => previousData,
|
||||
queryFn: async () => {
|
||||
const response = await fetch(
|
||||
`/internal-api/${encodeURIComponent(site.domain)}/segments`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json'
|
||||
}
|
||||
}
|
||||
).then(
|
||||
(
|
||||
res
|
||||
): Promise<
|
||||
(SavedSegment & {
|
||||
owner_id: number
|
||||
inserted_at: string
|
||||
updated_at: string
|
||||
})[]
|
||||
> => res.json()
|
||||
)
|
||||
return response
|
||||
}
|
||||
})
|
||||
|
||||
const segmentFilter = query.filters.find(isSegmentFilter)
|
||||
const appliedSegmentIds = (segmentFilter ? segmentFilter[2] : []) as number[]
|
||||
|
||||
return (
|
||||
!!data?.length && (
|
||||
<DropdownLinkGroup>
|
||||
{data.map((s) => {
|
||||
const authorLabel = (() => {
|
||||
if (!site.members) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!s.owner_id || !site.members[s.owner_id]) {
|
||||
return '(Deleted User)'
|
||||
}
|
||||
|
||||
// if (s.owner_id === user.id) {
|
||||
// return 'You'
|
||||
// }
|
||||
|
||||
return site.members[s.owner_id]
|
||||
})()
|
||||
|
||||
const showUpdatedAt = s.updated_at !== s.inserted_at
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={s.id}
|
||||
info={
|
||||
<div>
|
||||
<div>
|
||||
{
|
||||
{
|
||||
[SegmentType.personal]: 'Personal segment',
|
||||
[SegmentType.site]: 'Segment'
|
||||
}[s.type]
|
||||
}
|
||||
</div>
|
||||
<div className="font-normal text-xs">
|
||||
{`Created at ${formatDayShort(parseUTCDate(s.inserted_at))}`}
|
||||
{!showUpdatedAt && !!authorLabel && ` by ${authorLabel}`}
|
||||
</div>
|
||||
{showUpdatedAt && (
|
||||
<div className="font-normal text-xs">
|
||||
{`Last updated at ${formatDayShort(parseUTCDate(s.updated_at))}`}
|
||||
{!!authorLabel && ` by ${authorLabel}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SegmentLink
|
||||
{...s}
|
||||
appliedSegmentIds={appliedSegmentIds}
|
||||
closeList={closeList}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</DropdownLinkGroup>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const SegmentLink = ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
owner_id,
|
||||
appliedSegmentIds,
|
||||
closeList
|
||||
}: SavedSegment & { appliedSegmentIds: number[]; closeList: () => void }) => {
|
||||
const user = useUserContext()
|
||||
const canSeeActions = user.loggedIn
|
||||
const canDeleteSegment =
|
||||
user.loggedIn &&
|
||||
((owner_id === user.id && type === SegmentType.personal) ||
|
||||
(type === SegmentType.site &&
|
||||
['admin', 'owner', 'super_admin'].includes(user.role)))
|
||||
const site = useSiteContext()
|
||||
const { query } = useQueryContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queryKey = ['segments', id] as const
|
||||
|
||||
const getSegmentFn: QueryFunction<
|
||||
{ segment_data: SegmentData } & SavedSegment,
|
||||
typeof queryKey
|
||||
> = async ({ queryKey: [_, id] }) => {
|
||||
const res = await fetch(
|
||||
`/internal-api/${encodeURIComponent(site.domain)}/segments/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
const d = await res.json()
|
||||
return {
|
||||
...d,
|
||||
segment_data: parseApiSegmentData(d.segment_data)
|
||||
}
|
||||
}
|
||||
|
||||
const navigate = useAppNavigate()
|
||||
|
||||
const getSegment = useQuery({
|
||||
enabled: false,
|
||||
queryKey: queryKey,
|
||||
queryFn: getSegmentFn
|
||||
})
|
||||
|
||||
const prefetchSegment = () =>
|
||||
queryClient.prefetchQuery({
|
||||
queryKey,
|
||||
queryFn: getSegmentFn,
|
||||
staleTime: 120_000
|
||||
})
|
||||
|
||||
const fetchSegment = () =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey,
|
||||
queryFn: getSegmentFn
|
||||
})
|
||||
|
||||
const editSegment = async () => {
|
||||
try {
|
||||
const data = getSegment.data ?? (await fetchSegment())
|
||||
navigate({
|
||||
search: (search) => ({
|
||||
...search,
|
||||
filters: data.segment_data.filters,
|
||||
labels: data.segment_data.labels
|
||||
}),
|
||||
state: {
|
||||
editingSegment: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
owner_id: data.owner_id
|
||||
}
|
||||
} as EditingSegmentState
|
||||
})
|
||||
closeList()
|
||||
} catch (_error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownNavigationLink
|
||||
key={id}
|
||||
active={appliedSegmentIds.includes(id)}
|
||||
onMouseEnter={prefetchSegment}
|
||||
navigateOptions={{
|
||||
state: { editingSegment: null } as EditingSegmentState
|
||||
}}
|
||||
search={(search) => {
|
||||
const otherFilters = query.filters.filter((f) => !isSegmentFilter(f))
|
||||
const updatedSegmentIds = appliedSegmentIds.includes(id)
|
||||
? appliedSegmentIds.filter((i) => i !== id)
|
||||
: [...appliedSegmentIds, id]
|
||||
|
||||
if (!updatedSegmentIds.length) {
|
||||
return {
|
||||
...search,
|
||||
filters: otherFilters,
|
||||
labels: cleanLabels(otherFilters, query.labels)
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFilters = [
|
||||
['is', 'segment', updatedSegmentIds],
|
||||
...otherFilters
|
||||
]
|
||||
|
||||
return {
|
||||
...search,
|
||||
filters: updatedFilters,
|
||||
labels: cleanLabels(updatedFilters, query.labels, 'segment', {
|
||||
[formatSegmentIdAsLabelKey(id)]: name
|
||||
})
|
||||
}
|
||||
}}
|
||||
actions={
|
||||
!canSeeActions ? null : (
|
||||
<>
|
||||
<EditSegment className="ml-2" onClick={editSegment} />
|
||||
<DeleteSegment
|
||||
disabled={!canDeleteSegment}
|
||||
className="ml-2"
|
||||
segment={{ id, name, type, owner_id }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</DropdownNavigationLink>
|
||||
)
|
||||
}
|
||||
|
||||
const EditSegment = ({
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
onClick: () => Promise<void>
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'block w-4 h-4 fill-current hover:fill-indigo-600',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<EditSegmentIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteSegment = ({
|
||||
disabled,
|
||||
className,
|
||||
segment
|
||||
}: {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
segment: SavedSegment
|
||||
}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const site = useSiteContext()
|
||||
const navigate = useAppNavigate()
|
||||
const { query } = useQueryContext()
|
||||
const deleteSegment = useMutation({
|
||||
mutationFn: (data: SavedSegment) => {
|
||||
return fetch(
|
||||
`/internal-api/${encodeURIComponent(site.domain)}/segments/${data.id}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((d) => ({
|
||||
...d,
|
||||
segment_data: parseApiSegmentData(d.segment_data)
|
||||
}))
|
||||
},
|
||||
onSuccess: (_d): void => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments'] })
|
||||
|
||||
const segmentFilterIndex = query.filters.findIndex(isSegmentFilter)
|
||||
if (segmentFilterIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filter = query.filters[segmentFilterIndex]
|
||||
const clauses = filter[2]
|
||||
const updatedSegmentIds = clauses.filter((c) => c !== segment.id)
|
||||
|
||||
if (updatedSegmentIds.length === clauses.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const newFilters = !updatedSegmentIds.length
|
||||
? query.filters.filter((_f, index) => index !== segmentFilterIndex)
|
||||
: [
|
||||
...query.filters.slice(0, segmentFilterIndex),
|
||||
[filter[0], filter[1], updatedSegmentIds],
|
||||
...query.filters.slice(segmentFilterIndex + 1)
|
||||
]
|
||||
|
||||
navigate({
|
||||
search: (s) => {
|
||||
return {
|
||||
...s,
|
||||
filters: newFilters,
|
||||
labels: cleanLabels(newFilters, query.labels)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
'block w-4 h-4 fill-current hover:fill-red-600',
|
||||
className
|
||||
)}
|
||||
title="Delete segment"
|
||||
onClick={disabled ? () => {} : () => deleteSegment.mutate(segment)}
|
||||
>
|
||||
<DeleteSegmentIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteSegmentIcon = () => (
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8535 12.1463C12.9 12.1927 12.9368 12.2479 12.962 12.3086C12.9871 12.3693 13.0001 12.4343 13.0001 12.5C13.0001 12.5657 12.9871 12.6308 12.962 12.6915C12.9368 12.7522 12.9 12.8073 12.8535 12.8538C12.8071 12.9002 12.7519 12.9371 12.6912 12.9622C12.6305 12.9874 12.5655 13.0003 12.4998 13.0003C12.4341 13.0003 12.369 12.9874 12.3083 12.9622C12.2476 12.9371 12.1925 12.9002 12.146 12.8538L7.99979 8.70691L3.85354 12.8538C3.75972 12.9476 3.63247 13.0003 3.49979 13.0003C3.36711 13.0003 3.23986 12.9476 3.14604 12.8538C3.05222 12.76 2.99951 12.6327 2.99951 12.5C2.99951 12.3674 3.05222 12.2401 3.14604 12.1463L7.29291 8.00003L3.14604 3.85378C3.05222 3.75996 2.99951 3.63272 2.99951 3.50003C2.99951 3.36735 3.05222 3.2401 3.14604 3.14628C3.23986 3.05246 3.36711 2.99976 3.49979 2.99976C3.63247 2.99976 3.75972 3.05246 3.85354 3.14628L7.99979 7.29316L12.146 3.14628C12.2399 3.05246 12.3671 2.99976 12.4998 2.99976C12.6325 2.99976 12.7597 3.05246 12.8535 3.14628C12.9474 3.2401 13.0001 3.36735 13.0001 3.50003C13.0001 3.63272 12.9474 3.75996 12.8535 3.85378L8.70666 8.00003L12.8535 12.1463Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const EditSegmentIcon = () => (
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.2075 4.58572L11.4144 1.79322C11.3215 1.70034 11.2113 1.62666 11.0899 1.57639C10.9686 1.52612 10.8385 1.50024 10.7072 1.50024C10.5759 1.50024 10.4458 1.52612 10.3245 1.57639C10.2031 1.62666 10.0929 1.70034 10 1.79322L2.29313 9.50009C2.19987 9.59262 2.12593 9.70275 2.0756 9.82411C2.02528 9.94546 1.99959 10.0756 2.00001 10.207V13.0001C2.00001 13.2653 2.10536 13.5197 2.2929 13.7072C2.48043 13.8947 2.73479 14.0001 3 14.0001H13.5C13.6326 14.0001 13.7598 13.9474 13.8536 13.8536C13.9473 13.7599 14 13.6327 14 13.5001C14 13.3675 13.9473 13.2403 13.8536 13.1465C13.7598 13.0528 13.6326 13.0001 13.5 13.0001H7.2075L14.2075 6.00009C14.3004 5.90723 14.3741 5.79698 14.4243 5.67564C14.4746 5.5543 14.5005 5.42425 14.5005 5.29291C14.5005 5.16156 14.4746 5.03151 14.4243 4.91017C14.3741 4.78883 14.3004 4.67858 14.2075 4.58572ZM5.79313 13.0001H3V10.207L8.5 4.70697L11.2931 7.50009L5.79313 13.0001ZM12 6.79322L9.20751 4.00009L10.7075 2.50009L13.5 5.29322L12 6.79322Z" />
|
||||
</svg>
|
||||
)
|
46
assets/js/dashboard/segments/segments.ts
Normal file
46
assets/js/dashboard/segments/segments.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** @format */
|
||||
|
||||
import { Filter } from '../query'
|
||||
import { remapFromApiFilters } from '../util/filters'
|
||||
|
||||
export enum SegmentType {
|
||||
personal = 'personal',
|
||||
site = 'site'
|
||||
}
|
||||
|
||||
export type SavedSegment = {
|
||||
id: number
|
||||
name: string
|
||||
type: SegmentType
|
||||
owner_id: number
|
||||
}
|
||||
|
||||
export type SegmentData = {
|
||||
filters: Filter[]
|
||||
labels: Record<string, string>
|
||||
}
|
||||
|
||||
export type EditingSegmentState = {
|
||||
/** null means to definitively close the edit mode */
|
||||
editingSegment: SavedSegment | null
|
||||
}
|
||||
|
||||
const SEGMENT_LABEL_KEY_PREFIX = 'segment-'
|
||||
|
||||
export function isSegmentIdLabelKey(labelKey: string): boolean {
|
||||
return labelKey.startsWith(SEGMENT_LABEL_KEY_PREFIX)
|
||||
}
|
||||
|
||||
export function formatSegmentIdAsLabelKey(id: number): string {
|
||||
return `${SEGMENT_LABEL_KEY_PREFIX}${id}`
|
||||
}
|
||||
|
||||
export const isSegmentFilter = (f: Filter): boolean => f[1] === 'segment'
|
||||
|
||||
export const parseApiSegmentData = ({
|
||||
filters,
|
||||
...rest
|
||||
}: SegmentData): SegmentData => ({
|
||||
filters: remapFromApiFilters(filters),
|
||||
...rest
|
||||
})
|
@ -21,7 +21,8 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
||||
isDbip: dataset.isDbip === 'true',
|
||||
flags: JSON.parse(dataset.flags!),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!),
|
||||
shared: !!dataset.sharedLinkAuth
|
||||
shared: !!dataset.sharedLinkAuth,
|
||||
members: JSON.parse(dataset.members!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +53,8 @@ const siteContextDefaultValue = {
|
||||
isDbip: false,
|
||||
flags: {} as FeatureFlags,
|
||||
validIntervalsByPeriod: {} as Record<string, Array<string>>,
|
||||
shared: false
|
||||
shared: false,
|
||||
members: null as null | Record<number, string>
|
||||
}
|
||||
|
||||
export type PlausibleSite = typeof siteContextDefaultValue
|
||||
|
@ -261,7 +261,7 @@ export default class SiteSwitcher extends React.Component {
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className="origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg"
|
||||
className="origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg z-10"
|
||||
ref={(node) => (this.dropDownNode = node)}
|
||||
>
|
||||
<div className="rounded-md bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
|
||||
|
@ -15,6 +15,10 @@ import {
|
||||
import { apiPath } from '../../util/url'
|
||||
import { useQueryContext } from '../../query-context'
|
||||
import { useSiteContext } from '../../site-context'
|
||||
import {
|
||||
formatSegmentIdAsLabelKey,
|
||||
isSegmentFilter
|
||||
} from '../../segments/segments'
|
||||
|
||||
export default function FilterModalRow({ filter, labels, onUpdate }) {
|
||||
const { query } = useQueryContext()
|
||||
@ -27,16 +31,19 @@ export default function FilterModalRow({ filter, labels, onUpdate }) {
|
||||
value,
|
||||
label: getLabel(labels, filterKey, value)
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[filter, labels]
|
||||
[clauses, labels, filterKey]
|
||||
)
|
||||
|
||||
function onComboboxSelect(selection) {
|
||||
const newClauses = selection.map(({ value }) => value)
|
||||
const newLabels = Object.fromEntries(
|
||||
selection.map(({ label, value }) => [value, label])
|
||||
selection.map(({ label, value }) => {
|
||||
if (isSegmentFilter(filter)) {
|
||||
return [formatSegmentIdAsLabelKey(value), label]
|
||||
}
|
||||
return [value, label]
|
||||
})
|
||||
)
|
||||
|
||||
onUpdate([operation, filterKey, newClauses], newLabels)
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavigateKeybind } from '../../keybinding'
|
||||
import { Keybind } from '../../keybinding'
|
||||
import { rootRoute } from "../../router";
|
||||
import { useAppNavigate } from "../../navigation/use-app-navigate";
|
||||
import classNames from "classnames";
|
||||
|
||||
// This corresponds to the 'md' breakpoint on TailwindCSS.
|
||||
const MD_WIDTH = 768;
|
||||
@ -41,20 +42,13 @@ class Modal extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this.close()
|
||||
this.props.close()
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.setState({ viewport: window.innerWidth });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.props.navigate({
|
||||
path: rootRoute.path,
|
||||
search: (search) => search,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @description
|
||||
* Decide whether to set max-width, and if so, to what.
|
||||
@ -76,22 +70,18 @@ class Modal extends React.Component {
|
||||
|
||||
render() {
|
||||
return createPortal(
|
||||
<>
|
||||
<NavigateKeybind keyboardKey="Escape" type="keyup" navigateProps={{ path: rootRoute.path, search: (search) => search }} />
|
||||
<div className="modal is-open" onClick={this.props.onClick}>
|
||||
<div className="modal__overlay">
|
||||
<button className="modal__close"></button>
|
||||
<div
|
||||
ref={this.node}
|
||||
className="modal__container dark:bg-gray-800"
|
||||
style={this.getStyle()}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
<div className="modal is-open" onClick={this.props.onClick}>
|
||||
<div className="modal__overlay">
|
||||
<button className="modal__close"></button>
|
||||
<div
|
||||
ref={this.node}
|
||||
className={classNames("modal__container dark:bg-gray-800", this.props.className)}
|
||||
style={this.getStyle()}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
,
|
||||
</div>,
|
||||
document.getElementById("modal_root"),
|
||||
);
|
||||
}
|
||||
@ -99,5 +89,18 @@ class Modal extends React.Component {
|
||||
|
||||
export default function ModalWithRouting(props) {
|
||||
const navigate = useAppNavigate()
|
||||
return <Modal {...props} navigate={navigate} />
|
||||
const defaultCloseHandler = () =>
|
||||
navigate({ path: rootRoute.path, search: (s) => s })
|
||||
const closeHandler = props.close ?? defaultCloseHandler
|
||||
return (
|
||||
<>
|
||||
<Keybind keyboardKey="Escape" type="keyup" handler={closeHandler} />
|
||||
<Modal
|
||||
close={() =>
|
||||
navigate({ path: rootRoute.path, search: (search) => search })
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -8,28 +8,27 @@ export enum Role {
|
||||
}
|
||||
|
||||
const userContextDefaultValue = {
|
||||
role: Role.viewer,
|
||||
id: null,
|
||||
role: null,
|
||||
loggedIn: false
|
||||
}
|
||||
} as
|
||||
| { loggedIn: false; id: null; role: null }
|
||||
| { loggedIn: true; id: number; role: Role }
|
||||
|
||||
const UserContext = createContext(userContextDefaultValue)
|
||||
type UserContextValue = typeof userContextDefaultValue
|
||||
|
||||
const UserContext = createContext<UserContextValue>(userContextDefaultValue)
|
||||
|
||||
export const useUserContext = () => {
|
||||
return useContext(UserContext)
|
||||
}
|
||||
|
||||
export default function UserContextProvider({
|
||||
role,
|
||||
loggedIn,
|
||||
user,
|
||||
children
|
||||
}: {
|
||||
role: Role
|
||||
loggedIn: boolean
|
||||
user: UserContextValue
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<UserContext.Provider value={{ role, loggedIn }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
)
|
||||
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
|
||||
}
|
||||
|
@ -3,6 +3,10 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import * as api from '../api'
|
||||
import { useQueryContext } from '../query-context'
|
||||
import {
|
||||
formatSegmentIdAsLabelKey,
|
||||
isSegmentFilter
|
||||
} from '../segments/segments'
|
||||
|
||||
export const FILTER_MODAL_TO_FILTER_GROUP = {
|
||||
page: ['page', 'entry_page', 'exit_page'],
|
||||
@ -14,7 +18,8 @@ export const FILTER_MODAL_TO_FILTER_GROUP = {
|
||||
utm: ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
|
||||
goal: ['goal'],
|
||||
props: ['props'],
|
||||
hostname: ['hostname']
|
||||
hostname: ['hostname'],
|
||||
segment: ['segment']
|
||||
}
|
||||
|
||||
export const FILTER_GROUP_TO_MODAL_TYPE = Object.fromEntries(
|
||||
@ -76,9 +81,13 @@ const ESCAPED_PIPE = '\\|'
|
||||
export function getLabel(labels, filterKey, value) {
|
||||
if (['country', 'region', 'city'].includes(filterKey)) {
|
||||
return labels[value]
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
|
||||
if (isSegmentFilter(['is', filterKey, []])) {
|
||||
return labels[formatSegmentIdAsLabelKey(value)]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function getPropertyKeyFromFilterKey(filterKey) {
|
||||
@ -201,11 +210,18 @@ export function formatFilterGroup(filterGroup) {
|
||||
export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
|
||||
const filteredBy = Object.fromEntries(
|
||||
filters
|
||||
.flatMap(([_operation, filterKey, clauses]) =>
|
||||
['country', 'region', 'city'].includes(filterKey) ? clauses : []
|
||||
)
|
||||
.flatMap(([_operation, filterKey, clauses]) => {
|
||||
if (filterKey === 'segment') {
|
||||
return clauses.map(formatSegmentIdAsLabelKey)
|
||||
}
|
||||
if (['country', 'region', 'city'].includes(filterKey)) {
|
||||
return clauses
|
||||
}
|
||||
return []
|
||||
})
|
||||
.map((value) => [value, true])
|
||||
)
|
||||
|
||||
let result = { ...labels }
|
||||
for (const value in labels) {
|
||||
if (!filteredBy[value]) {
|
||||
@ -215,7 +231,7 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
|
||||
|
||||
if (
|
||||
mergedFilterKey &&
|
||||
['country', 'region', 'city'].includes(mergedFilterKey)
|
||||
['country', 'region', 'city', 'segment'].includes(mergedFilterKey)
|
||||
) {
|
||||
result = {
|
||||
...result,
|
||||
@ -226,21 +242,55 @@ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
|
||||
return result
|
||||
}
|
||||
|
||||
const NO_PREFIX_KEYS = new Set(['segment'])
|
||||
const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])
|
||||
const EVENT_PREFIX = 'event:'
|
||||
const VISIT_PREFIX = 'visit:'
|
||||
|
||||
function remapFilterKey(filterKey) {
|
||||
if (NO_PREFIX_KEYS.has(filterKey)) {
|
||||
return filterKey
|
||||
}
|
||||
if (EVENT_FILTER_KEYS.has(filterKey)) {
|
||||
return `${EVENT_PREFIX}${filterKey}`
|
||||
}
|
||||
return `${VISIT_PREFIX}${filterKey}`
|
||||
}
|
||||
|
||||
function remapApiFilterKey(apiFilterKey) {
|
||||
const isNoPrefixKey = NO_PREFIX_KEYS.has(apiFilterKey)
|
||||
|
||||
if (isNoPrefixKey) {
|
||||
return apiFilterKey
|
||||
}
|
||||
|
||||
const isEventKey = apiFilterKey.startsWith(EVENT_PREFIX)
|
||||
const isVisitKey = apiFilterKey.startsWith(VISIT_PREFIX)
|
||||
|
||||
if (isEventKey) {
|
||||
return apiFilterKey.substring(EVENT_PREFIX.length)
|
||||
}
|
||||
if (isVisitKey) {
|
||||
return apiFilterKey.substring(VISIT_PREFIX.length)
|
||||
}
|
||||
|
||||
return apiFilterKey // maybe throw?
|
||||
}
|
||||
|
||||
export function remapToApiFilters(filters) {
|
||||
return filters.map(([operation, filterKey, clauses]) => {
|
||||
return [operation, remapFilterKey(filterKey), clauses]
|
||||
})
|
||||
}
|
||||
|
||||
export function remapFromApiFilters(apiFilters) {
|
||||
return apiFilters.map(([operation, apiFilterKey, clauses]) => {
|
||||
return [operation, remapApiFilterKey(apiFilterKey), clauses]
|
||||
})
|
||||
}
|
||||
|
||||
export function serializeApiFilters(filters) {
|
||||
const apiFilters = filters.map(([operation, filterKey, clauses]) => {
|
||||
let apiFilterKey = `visit:${filterKey}`
|
||||
if (
|
||||
filterKey.startsWith(EVENT_PROPS_PREFIX) ||
|
||||
EVENT_FILTER_KEYS.has(filterKey)
|
||||
) {
|
||||
apiFilterKey = `event:${filterKey}`
|
||||
}
|
||||
return [operation, apiFilterKey, clauses]
|
||||
})
|
||||
|
||||
return JSON.stringify(apiFilters)
|
||||
return JSON.stringify(remapToApiFilters(filters))
|
||||
}
|
||||
|
||||
export function fetchSuggestions(apiPath, query, input, additionalFilter) {
|
||||
@ -291,7 +341,8 @@ export const formattedFilters = {
|
||||
page: 'Page',
|
||||
hostname: 'Hostname',
|
||||
entry_page: 'Entry Page',
|
||||
exit_page: 'Exit Page'
|
||||
exit_page: 'Exit Page',
|
||||
segment: 'Segment'
|
||||
}
|
||||
|
||||
export function parseLegacyFilter(filterKey, rawValue) {
|
||||
|
11
assets/js/types/query-api.d.ts
vendored
11
assets/js/types/query-api.d.ts
vendored
@ -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
|
||||
|
@ -40,7 +40,8 @@ export const TestContextProviders = ({
|
||||
isDbip: false,
|
||||
flags: {},
|
||||
validIntervalsByPeriod: {},
|
||||
shared: false
|
||||
shared: false,
|
||||
members: {1: "Test User"}
|
||||
}
|
||||
|
||||
const site = { ...defaultSite, ...siteOptions }
|
||||
@ -58,7 +59,7 @@ export const TestContextProviders = ({
|
||||
return (
|
||||
// <ThemeContextProvider> not interactive component, default value is suitable
|
||||
<SiteContextProvider site={site}>
|
||||
<UserContextProvider role={Role.admin} loggedIn={true}>
|
||||
<UserContextProvider user={{ role: Role.admin, loggedIn: true, id: 1 }}>
|
||||
<MemoryRouter
|
||||
basename={getRouterBasepath(site)}
|
||||
initialEntries={defaultInitialEntries}
|
||||
|
56
lib/plausible/helpers/list-traverse.ex
Normal file
56
lib/plausible/helpers/list-traverse.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule Plausible.Helpers.ListTraverse do
|
||||
@moduledoc """
|
||||
This module contains utility functions for parsing and validating lists of values.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Parses a list of values using a provided parser function.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `list`: A list of values to be parsed.
|
||||
- `parser_function`: A function that takes a single value and returns either
|
||||
`{:ok, result}` or `{:error, reason}`.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, parsed_list}` if all values are successfully parsed, where `parsed_list`
|
||||
is a list containing the results of applying `parser_function` to each value.
|
||||
- `{:error, reason}` if any value fails to parse, where `reason` is the error
|
||||
returned by the first failing `parser_function` call.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_list(["1", "2", "3"], &Integer.parse/1)
|
||||
{:ok, [1, 2, 3]}
|
||||
|
||||
iex> parse_list(["1", "not_a_number", "3"], &Integer.parse/1)
|
||||
{:error, :invalid}
|
||||
|
||||
"""
|
||||
@spec parse_list(list(), (any() -> {:ok, any()} | {:error, any()})) ::
|
||||
{:ok, list()} | {:error, any()}
|
||||
def parse_list(list, parser_function) do
|
||||
Enum.reduce_while(list, {:ok, []}, fn value, {:ok, results} ->
|
||||
case parser_function.(value) do
|
||||
{:ok, result} -> {:cont, {:ok, results ++ [result]}}
|
||||
{:error, _} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a list of values using a provided parser function.
|
||||
|
||||
Returns `:ok` if all values are valid, or `{:error, reason}` on first invalid value.
|
||||
"""
|
||||
@spec validate_list(list(), (any() -> :ok | {:error, any()})) :: :ok | {:error, any()}
|
||||
def validate_list(list, parser_function) do
|
||||
Enum.reduce_while(list, :ok, fn value, :ok ->
|
||||
case parser_function.(value) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, _} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
95
lib/plausible/segment.ex
Normal file
95
lib/plausible/segment.ex
Normal file
@ -0,0 +1,95 @@
|
||||
defmodule Plausible.Segment do
|
||||
@moduledoc """
|
||||
Schema for segments. Segments are saved filter combinations.
|
||||
"""
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@segment_types [:personal, :site]
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:id,
|
||||
:name,
|
||||
:type,
|
||||
:segment_data,
|
||||
:owner_id,
|
||||
:inserted_at,
|
||||
:updated_at
|
||||
]}
|
||||
|
||||
schema "segments" do
|
||||
field :name, :string
|
||||
field :type, Ecto.Enum, values: @segment_types
|
||||
field :segment_data, :map
|
||||
|
||||
# owner ID can be null (aka segment is dangling) when the original owner is deassociated from the site
|
||||
# the segment is dangling until another user edits it: the editor becomes the new owner
|
||||
belongs_to :owner, Plausible.Auth.User, foreign_key: :owner_id
|
||||
belongs_to :site, Plausible.Site
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(segment, attrs) do
|
||||
segment
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:segment_data,
|
||||
:site_id,
|
||||
:type,
|
||||
:owner_id
|
||||
])
|
||||
|> validate_required([:name, :segment_data, :site_id, :type, :owner_id])
|
||||
|> foreign_key_constraint(:site_id)
|
||||
|> foreign_key_constraint(:owner_id)
|
||||
|> validate_only_known_properties_present()
|
||||
|> validate_segment_data_filters()
|
||||
|> validate_segment_data_labels()
|
||||
end
|
||||
|
||||
defp validate_only_known_properties_present(changeset) do
|
||||
case get_field(changeset, :segment_data) do
|
||||
segment_data when is_map(segment_data) ->
|
||||
if Enum.any?(Map.keys(segment_data) -- ["filters", "labels"]) do
|
||||
add_error(
|
||||
changeset,
|
||||
:segment_data,
|
||||
"must not contain any other property except \"filters\" and \"labels\""
|
||||
)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_segment_data_filters(changeset) do
|
||||
case get_field(changeset, :segment_data) do
|
||||
%{"filters" => filters} when is_list(filters) and length(filters) > 0 ->
|
||||
changeset
|
||||
|
||||
_ ->
|
||||
add_error(
|
||||
changeset,
|
||||
:segment_data,
|
||||
"property \"filters\" must be an array with at least one member"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_segment_data_labels(changeset) do
|
||||
case get_field(changeset, :segment_data) do
|
||||
%{"labels" => labels} when not is_map(labels) ->
|
||||
add_error(changeset, :segment_data, "property \"labels\" must be map or nil")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
70
lib/plausible/segment/segments.md
Normal file
70
lib/plausible/segment/segments.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Saved segments
|
||||
|
||||
## Definitions
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Segment Owner** | Usually the user who authored the segment |
|
||||
| **Personal Segment** | A segment that has personal flag set as true and the user is the segment owner |
|
||||
| **Personal Segments of Other Users** | A segment that has personal flag set as true and the user is not the segment owner |
|
||||
| **Site Segment** | A segment that has personal flag set to false |
|
||||
| **Segment Contents** | A list of filters |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Public | Viewer | Admin | Owner | Super Admin |
|
||||
|------------|--------|--------|-------|-------|-------------|
|
||||
| Can view data filtered by any segment they know the ID of | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can see contents of any segment they know the ID of | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can make API requests filtered by any segment they know the ID of | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can create personal segments | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can see list of personal segments | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can edit personal segments | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can delete personal segments | | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can set personal segments to be site segments [$] | | | ✅ | ✅ | ✅ |
|
||||
| Can set site segments to be personal segments [$] | | | ✅ | ✅ | ✅ |
|
||||
| Can see list of site segments [$] | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Can edit site segments [$] | | | ✅ | ✅ | ✅ |
|
||||
| Can delete site segments [$] | | | ✅ | ✅ | ✅ |
|
||||
| Can list personal segments of other users | | | | | |
|
||||
| Can edit personal segments of other users | | | | | |
|
||||
| Can delete personal segments of other users | | | | | |
|
||||
|
||||
### Notes
|
||||
|
||||
* __[$]__: functionality available on Business plan or above
|
||||
|
||||
## Segment lifecycle
|
||||
|
||||
| Action | Outcome |
|
||||
|--------|---------|
|
||||
| A user* selects filters that constitute the segment, chooses name, chooses whether it's site segment or not*, clicks "update segment" | Segment created (with user as segment owner) |
|
||||
| A user* views the contents of an existing segment, chooses name, chooses whether it's site segment or not*, clicks "save as new segment" | Segment created (with user as segment owner) |
|
||||
| Segment owner* clicks edit segment, changes segment name or adds/removes/edits filters, chooses whether it's site segment or not*, clicks "update segment" | Segment updated |
|
||||
| Any user* except the segment owner opens the segment for editing and clicks save, with or without changes | Segment updated (with the user becoming the new segment owner) |
|
||||
| Segment owner* deletes segment | Segment deleted |
|
||||
| Any user* except the segment owner deletes segment | Segment deleted |
|
||||
| Site deleted | Segment deleted |
|
||||
| Segment owner is removed from site or deleted from Plausible | If personal segment, segment deleted; if site segment, nothing happens |
|
||||
| Any user* updates goal name, if site has any segments with "is goal ..." filters for that goal | Segment updated |
|
||||
| Plausible engineer updates filters schema in backwards incompatible way | Segment updated |
|
||||
|
||||
### Notes
|
||||
|
||||
__*__: if the user has that particular capability
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Constraints | Comment |
|
||||
|-------|------|-------------|---------|
|
||||
| :id | :bigint | null: false | |
|
||||
| :name | :string | null: false | |
|
||||
| :type | :enum | default: :personal, null: false | Possible values are :site, :personal. Needed to distinguish between segments that are supposed to be listed site-wide and ones that are listed only for author |
|
||||
| :segment_data | :map | null: false | Contains the filters array at "filters" key and the labels record at "labels" key |
|
||||
| :site_id | references(:sites) | on_delete: :delete_all, null: false | |
|
||||
| :owner_id | references(:users) | on_delete: :nothing, null: false | Used to display author info without repeating author name and email in the database |
|
||||
| timestamps() | | | Provides inserted_at, updated_at fields |
|
||||
|
||||
## API
|
||||
|
||||
[lib/plausible_web/router.ex](../../plausible_web/router.ex)
|
@ -47,6 +47,7 @@ defmodule Plausible.Site do
|
||||
has_many :invitations, Plausible.Auth.Invitation
|
||||
has_many :goals, Plausible.Goal, preload_order: [desc: :id]
|
||||
has_many :revenue_goals, Plausible.Goal, where: [currency: {:not, nil}]
|
||||
has_many :segments, Plausible.Segment, preload_order: [asc: :name]
|
||||
has_one :google_auth, GoogleAuth
|
||||
has_one :weekly_report, Plausible.Site.WeeklyReport
|
||||
has_one :monthly_report, Plausible.Site.MonthlyReport
|
||||
|
@ -160,6 +160,12 @@ defmodule Plausible.Stats.FilterSuggestions do
|
||||
|> wrap_suggestions()
|
||||
end
|
||||
|
||||
def filter_suggestions(site, _query, "segment", _filter_search) do
|
||||
Enum.map(Repo.preload(site, :segments).segments, fn segment ->
|
||||
%{value: segment.id, label: segment.name}
|
||||
end)
|
||||
end
|
||||
|
||||
def filter_suggestions(site, query, "prop_key", filter_search) do
|
||||
filter_query = if filter_search == nil, do: "%", else: "%#{filter_search}%"
|
||||
|
||||
|
@ -3,7 +3,7 @@ defmodule Plausible.Stats.Filters do
|
||||
A module for parsing filters used in stat queries.
|
||||
"""
|
||||
|
||||
alias Plausible.Stats.Filters.QueryParser
|
||||
alias Plausible.Stats.Filters.{FiltersParser}
|
||||
alias Plausible.Stats.Filters.{LegacyDashboardFilterParser, StatsAPIFilterParser}
|
||||
|
||||
@visit_props [
|
||||
@ -81,7 +81,7 @@ defmodule Plausible.Stats.Filters do
|
||||
do: LegacyDashboardFilterParser.parse_and_prefix(filters)
|
||||
|
||||
def parse(filters) when is_list(filters) do
|
||||
{:ok, parsed_filters} = QueryParser.parse_filters(filters)
|
||||
{:ok, parsed_filters} = FiltersParser.parse_filters(filters)
|
||||
parsed_filters
|
||||
end
|
||||
|
||||
@ -103,8 +103,8 @@ defmodule Plausible.Stats.Filters do
|
||||
|> Enum.map(fn {[_operator, dimension | _rest], _depth} -> dimension end)
|
||||
end
|
||||
|
||||
def filtering_on_dimension?(query, dimension) do
|
||||
dimension in dimensions_used_in_filters(query.filters)
|
||||
def filtering_on_dimension?(filters, dimension) do
|
||||
dimension in dimensions_used_in_filters(filters)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
134
lib/plausible/stats/filters/filters_parser.ex
Normal file
134
lib/plausible/stats/filters/filters_parser.ex
Normal file
@ -0,0 +1,134 @@
|
||||
defmodule Plausible.Stats.Filters.FiltersParser do
|
||||
@moduledoc """
|
||||
FiltersParser is the module to verify that filters array is in the expected format.
|
||||
"""
|
||||
|
||||
alias Plausible.Stats.Filters
|
||||
alias Plausible.Helpers.ListTraverse
|
||||
|
||||
@segment_filter_key "segment"
|
||||
def segment_filter_key(), do: @segment_filter_key
|
||||
|
||||
@filter_entry_operators [
|
||||
:is,
|
||||
:is_not,
|
||||
:matches,
|
||||
:matches_not,
|
||||
:matches_wildcard,
|
||||
:matches_wildcard_not,
|
||||
:contains,
|
||||
:contains_not
|
||||
]
|
||||
|
||||
@filter_tree_operators [:not, :and, :or]
|
||||
|
||||
def parse_filters(filters) when is_list(filters) do
|
||||
ListTraverse.parse_list(filters, &parse_filter/1)
|
||||
end
|
||||
|
||||
def parse_filters(_invalid_metrics), do: {:error, "Invalid filters passed."}
|
||||
|
||||
defp parse_filter(filter) do
|
||||
with {:ok, operator} <- parse_operator(filter),
|
||||
{:ok, second} <- parse_filter_second(operator, filter),
|
||||
{:ok, rest} <- parse_filter_rest(operator, filter) do
|
||||
{:ok, [operator, second | rest]}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_operator(["is" | _rest]), do: {:ok, :is}
|
||||
defp parse_operator(["is_not" | _rest]), do: {:ok, :is_not}
|
||||
defp parse_operator(["matches" | _rest]), do: {:ok, :matches}
|
||||
defp parse_operator(["matches_not" | _rest]), do: {:ok, :matches_not}
|
||||
defp parse_operator(["matches_wildcard" | _rest]), do: {:ok, :matches_wildcard}
|
||||
defp parse_operator(["matches_wildcard_not" | _rest]), do: {:ok, :matches_wildcard_not}
|
||||
defp parse_operator(["contains" | _rest]), do: {:ok, :contains}
|
||||
defp parse_operator(["contains_not" | _rest]), do: {:ok, :contains_not}
|
||||
defp parse_operator(["not" | _rest]), do: {:ok, :not}
|
||||
defp parse_operator(["and" | _rest]), do: {:ok, :and}
|
||||
defp parse_operator(["or" | _rest]), do: {:ok, :or}
|
||||
defp parse_operator(filter), do: {:error, "Unknown operator for filter '#{i(filter)}'."}
|
||||
|
||||
def parse_filter_second(:not, [_, filter | _rest]), do: parse_filter(filter)
|
||||
|
||||
def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or],
|
||||
do: parse_filters(filters)
|
||||
|
||||
def parse_filter_second(_operator, filter), do: parse_filter_key(filter)
|
||||
|
||||
defp parse_filter_key([_operator, filter_key | _rest] = filter) do
|
||||
parse_filter_key_string(filter_key, "Invalid filter '#{i(filter)}")
|
||||
end
|
||||
|
||||
defp parse_filter_key(filter), do: {:error, "Invalid filter '#{i(filter)}'."}
|
||||
|
||||
defp parse_filter_rest(operator, filter)
|
||||
when operator in @filter_entry_operators,
|
||||
do: parse_clauses_list(filter)
|
||||
|
||||
defp parse_filter_rest(operator, _filter)
|
||||
when operator in @filter_tree_operators,
|
||||
do: {:ok, []}
|
||||
|
||||
defp parse_clauses_list([operation, filter_key, list] = filter) when is_list(list) do
|
||||
all_strings? = Enum.all?(list, &is_binary/1)
|
||||
all_integers? = Enum.all?(list, &is_integer/1)
|
||||
|
||||
case {filter_key, all_strings?} do
|
||||
{"visit:city", false} when all_integers? ->
|
||||
{:ok, [list]}
|
||||
|
||||
{"visit:country", true} when operation in ["is", "is_not"] ->
|
||||
if Enum.all?(list, &(String.length(&1) == 2)) do
|
||||
{:ok, [list]}
|
||||
else
|
||||
{:error,
|
||||
"Invalid visit:country filter, visit:country needs to be a valid 2-letter country code."}
|
||||
end
|
||||
|
||||
{@segment_filter_key, false} when all_integers? ->
|
||||
{:ok, [list]}
|
||||
|
||||
{_, true} ->
|
||||
{:ok, [list]}
|
||||
|
||||
_ ->
|
||||
{:error, "Invalid filter '#{i(filter)}'."}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_clauses_list(filter), do: {:error, "Invalid filter '#{i(filter)}'"}
|
||||
|
||||
def parse_filter_key_string(filter_key, error_message \\ "") do
|
||||
case filter_key do
|
||||
"event:props:" <> property_name ->
|
||||
if String.length(property_name) > 0 do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
"event:" <> key ->
|
||||
if key in Filters.event_props() do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
"visit:" <> key ->
|
||||
if key in Filters.visit_props() do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
@segment_filter_key ->
|
||||
{:ok, filter_key}
|
||||
|
||||
_ ->
|
||||
{:error, error_message}
|
||||
end
|
||||
end
|
||||
|
||||
defp i(value), do: inspect(value, charlists: :as_lists)
|
||||
end
|
@ -4,6 +4,8 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
use Plausible
|
||||
|
||||
alias Plausible.Stats.{TableDecider, Filters, Metrics, DateTimeRange, JSONSchema, Time}
|
||||
alias Plausible.Stats.Filters.FiltersParser
|
||||
alias Plausible.Helpers.ListTraverse
|
||||
|
||||
@default_include %{
|
||||
imports: false,
|
||||
@ -33,13 +35,14 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
parse_time_range(site, Map.get(params, "date_range"), date, now),
|
||||
utc_time_range = raw_time_range |> DateTimeRange.to_timezone("Etc/UTC"),
|
||||
{:ok, metrics} <- parse_metrics(Map.get(params, "metrics", [])),
|
||||
{:ok, filters} <- parse_filters(Map.get(params, "filters", [])),
|
||||
{:ok, filters} <- FiltersParser.parse_filters(Map.get(params, "filters", [])),
|
||||
{:ok, dimensions} <- parse_dimensions(Map.get(params, "dimensions", [])),
|
||||
{:ok, order_by} <- parse_order_by(Map.get(params, "order_by")),
|
||||
{:ok, include} <- parse_include(site, Map.get(params, "include", %{})),
|
||||
{: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,
|
||||
@ -50,13 +53,15 @@ 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),
|
||||
:ok <- validate_toplevel_only_filter_dimension(query),
|
||||
:ok <- validate_special_metrics_filters(query),
|
||||
:ok <- validate_filtered_goals_exist(query),
|
||||
:ok <- validate_segments_allowed(site, query, %{}),
|
||||
:ok <- validate_revenue_metrics_access(site, query),
|
||||
:ok <- validate_metrics(query),
|
||||
:ok <- validate_include(query) do
|
||||
@ -73,7 +78,7 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
def parse_date_range_pair(_site, unknown), do: {:error, "Invalid date_range '#{i(unknown)}'."}
|
||||
|
||||
defp parse_metrics(metrics) when is_list(metrics) do
|
||||
parse_list(metrics, &parse_metric/1)
|
||||
ListTraverse.parse_list(metrics, &parse_metric/1)
|
||||
end
|
||||
|
||||
defp parse_metric(metric_str) do
|
||||
@ -83,89 +88,6 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
end
|
||||
end
|
||||
|
||||
def parse_filters(filters) when is_list(filters) do
|
||||
parse_list(filters, &parse_filter/1)
|
||||
end
|
||||
|
||||
def parse_filters(_invalid_metrics), do: {:error, "Invalid filters passed."}
|
||||
|
||||
defp parse_filter(filter) do
|
||||
with {:ok, operator} <- parse_operator(filter),
|
||||
{:ok, second} <- parse_filter_second(operator, filter),
|
||||
{:ok, rest} <- parse_filter_rest(operator, filter) do
|
||||
{:ok, [operator, second | rest]}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_operator(["is" | _rest]), do: {:ok, :is}
|
||||
defp parse_operator(["is_not" | _rest]), do: {:ok, :is_not}
|
||||
defp parse_operator(["matches" | _rest]), do: {:ok, :matches}
|
||||
defp parse_operator(["matches_not" | _rest]), do: {:ok, :matches_not}
|
||||
defp parse_operator(["matches_wildcard" | _rest]), do: {:ok, :matches_wildcard}
|
||||
defp parse_operator(["matches_wildcard_not" | _rest]), do: {:ok, :matches_wildcard_not}
|
||||
defp parse_operator(["contains" | _rest]), do: {:ok, :contains}
|
||||
defp parse_operator(["contains_not" | _rest]), do: {:ok, :contains_not}
|
||||
defp parse_operator(["not" | _rest]), do: {:ok, :not}
|
||||
defp parse_operator(["and" | _rest]), do: {:ok, :and}
|
||||
defp parse_operator(["or" | _rest]), do: {:ok, :or}
|
||||
defp parse_operator(filter), do: {:error, "Unknown operator for filter '#{i(filter)}'."}
|
||||
|
||||
def parse_filter_second(:not, [_, filter | _rest]), do: parse_filter(filter)
|
||||
|
||||
def parse_filter_second(operator, [_, filters | _rest]) when operator in [:and, :or],
|
||||
do: parse_filters(filters)
|
||||
|
||||
def parse_filter_second(_operator, filter), do: parse_filter_key(filter)
|
||||
|
||||
defp parse_filter_key([_operator, filter_key | _rest] = filter) do
|
||||
parse_filter_key_string(filter_key, "Invalid filter '#{i(filter)}")
|
||||
end
|
||||
|
||||
defp parse_filter_key(filter), do: {:error, "Invalid filter '#{i(filter)}'."}
|
||||
|
||||
defp parse_filter_rest(operator, filter)
|
||||
when operator in [
|
||||
:is,
|
||||
:is_not,
|
||||
:matches,
|
||||
:matches_not,
|
||||
:matches_wildcard,
|
||||
:matches_wildcard_not,
|
||||
:contains,
|
||||
:contains_not
|
||||
],
|
||||
do: parse_clauses_list(filter)
|
||||
|
||||
defp parse_filter_rest(operator, _filter)
|
||||
when operator in [:not, :and, :or],
|
||||
do: {:ok, []}
|
||||
|
||||
defp parse_clauses_list([operation, filter_key, list] = filter) when is_list(list) do
|
||||
all_strings? = Enum.all?(list, &is_binary/1)
|
||||
all_integers? = Enum.all?(list, &is_integer/1)
|
||||
|
||||
case {filter_key, all_strings?} do
|
||||
{"visit:city", false} when all_integers? ->
|
||||
{:ok, [list]}
|
||||
|
||||
{"visit:country", true} when operation in ["is", "is_not"] ->
|
||||
if Enum.all?(list, &(String.length(&1) == 2)) do
|
||||
{:ok, [list]}
|
||||
else
|
||||
{:error,
|
||||
"Invalid visit:country filter, visit:country needs to be a valid 2-letter country code."}
|
||||
end
|
||||
|
||||
{_, true} ->
|
||||
{:ok, [list]}
|
||||
|
||||
_ ->
|
||||
{:error, "Invalid filter '#{i(filter)}'."}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_clauses_list(filter), do: {:error, "Invalid filter '#{i(filter)}'"}
|
||||
|
||||
defp parse_date(_site, date_string, _date) when is_binary(date_string) do
|
||||
case Date.from_iso8601(date_string) do
|
||||
{:ok, date} -> {:ok, date}
|
||||
@ -273,14 +195,14 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
defp today(site), do: DateTime.now!(site.timezone) |> DateTime.to_date()
|
||||
|
||||
defp parse_dimensions(dimensions) when is_list(dimensions) do
|
||||
parse_list(
|
||||
ListTraverse.parse_list(
|
||||
dimensions,
|
||||
&parse_dimension_entry(&1, "Invalid dimensions '#{i(dimensions)}'")
|
||||
)
|
||||
end
|
||||
|
||||
defp parse_order_by(order_by) when is_list(order_by) do
|
||||
parse_list(order_by, &parse_order_by_entry/1)
|
||||
ListTraverse.parse_list(order_by, &parse_order_by_entry/1)
|
||||
end
|
||||
|
||||
defp parse_order_by(nil), do: {:ok, nil}
|
||||
@ -296,7 +218,7 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
defp parse_dimension_entry(key, error_message) do
|
||||
case {
|
||||
parse_time(key),
|
||||
parse_filter_key_string(key)
|
||||
FiltersParser.parse_filter_key_string(key)
|
||||
} do
|
||||
{{:ok, time}, _} -> {:ok, time}
|
||||
{_, {:ok, filter_key}} -> {:ok, filter_key}
|
||||
@ -308,7 +230,7 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
case {
|
||||
parse_time(value),
|
||||
parse_metric(value),
|
||||
parse_filter_key_string(value)
|
||||
FiltersParser.parse_filter_key_string(value)
|
||||
} do
|
||||
{{:ok, time}, _, _} -> {:ok, time}
|
||||
{_, {:ok, metric}, _} -> {:ok, metric}
|
||||
@ -360,34 +282,6 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
|
||||
defp atomize_keys(value), do: value
|
||||
|
||||
defp parse_filter_key_string(filter_key, error_message \\ "") do
|
||||
case filter_key do
|
||||
"event:props:" <> property_name ->
|
||||
if String.length(property_name) > 0 do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
"event:" <> key ->
|
||||
if key in Filters.event_props() do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
"visit:" <> key ->
|
||||
if key in Filters.visit_props() do
|
||||
{:ok, filter_key}
|
||||
else
|
||||
{:error, error_message}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, error_message}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_order_by(query) do
|
||||
if query.order_by do
|
||||
valid_values = query.metrics ++ query.dimensions
|
||||
@ -410,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)
|
||||
@ -455,6 +357,10 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_segments_allowed(_site, _query, _available_segments) do
|
||||
:ok
|
||||
end
|
||||
|
||||
defp validate_filtered_goals_exist(query) do
|
||||
# Note: Only works since event:goal is allowed as a top level filter
|
||||
goal_filter_clauses =
|
||||
@ -464,7 +370,10 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
end)
|
||||
|
||||
if length(goal_filter_clauses) > 0 do
|
||||
validate_list(goal_filter_clauses, &validate_goal_filter(&1, query.preloaded_goals))
|
||||
ListTraverse.validate_list(
|
||||
goal_filter_clauses,
|
||||
&validate_goal_filter(&1, query.preloaded_goals)
|
||||
)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
@ -527,14 +436,14 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
end
|
||||
|
||||
defp validate_metrics(query) do
|
||||
with :ok <- validate_list(query.metrics, &validate_metric(&1, query)) do
|
||||
with :ok <- ListTraverse.validate_list(query.metrics, &validate_metric(&1, query)) do
|
||||
validate_no_metrics_filters_conflict(query)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_metric(metric, query) when metric in [:conversion_rate, :group_conversion_rate] do
|
||||
if Enum.member?(query.dimensions, "event:goal") or
|
||||
Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
:ok
|
||||
else
|
||||
{:error, "Metric `#{metric}` can only be queried with event:goal filters or dimensions."}
|
||||
@ -543,7 +452,7 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
|
||||
defp validate_metric(:views_per_visit = metric, query) do
|
||||
cond do
|
||||
Filters.filtering_on_dimension?(query, "event:page") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:page") ->
|
||||
{:error, "Metric `#{metric}` cannot be queried with a filter on `event:page`."}
|
||||
|
||||
length(query.dimensions) > 0 ->
|
||||
@ -588,22 +497,4 @@ defmodule Plausible.Stats.Filters.QueryParser do
|
||||
end
|
||||
|
||||
defp i(value), do: inspect(value, charlists: :as_lists)
|
||||
|
||||
defp parse_list(list, parser_function) do
|
||||
Enum.reduce_while(list, {:ok, []}, fn value, {:ok, results} ->
|
||||
case parser_function.(value) do
|
||||
{:ok, result} -> {:cont, {:ok, results ++ [result]}}
|
||||
{:error, _} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp validate_list(list, parser_function) do
|
||||
Enum.reduce_while(list, :ok, fn value, :ok ->
|
||||
case parser_function.(value) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, _} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
72
lib/plausible/stats/filters/segments.ex
Normal file
72
lib/plausible/stats/filters/segments.ex
Normal file
@ -0,0 +1,72 @@
|
||||
defmodule Plausible.Stats.Filters.Segments do
|
||||
@moduledoc """
|
||||
Module containing the business logic of segments
|
||||
"""
|
||||
alias Plausible.Stats.Filters
|
||||
alias Plausible.Stats.Filters.FiltersParser
|
||||
|
||||
@spec has_segment_filters?(list()) :: boolean()
|
||||
def has_segment_filters?(filters),
|
||||
do: Filters.filtering_on_dimension?(filters, FiltersParser.segment_filter_key())
|
||||
|
||||
@spec expand_segments_to_constituent_filters(list(), list()) ::
|
||||
list()
|
||||
def expand_segments_to_constituent_filters(filters, segments) do
|
||||
case segment_filter_index = find_top_level_segment_filter_index(filters) do
|
||||
nil ->
|
||||
filters
|
||||
|
||||
_ ->
|
||||
{head, [segment_filter | tail]} = Enum.split(filters, segment_filter_index)
|
||||
[_operator, _filter_key, segment_id_clauses] = segment_filter
|
||||
|
||||
expanded_filters =
|
||||
Enum.concat(
|
||||
Enum.map(segment_id_clauses, fn segment_id ->
|
||||
with {:ok, segment_data} <- get_segment_data(segments, segment_id),
|
||||
{:ok, %{filters: filters}} <-
|
||||
validate_segment_data(segment_data) do
|
||||
filters
|
||||
else
|
||||
{:error, :segment_not_found} ->
|
||||
raise "Segment not found with id #{inspect(segment_id)}."
|
||||
|
||||
{:error, :segment_invalid} ->
|
||||
raise "Segment invalid with id #{inspect(segment_id)}."
|
||||
end
|
||||
end)
|
||||
)
|
||||
|
||||
head ++ expanded_filters ++ tail
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_top_level_segment_filter_index(list()) :: non_neg_integer() | nil
|
||||
defp find_top_level_segment_filter_index(filters) do
|
||||
Enum.find_index(filters, fn filter ->
|
||||
case filter do
|
||||
[_first, second, _third] -> second == FiltersParser.segment_filter_key()
|
||||
_ -> false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@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}
|
||||
%Plausible.Segment{segment_data: segment_data} -> {:ok, segment_data}
|
||||
end
|
||||
end
|
||||
|
||||
@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
|
||||
false <- has_segment_filters?(filters) do
|
||||
{:ok, %{filters: filters}}
|
||||
else
|
||||
_ -> {:error, :segment_invalid}
|
||||
end
|
||||
end
|
||||
end
|
@ -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)
|
||||
|> put_include_comparisons(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(
|
||||
|
@ -17,6 +17,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: %{},
|
||||
|
@ -7,7 +7,7 @@ defmodule Plausible.Stats.QueryOptimizer do
|
||||
alias Plausible.Stats.{DateTimeRange, Filters, Query, TableDecider, Util, Time}
|
||||
|
||||
@doc """
|
||||
This module manipulates an existing query, updating it according to business logic.
|
||||
This method manipulates an existing query, updating it according to business logic.
|
||||
|
||||
For example, it:
|
||||
1. Figures out what the right granularity to group by time is
|
||||
@ -46,6 +46,7 @@ defmodule Plausible.Stats.QueryOptimizer do
|
||||
|
||||
defp pipeline() do
|
||||
[
|
||||
&expand_segments_to_filters/1,
|
||||
&update_group_by_time/1,
|
||||
&add_missing_order_by/1,
|
||||
&update_time_in_order_by/1,
|
||||
@ -176,6 +177,20 @@ defmodule Plausible.Stats.QueryOptimizer do
|
||||
)
|
||||
end
|
||||
|
||||
defp expand_segments_to_filters(query) do
|
||||
if length(query.preloaded_segments) > 0 do
|
||||
filters =
|
||||
Filters.Segments.expand_segments_to_constituent_filters(
|
||||
query.filters,
|
||||
query.preloaded_segments
|
||||
)
|
||||
|
||||
%Query{query | filters: filters}
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
defp remove_revenue_metrics_if_unavailable(query) do
|
||||
if query.remove_unavailable_revenue_metrics and map_size(query.revenue_currencies) == 0 do
|
||||
|
@ -165,10 +165,10 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
|
||||
|
||||
defp validate_metric("time_on_page" = metric, query) do
|
||||
cond do
|
||||
Filters.filtering_on_dimension?(query, "event:goal") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:goal") ->
|
||||
{:error, "Metric `#{metric}` cannot be queried when filtering by `event:goal`"}
|
||||
|
||||
Filters.filtering_on_dimension?(query, "event:name") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:name") ->
|
||||
{:error, "Metric `#{metric}` cannot be queried when filtering by `event:name`"}
|
||||
|
||||
query.dimensions == ["event:page"] ->
|
||||
@ -178,7 +178,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
|
||||
{:error,
|
||||
"Metric `#{metric}` is not supported in breakdown queries (except `event:page` breakdown)"}
|
||||
|
||||
Filters.filtering_on_dimension?(query, "event:page") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:page") ->
|
||||
{:ok, metric}
|
||||
|
||||
true ->
|
||||
@ -192,7 +192,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
|
||||
query.dimensions == ["event:goal"] ->
|
||||
{:ok, metric}
|
||||
|
||||
Filters.filtering_on_dimension?(query, "event:goal") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:goal") ->
|
||||
{:ok, metric}
|
||||
|
||||
true ->
|
||||
@ -207,7 +207,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
|
||||
|
||||
defp validate_metric("views_per_visit" = metric, query) do
|
||||
cond do
|
||||
Filters.filtering_on_dimension?(query, "event:page") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:page") ->
|
||||
{:error, "Metric `#{metric}` cannot be queried with a filter on `event:page`."}
|
||||
|
||||
not Enum.empty?(query.dimensions) ->
|
||||
|
@ -8,6 +8,13 @@ defmodule PlausibleWeb.Api.Helpers do
|
||||
|> halt()
|
||||
end
|
||||
|
||||
def not_enough_permissions(conn, msg) do
|
||||
conn
|
||||
|> put_status(403)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
def bad_request(conn, msg) do
|
||||
conn
|
||||
|> put_status(400)
|
||||
|
@ -0,0 +1,146 @@
|
||||
defmodule PlausibleWeb.Api.Internal.SegmentsController do
|
||||
use Plausible
|
||||
use PlausibleWeb, :controller
|
||||
use Plausible.Repo
|
||||
use PlausibleWeb.Plugs.ErrorHandler
|
||||
alias PlausibleWeb.Api.Helpers, as: H
|
||||
|
||||
defp normalize_segment_id_param(input) do
|
||||
case Integer.parse(input) do
|
||||
{int_value, ""} -> int_value
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_one_segment(_user_id, _site_id, nil) do
|
||||
nil
|
||||
end
|
||||
|
||||
defp get_one_segment(user_id, site_id, segment_id) do
|
||||
query =
|
||||
from(segment in Plausible.Segment,
|
||||
where: segment.site_id == ^site_id,
|
||||
where: segment.id == ^segment_id,
|
||||
where: segment.type == :site or segment.owner_id == ^user_id
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
defp get_index_query(user_id, site_id) do
|
||||
fields_in_index = [
|
||||
:id,
|
||||
:name,
|
||||
:type,
|
||||
:inserted_at,
|
||||
:updated_at,
|
||||
:owner_id
|
||||
]
|
||||
|
||||
from(segment in Plausible.Segment,
|
||||
select: ^fields_in_index,
|
||||
where: segment.site_id == ^site_id,
|
||||
where: segment.type == :site or segment.owner_id == ^user_id,
|
||||
order_by: [asc: segment.name]
|
||||
)
|
||||
end
|
||||
|
||||
defp has_capability_to_toggle_site_segment?(current_user_role) do
|
||||
current_user_role in [:admin, :owner, :super_admin]
|
||||
end
|
||||
|
||||
def get_all_segments(conn, _params) do
|
||||
site_id = conn.assigns.site.id
|
||||
user_id = if is_nil(conn.assigns[:current_user]) do 0 else conn.assigns.current_user.id end
|
||||
|
||||
result = Repo.all(get_index_query(user_id, site_id))
|
||||
|
||||
json(conn, result)
|
||||
end
|
||||
|
||||
def get_segment(conn, params) do
|
||||
site_id = conn.assigns.site.id
|
||||
user_id = if is_nil(conn.assigns[:current_user]) do 0 else conn.assigns.current_user.id end
|
||||
segment_id = normalize_segment_id_param(params["segment_id"])
|
||||
|
||||
result = get_one_segment(user_id, site_id, segment_id)
|
||||
|
||||
case result do
|
||||
nil -> H.not_found(conn, "Segment not found with ID #{inspect(params["segment_id"])}")
|
||||
%{} -> json(conn, result)
|
||||
end
|
||||
end
|
||||
|
||||
def create_segment(conn, params) do
|
||||
user_id = conn.assigns.current_user.id
|
||||
site_id = conn.assigns.site.id
|
||||
|
||||
segment_definition =
|
||||
Map.merge(params, %{"site_id" => site_id, "owner_id" => user_id})
|
||||
|
||||
changeset = Plausible.Segment.changeset(%Plausible.Segment{}, segment_definition)
|
||||
|
||||
if changeset.changes.type == :site and
|
||||
not has_capability_to_toggle_site_segment?(conn.assigns.current_user_role) do
|
||||
H.not_enough_permissions(conn, "Not enough permissions to create site segments")
|
||||
else
|
||||
result = Repo.insert(changeset)
|
||||
|
||||
case result do
|
||||
{:ok, segment} ->
|
||||
json(conn, segment)
|
||||
|
||||
{:error, _} ->
|
||||
H.bad_request(conn, "Failed to create segment")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_segment(conn, params) do
|
||||
user_id = conn.assigns.current_user.id
|
||||
site_id = conn.assigns.site.id
|
||||
segment_id = normalize_segment_id_param(params["segment_id"])
|
||||
|
||||
if not is_nil(params["type"]) and
|
||||
not has_capability_to_toggle_site_segment?(conn.assigns.current_user_role) do
|
||||
H.not_enough_permissions(conn, "Not enough permissions to set segment visibility")
|
||||
else
|
||||
existing_segment = get_one_segment(user_id, site_id, segment_id)
|
||||
|
||||
case existing_segment do
|
||||
nil ->
|
||||
H.not_found(conn, "Segment not found with ID #{inspect(params["segment_id"])}")
|
||||
|
||||
%{} ->
|
||||
updated_segment =
|
||||
Repo.update!(Plausible.Segment.changeset(existing_segment, params),
|
||||
returning: true
|
||||
)
|
||||
|
||||
json(conn, updated_segment)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_segment(conn, params) do
|
||||
user_id = conn.assigns.current_user.id
|
||||
site_id = conn.assigns.site.id
|
||||
segment_id = normalize_segment_id_param(params["segment_id"])
|
||||
|
||||
existing_segment = get_one_segment(user_id, site_id, segment_id)
|
||||
|
||||
if existing_segment.type == :site and
|
||||
not has_capability_to_toggle_site_segment?(conn.assigns.current_user_role) do
|
||||
H.not_enough_permissions(conn, "Not enough permissions to delete site segments")
|
||||
else
|
||||
case existing_segment do
|
||||
nil ->
|
||||
H.not_found(conn, "Segment not found with ID #{inspect(params["segment_id"])}")
|
||||
|
||||
%{} ->
|
||||
Repo.delete!(existing_segment)
|
||||
json(conn, existing_segment)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -294,7 +294,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end
|
||||
|
||||
defp fetch_top_stats(site, query) do
|
||||
goal_filter? = Filters.filtering_on_dimension?(query, "event:goal")
|
||||
goal_filter? = Filters.filtering_on_dimension?(query.filters, "event:goal")
|
||||
|
||||
cond do
|
||||
query.period == "30m" && goal_filter? ->
|
||||
@ -392,7 +392,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end
|
||||
|
||||
defp fetch_other_top_stats(site, query) do
|
||||
page_filter? = Filters.filtering_on_dimension?(query, "event:page")
|
||||
page_filter? = Filters.filtering_on_dimension?(query.filters, "event:page")
|
||||
|
||||
metrics = [:visitors, :visits, :pageviews, :sample_percent]
|
||||
|
||||
@ -464,7 +464,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{source: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -551,10 +551,10 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
defp validate_funnel_query(query) do
|
||||
cond do
|
||||
Filters.filtering_on_dimension?(query, "event:goal") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:goal") ->
|
||||
{:error, {:invalid_funnel_query, "goals"}}
|
||||
|
||||
Filters.filtering_on_dimension?(query, "event:page") ->
|
||||
Filters.filtering_on_dimension?(query.filters, "event:page") ->
|
||||
{:error, {:invalid_funnel_query, "pages"}}
|
||||
|
||||
query.period == "realtime" ->
|
||||
@ -578,7 +578,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{utm_medium: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -606,7 +606,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{utm_campaign: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -634,7 +634,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{utm_content: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -662,7 +662,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{utm_term: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -690,7 +690,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{utm_source: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -718,7 +718,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{referrer: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
res
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -835,7 +835,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{page: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
pages
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -863,7 +863,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{entry_page: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
to_csv(entry_pages, [:name, :visitors, :conversion_rate], [
|
||||
:name,
|
||||
:conversions,
|
||||
@ -899,7 +899,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{exit_page: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
to_csv(exit_pages, [:name, :visitors, :conversion_rate], [
|
||||
:name,
|
||||
:conversions,
|
||||
@ -972,7 +972,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
Map.put(country, :name, country_info.name)
|
||||
end)
|
||||
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
countries
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1032,7 +1032,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end)
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
regions
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1076,7 +1076,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end)
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
cities
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1108,7 +1108,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{browser: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
browsers
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1140,7 +1140,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{browser_version: :version})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
results
|
||||
|> transform_keys(%{browser: :name, visitors: :conversions})
|
||||
|> to_csv([:name, :version, :conversions, :conversion_rate])
|
||||
@ -1181,7 +1181,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{os: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
systems
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1213,7 +1213,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{os_version: :version})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
results
|
||||
|> transform_keys(%{os: :name, visitors: :conversions})
|
||||
|> to_csv([:name, :version, :conversions, :conversion_rate])
|
||||
@ -1254,7 +1254,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> transform_keys(%{device: :name})
|
||||
|
||||
if params["csv"] do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
sizes
|
||||
|> transform_keys(%{visitors: :conversions})
|
||||
|> to_csv([:name, :conversions, :conversion_rate])
|
||||
@ -1344,7 +1344,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> Enum.concat()
|
||||
|
||||
percent_or_cr =
|
||||
if Filters.filtering_on_dimension?(query, "event:goal"),
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal"),
|
||||
do: :conversion_rate,
|
||||
else: :percentage
|
||||
|
||||
@ -1361,7 +1361,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
query = Query.from(site, params, debug_metadata(conn))
|
||||
|
||||
metrics =
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
[:visitors, :events, :conversion_rate] ++ @revenue_metrics
|
||||
else
|
||||
[:visitors, :events, :percentage] ++ @revenue_metrics
|
||||
@ -1533,7 +1533,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
requires_goal_filter? = metric in [:conversion_rate, :events]
|
||||
|
||||
if requires_goal_filter? and !Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if requires_goal_filter? and !Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
{:error, "Metric `#{metric}` can only be queried with a goal filter"}
|
||||
else
|
||||
{:ok, metric}
|
||||
@ -1564,7 +1564,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end
|
||||
|
||||
defp breakdown_metrics(query, extra_metrics \\ []) do
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
[:visitors, :conversion_rate, :total_visitors]
|
||||
else
|
||||
[:visitors] ++ extra_metrics
|
||||
|
@ -73,6 +73,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
title: title(conn, site),
|
||||
demo: demo,
|
||||
flags: get_flags(conn.assigns[:current_user], site),
|
||||
members: get_members(conn.assigns[:current_user], site),
|
||||
is_dbip: is_dbip(),
|
||||
dogfood_page_path: dogfood_page_path,
|
||||
load_dashboard_js: true
|
||||
@ -192,7 +193,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
|
||||
defp csv_graph_metrics(query) do
|
||||
{metrics, column_headers} =
|
||||
if Filters.filtering_on_dimension?(query, "event:goal") do
|
||||
if Filters.filtering_on_dimension?(query.filters, "event:goal") do
|
||||
{
|
||||
[:visitors, :events, :conversion_rate],
|
||||
[:date, :unique_conversions, :total_conversions, :conversion_rate]
|
||||
@ -356,6 +357,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
background: conn.params["background"],
|
||||
theme: conn.params["theme"],
|
||||
flags: get_flags(conn.assigns[:current_user], shared_link.site),
|
||||
members: get_members(conn.assigns[:current_user], shared_link.site),
|
||||
is_dbip: is_dbip(),
|
||||
load_dashboard_js: true
|
||||
)
|
||||
@ -381,6 +383,15 @@ defmodule PlausibleWeb.StatsController do
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
defp get_members(nil, _site) do
|
||||
nil
|
||||
end
|
||||
|
||||
defp get_members(_user, site) do
|
||||
s = Plausible.Repo.preload(site, :members)
|
||||
s.members |> Enum.map(fn member -> {member.id, member.name} end) |> Map.new()
|
||||
end
|
||||
|
||||
defp is_dbip() do
|
||||
on_ee do
|
||||
false
|
||||
|
@ -165,6 +165,17 @@ defmodule PlausibleWeb.Router do
|
||||
end
|
||||
end
|
||||
|
||||
# This scope indicates routes changeable without notice.
|
||||
scope "/internal-api", PlausibleWeb.Api.Internal do
|
||||
pipe_through [:internal_stats_api]
|
||||
|
||||
get "/:domain/segments", SegmentsController, :get_all_segments
|
||||
get "/:domain/segments/:segment_id", SegmentsController, :get_segment
|
||||
post "/:domain/segments", SegmentsController, :create_segment
|
||||
patch "/:domain/segments/:segment_id", SegmentsController, :update_segment
|
||||
delete "/:domain/segments/:segment_id", SegmentsController, :delete_segment
|
||||
end
|
||||
|
||||
scope "/api/stats", PlausibleWeb.Api do
|
||||
pipe_through :internal_stats_api
|
||||
|
||||
|
@ -38,6 +38,8 @@
|
||||
data-background={@conn.assigns[:background]}
|
||||
data-is-dbip={to_string(@is_dbip)}
|
||||
data-current-user-role={@conn.assigns[:current_user_role]}
|
||||
data-current-user-id={if is_nil(@conn.assigns[:current_user]) do nil else @conn.assigns[:current_user].id end}
|
||||
data-members={Jason.encode!(@members)}
|
||||
data-flags={Jason.encode!(@flags)}
|
||||
data-valid-intervals-by-period={
|
||||
Plausible.Stats.Interval.valid_by_period(site: @site) |> Jason.encode!()
|
||||
|
@ -347,6 +347,11 @@
|
||||
"enum": ["is", "contains"],
|
||||
"description": "filter operation"
|
||||
},
|
||||
"filter_operation_for_segments": {
|
||||
"type": "string",
|
||||
"enum": ["is"],
|
||||
"description": "filter operation"
|
||||
},
|
||||
"filter_without_goals": {
|
||||
"type": "array",
|
||||
"additionalItems": false,
|
||||
@ -392,10 +397,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"filter_for_segment": {
|
||||
"type": "array",
|
||||
"additionalItems": false,
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": [
|
||||
{
|
||||
"$ref": "#/definitions/filter_operation_for_segments"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "segment"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/clauses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"filter_entry": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/filter_without_goals" },
|
||||
{ "$ref": "#/definitions/filter_with_goals" }
|
||||
{ "$ref": "#/definitions/filter_with_goals" },
|
||||
{ "$ref": "#/definitions/filter_for_segment" }
|
||||
]
|
||||
},
|
||||
"filter_tree": {
|
||||
|
92
test/plausible/segment_schema_test.exs
Normal file
92
test/plausible/segment_schema_test.exs
Normal file
@ -0,0 +1,92 @@
|
||||
defmodule Plausible.SegmentSchemaTest do
|
||||
use ExUnit.Case
|
||||
|
||||
setup do
|
||||
segment = %Plausible.Segment{
|
||||
name: "any name",
|
||||
type: :personal,
|
||||
segment_data: %{"filters" => ["is", "visit:page", ["/blog"]]},
|
||||
owner_id: 1,
|
||||
site_id: 100
|
||||
}
|
||||
|
||||
{:ok, segment: segment}
|
||||
end
|
||||
|
||||
test "changeset has required fields" do
|
||||
assert Plausible.Segment.changeset(%Plausible.Segment{}, %{}).errors == [
|
||||
segment_data: {"property \"filters\" must be an array with at least one member", []},
|
||||
name: {"can't be blank", [validation: :required]},
|
||||
segment_data: {"can't be blank", [validation: :required]},
|
||||
site_id: {"can't be blank", [validation: :required]},
|
||||
type: {"can't be blank", [validation: :required]},
|
||||
owner_id: {"can't be blank", [validation: :required]}
|
||||
]
|
||||
end
|
||||
|
||||
test "changeset does not allow setting owner_id to nil (setting to nil happens with database triggers)",
|
||||
%{segment: valid_segment} do
|
||||
assert Plausible.Segment.changeset(
|
||||
valid_segment,
|
||||
%{
|
||||
owner_id: nil
|
||||
}
|
||||
).errors == [
|
||||
owner_id: {"can't be blank", [validation: :required]}
|
||||
]
|
||||
end
|
||||
|
||||
test "changeset allows setting nil owner_id to a user id (to be able to recover dangling site segments)",
|
||||
%{segment: valid_segment} do
|
||||
assert Plausible.Segment.changeset(
|
||||
%Plausible.Segment{
|
||||
valid_segment
|
||||
| owner_id: nil
|
||||
},
|
||||
%{
|
||||
owner_id: 100_100
|
||||
}
|
||||
).valid? == true
|
||||
end
|
||||
|
||||
test "changeset requires segment_data to be structured as expected", %{segment: valid_segment} do
|
||||
assert Plausible.Segment.changeset(
|
||||
valid_segment,
|
||||
%{
|
||||
segment_data: %{"filters" => 1, "labels" => true, "other" => []}
|
||||
}
|
||||
).errors == [
|
||||
{:segment_data, {"property \"labels\" must be map or nil", []}},
|
||||
{:segment_data,
|
||||
{"property \"filters\" must be an array with at least one member", []}},
|
||||
{:segment_data,
|
||||
{"must not contain any other property except \"filters\" and \"labels\"", []}}
|
||||
]
|
||||
end
|
||||
|
||||
test "changeset forbids empty filters list", %{segment: valid_segment} do
|
||||
assert Plausible.Segment.changeset(
|
||||
valid_segment,
|
||||
%{
|
||||
segment_data: %{
|
||||
"filters" => []
|
||||
}
|
||||
}
|
||||
).errors == [
|
||||
{:segment_data,
|
||||
{"property \"filters\" must be an array with at least one member", []}}
|
||||
]
|
||||
end
|
||||
|
||||
test "changeset permits well-structured segment data", %{segment: valid_segment} do
|
||||
assert Plausible.Segment.changeset(
|
||||
valid_segment,
|
||||
%{
|
||||
segment_data: %{
|
||||
"filters" => [["is", "visit:country", ["DE"]]],
|
||||
"labels" => %{"DE" => "Germany"}
|
||||
}
|
||||
}
|
||||
).valid? == true
|
||||
end
|
||||
end
|
@ -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
|
||||
@ -497,6 +497,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
|
||||
"metrics" => ["visitors"],
|
||||
"date_range" => "all",
|
||||
"filters" => [
|
||||
["is", "segment", [200]],
|
||||
[
|
||||
"or",
|
||||
[
|
||||
@ -516,6 +517,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
|
||||
metrics: [:visitors],
|
||||
utc_time_range: @date_range_day,
|
||||
filters: [
|
||||
[:is, "segment", [200]],
|
||||
[
|
||||
:or,
|
||||
[
|
||||
|
@ -0,0 +1,428 @@
|
||||
defmodule PlausibleWeb.Api.Internal.SegmentsControllerTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
use Plausible.Repo
|
||||
|
||||
describe "GET /internal-api/:domain/segments" do
|
||||
setup [:create_user, :create_new_site, :log_in]
|
||||
|
||||
test "returns empty list when no segments", %{conn: conn, site: site} do
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments")
|
||||
|
||||
assert json_response(conn, 200) == []
|
||||
end
|
||||
|
||||
for role <- [:viewer, :owner] do
|
||||
test "returns list with personal and site segments for #{role}", %{conn: conn, user: user} do
|
||||
other_site = insert(:site, owner: user)
|
||||
other_user = insert(:user)
|
||||
|
||||
site =
|
||||
insert(:site,
|
||||
memberships: [
|
||||
build(:site_membership, user: user, role: unquote(role)),
|
||||
build(:site_membership, user: other_user, role: :admin)
|
||||
]
|
||||
)
|
||||
|
||||
insert_list(2, :segment,
|
||||
site: other_site,
|
||||
owner_id: user.id,
|
||||
type: :site,
|
||||
name: "other site segment"
|
||||
)
|
||||
|
||||
insert_list(10, :segment,
|
||||
site: site,
|
||||
owner_id: other_user.id,
|
||||
type: :personal,
|
||||
name: "other user personal segment"
|
||||
)
|
||||
|
||||
inserted_at = "2024-10-04T12:00:00"
|
||||
|
||||
personal_segment =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
owner_id: user.id,
|
||||
type: :personal,
|
||||
name: "a personal segment",
|
||||
inserted_at: inserted_at,
|
||||
updated_at: inserted_at
|
||||
)
|
||||
|
||||
emea_site_segment =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
owner_id: other_user.id,
|
||||
type: :site,
|
||||
name: "EMEA region",
|
||||
inserted_at: inserted_at,
|
||||
updated_at: inserted_at
|
||||
)
|
||||
|
||||
apac_site_segment =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
owner_id: user.id,
|
||||
type: :site,
|
||||
name: "APAC region",
|
||||
inserted_at: inserted_at,
|
||||
updated_at: inserted_at
|
||||
)
|
||||
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments")
|
||||
|
||||
assert json_response(conn, 200) ==
|
||||
Enum.map([apac_site_segment, personal_segment, emea_site_segment], fn s ->
|
||||
%{
|
||||
"id" => s.id,
|
||||
"name" => s.name,
|
||||
"type" => Atom.to_string(s.type),
|
||||
"owner_id" => s.owner_id,
|
||||
"inserted_at" => inserted_at,
|
||||
"updated_at" => inserted_at,
|
||||
"segment_data" => nil
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /internal-api/:domain/segments/:segment_id" do
|
||||
setup [:create_user, :create_new_site, :log_in]
|
||||
|
||||
test "serves 404 when invalid segment key used", %{conn: conn, site: site} do
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/any-id")
|
||||
|
||||
assert json_response(conn, 404) == %{"error" => "Segment not found with ID \"any-id\""}
|
||||
end
|
||||
|
||||
test "serves 404 when no segment found", %{conn: conn, site: site} do
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/100100")
|
||||
|
||||
assert json_response(conn, 404) == %{"error" => "Segment not found with ID \"100100\""}
|
||||
end
|
||||
|
||||
test "serves 404 when segment is for another site", %{conn: conn, site: site, user: user} do
|
||||
other_site = insert(:site, owner: user)
|
||||
|
||||
%{id: segment_id} =
|
||||
insert(:segment,
|
||||
site: other_site,
|
||||
owner_id: user.id,
|
||||
type: :site,
|
||||
name: "any"
|
||||
)
|
||||
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
assert json_response(conn, 404) == %{
|
||||
"error" => "Segment not found with ID \"#{segment_id}\""
|
||||
}
|
||||
end
|
||||
|
||||
test "serves 404 when user is not the segment owner and segment is personal",
|
||||
%{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
other_user = insert(:user)
|
||||
|
||||
inserted_at = "2024-10-01T10:00:00"
|
||||
updated_at = inserted_at
|
||||
|
||||
%{
|
||||
id: segment_id
|
||||
} =
|
||||
insert(:segment,
|
||||
type: :personal,
|
||||
owner_id: other_user.id,
|
||||
site: site,
|
||||
name: "any",
|
||||
inserted_at: inserted_at,
|
||||
updated_at: updated_at
|
||||
)
|
||||
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
assert json_response(conn, 404) == %{
|
||||
"error" => "Segment not found with ID \"#{segment_id}\""
|
||||
}
|
||||
end
|
||||
|
||||
test "serves 200 with segment when user is not the segment owner and segment is not personal",
|
||||
%{
|
||||
conn: conn,
|
||||
site: site
|
||||
} do
|
||||
other_user = insert(:user)
|
||||
|
||||
inserted_at = "2024-10-01T10:00:00"
|
||||
updated_at = inserted_at
|
||||
|
||||
%{
|
||||
id: segment_id,
|
||||
segment_data: segment_data
|
||||
} =
|
||||
insert(:segment,
|
||||
type: :site,
|
||||
owner_id: other_user.id,
|
||||
site: site,
|
||||
name: "any",
|
||||
inserted_at: inserted_at,
|
||||
updated_at: updated_at
|
||||
)
|
||||
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"id" => segment_id,
|
||||
"owner_id" => other_user.id,
|
||||
"name" => "any",
|
||||
"type" => "site",
|
||||
"segment_data" => segment_data,
|
||||
"inserted_at" => inserted_at,
|
||||
"updated_at" => updated_at
|
||||
}
|
||||
end
|
||||
|
||||
test "serves 200 with segment when user is segment owner", %{
|
||||
conn: conn,
|
||||
site: site,
|
||||
user: user
|
||||
} do
|
||||
inserted_at = "2024-09-01T10:00:00"
|
||||
updated_at = inserted_at
|
||||
|
||||
%{id: segment_id, segment_data: segment_data} =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
name: "any",
|
||||
owner_id: user.id,
|
||||
type: :personal,
|
||||
inserted_at: inserted_at,
|
||||
updated_at: updated_at
|
||||
)
|
||||
|
||||
conn =
|
||||
get(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"id" => segment_id,
|
||||
"owner_id" => user.id,
|
||||
"name" => "any",
|
||||
"type" => "personal",
|
||||
"segment_data" => segment_data,
|
||||
"inserted_at" => inserted_at,
|
||||
"updated_at" => updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /internal-api/:domain/segments" do
|
||||
setup [:create_user, :create_new_site, :log_in]
|
||||
|
||||
test "forbids viewers from creating site segments", %{conn: conn, user: user} do
|
||||
site =
|
||||
insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
|
||||
|
||||
conn =
|
||||
post(conn, "/internal-api/#{site.domain}/segments", %{
|
||||
"type" => "site",
|
||||
"segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]},
|
||||
"name" => "any name"
|
||||
})
|
||||
|
||||
assert json_response(conn, 403) == %{
|
||||
"error" => "Not enough permissions to create site segments"
|
||||
}
|
||||
end
|
||||
|
||||
for %{role: role, type: type} <- [
|
||||
%{role: :viewer, type: :personal},
|
||||
%{role: :admin, type: :personal},
|
||||
%{role: :admin, type: :site}
|
||||
] do
|
||||
test "#{role} can create segment with type \"#{type}\" successfully",
|
||||
%{conn: conn, user: user} do
|
||||
site =
|
||||
insert(:site, memberships: [build(:site_membership, user: user, role: unquote(role))])
|
||||
t = Atom.to_string(unquote(type))
|
||||
name = "any name"
|
||||
|
||||
conn =
|
||||
post(conn, "/internal-api/#{site.domain}/segments", %{
|
||||
"type" => t,
|
||||
"segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]},
|
||||
"name" => name
|
||||
})
|
||||
|
||||
response = json_response(conn, 200)
|
||||
|
||||
assert %{
|
||||
"name" => ^name,
|
||||
"segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]},
|
||||
"type" => ^t
|
||||
} = response
|
||||
|
||||
%{
|
||||
"id" => id,
|
||||
"owner_id" => owner_id,
|
||||
"updated_at" => updated_at,
|
||||
"inserted_at" => inserted_at
|
||||
} =
|
||||
response
|
||||
|
||||
assert is_integer(id)
|
||||
assert ^owner_id = user.id
|
||||
assert is_binary(inserted_at)
|
||||
assert is_binary(updated_at)
|
||||
assert ^inserted_at = updated_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /internal-api/:domain/segments/:segment_id" do
|
||||
setup [:create_user, :create_new_site, :log_in]
|
||||
|
||||
for {current_type, patch_type} <- [
|
||||
{:personal, :site},
|
||||
{:site, :personal},
|
||||
] do
|
||||
test "prevents viewers from updating segments with current type #{current_type} with #{patch_type}",
|
||||
%{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
|
||||
inserted_at = "2024-09-01T10:00:00"
|
||||
updated_at = inserted_at
|
||||
|
||||
%{id: segment_id} =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
name: "foo",
|
||||
type: unquote(current_type),
|
||||
owner_id: user.id,
|
||||
inserted_at: inserted_at,
|
||||
updated_at: updated_at
|
||||
)
|
||||
|
||||
conn =
|
||||
patch(conn, "/internal-api/#{site.domain}/segments/#{segment_id}", %{
|
||||
"name" => "updated name",
|
||||
"type" => unquote(patch_type)
|
||||
})
|
||||
|
||||
assert json_response(conn, 403) == %{
|
||||
"error" => "Not enough permissions to set segment visibility"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "updates segment successfully", %{conn: conn, user: user} do
|
||||
site = insert(:site, memberships: [build(:site_membership, user: user, role: :admin)])
|
||||
|
||||
name = "foo"
|
||||
inserted_at = "2024-09-01T10:00:00"
|
||||
updated_at = inserted_at
|
||||
type = :site
|
||||
updated_type = :personal
|
||||
|
||||
%{id: segment_id, owner_id: owner_id, segment_data: segment_data} =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
name: name,
|
||||
type: type,
|
||||
owner_id: user.id,
|
||||
inserted_at: inserted_at,
|
||||
updated_at: updated_at
|
||||
)
|
||||
|
||||
conn =
|
||||
patch(conn, "/internal-api/#{site.domain}/segments/#{segment_id}", %{
|
||||
"name" => "updated name",
|
||||
"type" => updated_type
|
||||
})
|
||||
|
||||
response = json_response(conn, 200)
|
||||
|
||||
assert %{
|
||||
"owner_id" => ^owner_id,
|
||||
"inserted_at" => ^inserted_at,
|
||||
"id" => ^segment_id,
|
||||
"segment_data" => ^segment_data
|
||||
} = response
|
||||
|
||||
assert response["name"] == "updated name"
|
||||
assert response["type"] == Atom.to_string(updated_type)
|
||||
assert response["updated_at"] != inserted_at
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /internal-api/:domain/segments/:segment_id" do
|
||||
setup [:create_user, :create_new_site, :log_in]
|
||||
|
||||
test "forbids viewers from deleting site segments", %{conn: conn, user: user} do
|
||||
site = insert(:site, memberships: [build(:site_membership, user: user, role: :viewer)])
|
||||
|
||||
%{id: segment_id} =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
name: "any",
|
||||
type: :site,
|
||||
owner_id: user.id
|
||||
)
|
||||
|
||||
conn =
|
||||
delete(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
assert json_response(conn, 403) == %{
|
||||
"error" => "Not enough permissions to delete site segments"
|
||||
}
|
||||
end
|
||||
|
||||
for %{role: role, type: type} <- [
|
||||
%{role: :viewer, type: :personal},
|
||||
%{role: :admin, type: :personal},
|
||||
%{role: :admin, type: :site}
|
||||
] do
|
||||
test "#{role} can delete segment with type \"#{type}\" successfully",
|
||||
%{conn: conn, user: user} do
|
||||
site =
|
||||
insert(:site, memberships: [build(:site_membership, user: user, role: unquote(role))])
|
||||
t = Atom.to_string(unquote(type))
|
||||
|
||||
user_id = user.id
|
||||
|
||||
%{id: segment_id, segment_data: segment_data} =
|
||||
insert(:segment,
|
||||
site: site,
|
||||
name: "any",
|
||||
type: t,
|
||||
owner_id: user_id
|
||||
)
|
||||
|
||||
conn =
|
||||
delete(conn, "/internal-api/#{site.domain}/segments/#{segment_id}")
|
||||
|
||||
response = json_response(conn, 200)
|
||||
|
||||
assert %{
|
||||
"id" => ^segment_id,
|
||||
"owner_id" => ^user_id,
|
||||
"name" => "any",
|
||||
"segment_data" => ^segment_data,
|
||||
"type" => ^t
|
||||
} = response
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -342,5 +342,73 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "handles multiple segment filters", %{conn: conn, site: site, user: user} do
|
||||
%{id: segment_alfa} =
|
||||
insert(:segment,
|
||||
site_id: site.id,
|
||||
owner_id: user.id,
|
||||
name: "Ireland and Britain (excl London)",
|
||||
type: :site,
|
||||
segment_data: %{
|
||||
"filters" => [
|
||||
["is", "visit:country", ["IE", "GB"]],
|
||||
["is_not", "visit:city", [2_643_743]]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
%{id: segment_beta} =
|
||||
insert(:segment,
|
||||
site_id: site.id,
|
||||
owner_id: user.id,
|
||||
name: "Entered on root or Blog",
|
||||
type: :personal,
|
||||
segment_data: %{
|
||||
"filters" => [
|
||||
["is", "visit:entry_page", ["/", "/blog"]]
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, country_code: "EE"),
|
||||
build(:pageview,
|
||||
country_code: "GB",
|
||||
# London
|
||||
city_geoname_id: 2_643_743
|
||||
),
|
||||
build(:pageview,
|
||||
country_code: "GB",
|
||||
# London
|
||||
city_geoname_id: 2_643_743
|
||||
),
|
||||
build(:pageview, country_code: "GB"),
|
||||
build(:pageview, country_code: "IE", pathname: "/other"),
|
||||
build(:pageview, country_code: "IE")
|
||||
])
|
||||
|
||||
filters = Jason.encode!([["is", "segment", [segment_alfa, segment_beta]]])
|
||||
conn = get(conn, "/api/stats/#{site.domain}/countries?period=day&filters=#{filters}")
|
||||
|
||||
assert json_response(conn, 200)["results"] == [
|
||||
%{
|
||||
"code" => "GB",
|
||||
"alpha_3" => "GBR",
|
||||
"name" => "United Kingdom",
|
||||
"flag" => "🇬🇧",
|
||||
"visitors" => 1,
|
||||
"percentage" => 50
|
||||
},
|
||||
%{
|
||||
"code" => "IE",
|
||||
"alpha_3" => "IRL",
|
||||
"name" => "Ireland",
|
||||
"flag" => "🇮🇪",
|
||||
"visitors" => 1,
|
||||
"percentage" => 50
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -390,6 +390,12 @@ defmodule Plausible.Factory do
|
||||
}
|
||||
end
|
||||
|
||||
def segment_factory do
|
||||
%Plausible.Segment{
|
||||
segment_data: %{"filters" => [["is", "visit:entry_page", ["/blog"]]]}
|
||||
}
|
||||
end
|
||||
|
||||
defp hash_key() do
|
||||
Keyword.fetch!(
|
||||
Application.get_env(:plausible, PlausibleWeb.Endpoint),
|
||||
|
Loading…
Reference in New Issue
Block a user