Squashed saved segments

This commit is contained in:
Artur Pata 2024-11-14 16:09:11 +02:00
parent cf4ba664ed
commit 32107cfbb5
44 changed files with 2446 additions and 274 deletions

View File

@ -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>

View File

@ -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 & {
navigateOptions?: NavigateOptions
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
active?: boolean
children: ReactNode
className?: string
onClick?: () => void
onLinkClick?: () => void
actions?: ReactNode
}) => (
<AppNavigationLink
{...props}
<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}
>
<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>
)

View File

@ -21,9 +21,5 @@ const ClearFiltersKeybind = () => (
)
export function DashboardKeybinds() {
return (
<>
<ClearFiltersKeybind />
</>
)
return <>{false && <ClearFiltersKeybind />}</> // temp disable
}

View File

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

View File

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

View File

@ -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>

View File

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

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

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

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

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

View File

@ -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

View File

@ -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">

View File

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

View File

@ -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"
className={classNames("modal__container dark:bg-gray-800", this.props.className)}
style={this.getStyle()}
>
{this.props.children}
</div>
</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}
/>
</>
)
}

View File

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

View File

@ -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) {

View File

@ -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

View File

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

View 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
View 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

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

View File

@ -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

View File

@ -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}%"

View File

@ -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 """

View 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

View File

@ -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

View 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

View File

@ -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(

View File

@ -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: %{},

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!()

View File

@ -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": {

View 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

View File

@ -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,
[

View File

@ -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

View File

@ -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

View File

@ -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),