diff --git a/assets/js/dashboard.tsx b/assets/js/dashboard.tsx index ad44acc74dfc..0498b63bf6b2 100644 --- a/assets/js/dashboard.tsx +++ b/assets/js/dashboard.tsx @@ -57,8 +57,15 @@ if (container && container.dataset) { diff --git a/assets/js/dashboard/components/dropdown.tsx b/assets/js/dashboard/components/dropdown.tsx index c3397211f192..14ba7577b2a8 100644 --- a/assets/js/dashboard/components/dropdown.tsx +++ b/assets/js/dashboard/components/dropdown.tsx @@ -13,6 +13,24 @@ import { AppNavigationLink, AppNavigationTarget } from '../navigation/use-app-navigate' +import { NavigateOptions } from 'react-router-dom' + +export const DropdownSubtitle = ({ + children, + className +}: { + children: ReactNode + className?: string +}) => ( +
+ {children} +
+) export const ToggleDropdownButton = forwardRef< HTMLDivElement, @@ -109,12 +127,12 @@ export const DropdownMenuWrapper = forwardRef< ) }) -export const DropdownLinkGroup = ({ - className, - children, - ...props -}: DetailedHTMLProps, HTMLDivElement>) => ( +export const DropdownLinkGroup = React.forwardRef< + HTMLDivElement, + DetailedHTMLProps, HTMLDivElement> +>(({ className, children, ...props }, ref) => (
{children}
-) +)) export const DropdownNavigationLink = ({ children, active, className, + actions, + path, + params, + search, + navigateOptions, + onLinkClick, ...props }: AppNavigationTarget & { - active?: boolean - children: ReactNode - className?: string - onClick?: () => void -}) => ( - , HTMLDivElement> & { + active?: boolean + onLinkClick?: () => void + actions?: ReactNode + }) => ( +
- {children} - + + {children} + + {!!actions && actions} +
) diff --git a/assets/js/dashboard/components/search-input.tsx b/assets/js/dashboard/components/search-input.tsx index 06872f97556d..7a0e475f46f9 100644 --- a/assets/js/dashboard/components/search-input.tsx +++ b/assets/js/dashboard/components/search-input.tsx @@ -6,11 +6,13 @@ import { useDebounce } from '../custom-hooks' import classNames from 'classnames' export const SearchInput = ({ + className, onSearch, - className + initialValue }: { className?: string onSearch: (value: string) => void + initialValue?: string }) => { const searchBoxRef = useRef(null) const [isFocused, setIsFocused] = useState(false) @@ -63,6 +65,7 @@ export const SearchInput = ({ ref={searchBoxRef} type="text" placeholder={isFocused ? 'Search' : 'Press / to search'} + value={initialValue} className={classNames( 'shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 w-48', className diff --git a/assets/js/dashboard/dashboard-keybinds.tsx b/assets/js/dashboard/dashboard-keybinds.tsx index f40b244df05f..1d6191728a03 100644 --- a/assets/js/dashboard/dashboard-keybinds.tsx +++ b/assets/js/dashboard/dashboard-keybinds.tsx @@ -21,9 +21,5 @@ const ClearFiltersKeybind = () => ( ) export function DashboardKeybinds() { - return ( - <> - - - ) + return <>{false && } // temp disable } diff --git a/assets/js/dashboard/datepicker.tsx b/assets/js/dashboard/datepicker.tsx index ae5fa74a98ab..ac744eeadf74 100644 --- a/assets/js/dashboard/datepicker.tsx +++ b/assets/js/dashboard/datepicker.tsx @@ -201,7 +201,7 @@ function ComparisonMenu({ s} - onClick={toggleCompareMenuCalendar} + onLinkClick={toggleCompareMenuCalendar} > {COMPARISON_MODES[ComparisonMode.custom]} @@ -250,7 +250,7 @@ function QueryPeriodsMenu({ key={label} active={isActive({ site, query })} search={search} - onClick={onClick || closeMenu} + onLinkClick={onClick || closeMenu} > {label} {!!keyboardKey && {keyboardKey}} @@ -352,7 +352,7 @@ export default function QueryPeriodPicker() { }, [closeMenu, query]) return ( -
+
search} className="group flex w-full justify-between items-center" style={{ width: 'calc(100% - 1.5rem)' }} > - {styledFilterText(query, filter)} + {styledFilterText(query.labels, filter)} removeFilter(filterIndex, navigate, query)} > @@ -184,16 +184,16 @@ function Filters() { return ( search} > - {styledFilterText(query, filter)} + {styledFilterText(query.labels, filter)} removeFilter(filterIndex, navigate, query)} > diff --git a/assets/js/dashboard/nav-menu/filter-menu.tsx b/assets/js/dashboard/nav-menu/filter-menu.tsx index 63bc7cd67b79..6b86c70ac8ff 100644 --- a/assets/js/dashboard/nav-menu/filter-menu.tsx +++ b/assets/js/dashboard/nav-menu/filter-menu.tsx @@ -5,79 +5,371 @@ import { DropdownLinkGroup, DropdownMenuWrapper, DropdownNavigationLink, + DropdownSubtitle, ToggleDropdownButton } from '../components/dropdown' import { MagnifyingGlassIcon } from '@heroicons/react/20/solid' import { + cleanLabels, FILTER_MODAL_TO_FILTER_GROUP, - formatFilterGroup + formatFilterGroup, + remapToApiFilters } from '../util/filters' import { PlausibleSite, useSiteContext } from '../site-context' import { filterRoute } from '../router' import { useOnClickOutside } from '../util/use-on-click-outside' +import { SegmentsList } from '../segments/segments-dropdown' +import { useQueryContext } from '../query-context' +import { + SegmentExpandedLocationState, + useSegmentExpandedContext +} from '../segments/segment-expanded-context' +import { + CreateSegmentModal, + DeleteSegmentModal, + UpdateSegmentModal +} from '../segments/segment-modals' +import { useUserContext } from '../user-context' +import { + formatSegmentIdAsLabelKey, + getSegmentNamePlaceholder, + parseApiSegmentData, + SavedSegment +} from '../segments/segments' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useAppNavigate } from '../navigation/use-app-navigate' +import { DashboardQuery } from '../query' +import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid' export function getFilterListItems({ propsAvailable -}: Pick): { - modalKey: string - label: string -}[] { - const allKeys = Object.keys(FILTER_MODAL_TO_FILTER_GROUP) as Array< - keyof typeof FILTER_MODAL_TO_FILTER_GROUP - > - const keysToOmit: Array = - propsAvailable ? [] : ['props'] - return allKeys - .filter((k) => !keysToOmit.includes(k)) - .map((modalKey) => ({ modalKey, label: formatFilterGroup(modalKey) })) +}: Pick): Array< + Array<{ + title: string + modals: Array + }> +> { + return [ + [ + { + title: 'URL', + modals: ['page', 'hostname'] + }, + { + title: 'Acquisition', + modals: ['source', 'utm'] + } + ], + [ + { + title: 'Device', + modals: ['location', 'screen', 'browser', 'os'] + }, + { + title: 'Behaviour', + modals: ['goal', !!propsAvailable && 'props'] + } + ] + ] } export const FilterMenu = () => { + const user = useUserContext() const dropdownRef = useRef(null) const [opened, setOpened] = useState(false) const site = useSiteContext() - const filterListItems = useMemo(() => getFilterListItems(site), [site]) + const columns = useMemo(() => getFilterListItems(site), [site]) + const { query } = useQueryContext() + const { expandedSegment, modal } = useSegmentExpandedContext() + const queryClient = useQueryClient() + const navigate = useAppNavigate() + const patchSegment = useMutation({ + mutationFn: ({ + id, + name, + type, + segment_data + }: Pick & + Partial> & { + 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) => { + queryClient.invalidateQueries({ queryKey: ['segments'] }) + navigate({ + search: (search) => { + const filters = [['is', 'segment', [d.id]]] + const labels = cleanLabels(filters, {}, 'segment', { + [formatSegmentIdAsLabelKey(d.id)]: d.name + }) + return { + ...search, + filters, + labels + } + }, + state: { + expandedSegment: null, + modal: null + } as SegmentExpandedLocationState + }) + setOpened(false) + } + }) + + 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) => { + queryClient.invalidateQueries({ queryKey: ['segments'] }) + const filters = [['is', 'segment', [d.id]]] + const labels = cleanLabels(filters, {}, 'segment', { + [formatSegmentIdAsLabelKey(d.id)]: d.name + }) + return { + ...search, + filters, + labels + } + }, + state: { + expandedSegment: null, + modal: null + } as SegmentExpandedLocationState + }) + setOpened(false) + } + }) + const deleteSegment = useMutation({ + mutationFn: (data: Pick) => { + 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'] }) + navigate({ + search: (s) => { + return { + ...s, + filters: null, + labels: null + } + }, + state: { + expandedSegment: null, + modal: null + } as SegmentExpandedLocationState + }) + setOpened(false) + } + }) useOnClickOutside({ ref: dropdownRef, - active: opened, + active: opened && modal === null, handler: () => setOpened(false) }) return ( - setOpened((opened) => !opened)} - currentOption={ - - - Filter - - } - > - {opened && ( - - - {filterListItems.map(({ modalKey, label }) => ( - search} - > - {label} - - ))} - - + <> + {user.loggedIn && modal === 'update' && expandedSegment && ( + + navigate({ + search: (s) => s, + state: { expandedSegment: expandedSegment, modal: null } + }) + } + onSave={({ id, name, type }) => + patchSegment.mutate({ + id, + name, + type, + segment_data: { + filters: query.filters, + labels: query.labels + } + }) + } + /> )} - + {user.loggedIn && modal === 'create' && ( + + navigate({ + search: (s) => s, + state: { expandedSegment: expandedSegment, modal: null } + }) + } + onSave={({ name, type }) => + createSegment.mutate({ + name, + type, + segment_data: { + filters: query.filters, + labels: query.labels + } + }) + } + /> + )} + {user.loggedIn && modal === 'delete' && expandedSegment && ( + + navigate({ + search: (s) => s, + state: { expandedSegment: expandedSegment, modal: null } + }) + } + onSave={({ id }) => deleteSegment.mutate({ id })} + /> + )} + + setOpened((opened) => !opened)} + currentOption={ +
+ {!expandedSegment && ( + <> + + Filter + + )} + {!!expandedSegment && ( + <> + + Edit segment + + )} + {/* {expandedSegment ? 'Segment' : 'Filter'} */} +
+ } + > + {opened && ( + + setOpened(false)} /> + + {columns.map((filterGroups, index) => ( +
+ {filterGroups.map(({ title, modals }) => ( +
+ + {title} + + {modals + .filter((m) => !!m) + .map((modalKey) => ( + setOpened(false)} + active={false} + key={modalKey} + path={filterRoute.path} + params={{ field: modalKey }} + search={(search) => search} + > + {formatFilterGroup(modalKey)} + + ))} +
+ ))} +
+ ))} +
+
+ )} +
+ ) } diff --git a/assets/js/dashboard/nav-menu/filter-pill.tsx b/assets/js/dashboard/nav-menu/filter-pill.tsx index 20ac38e654b7..68cf46203b8a 100644 --- a/assets/js/dashboard/nav-menu/filter-pill.tsx +++ b/assets/js/dashboard/nav-menu/filter-pill.tsx @@ -1,24 +1,40 @@ /** @format */ import React, { ReactNode } from 'react' -import { AppNavigationLink } from '../navigation/use-app-navigate' -import { filterRoute } from '../router' +import { + AppNavigationLink, + AppNavigationTarget +} from '../navigation/use-app-navigate' import { XMarkIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' +export type FilterPillProps = { + className?: string + plainText: string + interactive: + | { + onRemoveClick?: () => void + navigationTarget: AppNavigationTarget + } + | false + children: ReactNode + actions?: ReactNode +} + export function FilterPill({ className, plainText, children, - modalToOpen, - onRemoveClick -}: { - className?: string - plainText: string - modalToOpen: string - children: ReactNode - onRemoveClick: () => void -}) { + interactive, + actions +}: FilterPillProps) { + const c = 'flex w-full h-full items-center py-2 pl-3 last:pr-3' + const inner = ( + + {children} + + ) + return (
- search} - > - - {children} - - - + {interactive ? ( + <> + + {inner} + + {!!interactive.onRemoveClick && ( + + )} + {actions} + + ) : ( + <> +
+ {inner} +
+ {actions} + + )}
) } diff --git a/assets/js/dashboard/nav-menu/filter-pills-list.tsx b/assets/js/dashboard/nav-menu/filter-pills-list.tsx index 830cae556fc7..3f7cd751faf1 100644 --- a/assets/js/dashboard/nav-menu/filter-pills-list.tsx +++ b/assets/js/dashboard/nav-menu/filter-pills-list.tsx @@ -2,7 +2,7 @@ import React, { DetailedHTMLProps, HTMLAttributes } from 'react' import { useQueryContext } from '../query-context' -import { FilterPill } from './filter-pill' +import { FilterPill, FilterPillProps } from './filter-pill' import { cleanLabels, EVENT_PROPS_PREFIX, @@ -12,28 +12,40 @@ import { } from '../util/filters' import { useAppNavigate } from '../navigation/use-app-navigate' import classNames from 'classnames' +import { filterRoute } from '../router' export const PILL_X_GAP = 16 export const PILL_Y_GAP = 8 -/** Restricts output to slice of DashboardQuery['filters'], or makes the output outside the slice invisible */ -type Slice = { +type SliceStartEnd = { /** The beginning index of the specified portion of the array. If start is undefined, then the slice begins at index 0. */ start?: number /** The end index of the specified portion of the array. This is exclusive of the element at the index 'end'. If end is undefined, then the slice extends to the end of the array. */ end?: number - /** Determines if it renders the elements outside the slice with invisible or doesn't render the elements at all */ - type: 'hide-outside' | 'no-render-outside' } -type FilterPillsProps = { +type InvisibleOutsideSlice = { + type: 'invisible-outside' +} & SliceStartEnd + +type NoRenderOutsideSlice = { + type: 'no-render-outside' +} & SliceStartEnd + +type AppliedFilterPillsListProps = Omit< + FilterPillsListProps, + 'slice' | 'pillProps' | 'pills' +> & { slice?: InvisibleOutsideSlice | NoRenderOutsideSlice } + +type FilterPillsListProps = { direction: 'horizontal' | 'vertical' - slice?: Slice -} & DetailedHTMLProps, HTMLDivElement> +} & DetailedHTMLProps, HTMLDivElement> & { + pills: FilterPillProps[] + } -export const FilterPillsList = React.forwardRef< +export const AppliedFilterPillsList = React.forwardRef< HTMLDivElement, - FilterPillsProps + AppliedFilterPillsListProps >(({ className, style, slice, direction }, ref) => { const { query } = useQueryContext() const navigate = useAppNavigate() @@ -47,12 +59,56 @@ export const FilterPillsList = React.forwardRef< slice?.type === 'no-render-outside' ? (slice.start ?? 0) : 0 const isInvisible = (index: number) => { - return slice?.type === 'hide-outside' + return slice?.type === 'invisible-outside' ? index < (slice.start ?? 0) || index > (slice.end ?? query.filters.length) - 1 : false } + return ( + ({ + className: classNames(isInvisible(index) && 'invisible'), + plainText: plainFilterText(query.labels, filter), + children: styledFilterText(query.labels, filter), + interactive: { + navigationTarget: { + path: filterRoute.path, + search: (s) => s, + params: { + field: + FILTER_GROUP_TO_MODAL_TYPE[ + filter[1].startsWith(EVENT_PROPS_PREFIX) ? 'props' : filter[1] + ] + } + }, + onRemoveClick: () => { + const newFilters = query.filters.filter( + (_, i) => i !== index + indexAdjustment + ) + + navigate({ + search: (search) => ({ + ...search, + filters: newFilters, + labels: cleanLabels(newFilters, query.labels) + }) + }) + } + } + }))} + className={className} + style={style} + ref={ref} + direction={direction} + /> + ) +}) + +export const FilterPillsList = React.forwardRef< + HTMLDivElement, + FilterPillsListProps +>(({ className, style, direction, pills }, ref) => { return (
- {renderableFilters.map((filter, index) => ( - - navigate({ - search: (search) => ({ - ...search, - filters: query.filters.filter( - (_, i) => i !== index + indexAdjustment - ), - labels: cleanLabels(query.filters, query.labels) - }) - }) - } - > - {styledFilterText(query, filter)} - + {pills.map((options, index) => ( + ))}
) diff --git a/assets/js/dashboard/nav-menu/filters-bar.test.tsx b/assets/js/dashboard/nav-menu/filters-bar.test.tsx index 9cca865b04df..b722f3b75022 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.test.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.test.tsx @@ -90,7 +90,7 @@ describe(`${handleVisibility.name}`, () => { const setVisibility = jest.fn() const input = { setVisibility, - topBarWidth: 1000, + leftoverWidth: 1000, actionsWidth: 100, seeMorePresent: false, seeMoreWidth: 50, @@ -115,7 +115,7 @@ describe(`${handleVisibility.name}`, () => { visibleCount: 4 }) - handleVisibility({ ...input, topBarWidth: 999 }) + handleVisibility({ ...input, leftoverWidth: 999 }) expect(setVisibility).toHaveBeenCalledTimes(3) expect(setVisibility).toHaveBeenLastCalledWith({ width: 675, @@ -127,7 +127,7 @@ describe(`${handleVisibility.name}`, () => { const setVisibility = jest.fn() const input = { setVisibility, - topBarWidth: 300, + leftoverWidth: 300, actionsWidth: 100, seeMorePresent: true, seeMoreWidth: 50, diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx index e0f48e342611..2a71cb020bbf 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.tsx @@ -1,23 +1,24 @@ /** @format */ -import { EllipsisHorizontalIcon, XMarkIcon } from '@heroicons/react/20/solid' +import { EllipsisHorizontalIcon } from '@heroicons/react/20/solid' import classNames from 'classnames' import React, { useRef, useState, useLayoutEffect, useEffect } from 'react' -import { AppNavigationLink } from '../navigation/use-app-navigate' import { useOnClickOutside } from '../util/use-on-click-outside' import { DropdownMenuWrapper, ToggleDropdownButton } from '../components/dropdown' -import { FilterPillsList, PILL_X_GAP } from './filter-pills-list' +import { AppliedFilterPillsList, PILL_X_GAP } from './filter-pills-list' import { useQueryContext } from '../query-context' +import { AppNavigationLink } from '../navigation/use-app-navigate' +const LEFT_ACTIONS_GAP_PX = 16 const SEE_MORE_GAP_PX = 16 const SEE_MORE_WIDTH_PX = 36 export const handleVisibility = ({ setVisibility, - topBarWidth, + leftoverWidth: leftoverWidth, actionsWidth, seeMorePresent, seeMoreWidth, @@ -25,14 +26,14 @@ export const handleVisibility = ({ pillGap }: { setVisibility: (v: VisibilityState) => void - topBarWidth: number | null + leftoverWidth: number | null actionsWidth: number | null pillWidths: (number | null)[] | null seeMorePresent: boolean seeMoreWidth: number pillGap: number }): void => { - if (topBarWidth === null || actionsWidth === null || pillWidths === null) { + if (leftoverWidth === null || actionsWidth === null || pillWidths === null) { return } @@ -52,11 +53,13 @@ export const handleVisibility = ({ return { visibleCount, lastValidWidth } } - const fits = fitToWidth(topBarWidth - actionsWidth) + const fits = fitToWidth(leftoverWidth - actionsWidth) // Check if possible to fit one more if "See more" is removed if (seeMorePresent && fits.visibleCount === pillWidths.length - 1) { - const maybeFitsMore = fitToWidth(topBarWidth - actionsWidth + seeMoreWidth) + const maybeFitsMore = fitToWidth( + leftoverWidth - actionsWidth + seeMoreWidth + ) if (maybeFitsMore.visibleCount === pillWidths.length) { return setVisibility({ width: maybeFitsMore.lastValidWidth, @@ -67,7 +70,9 @@ export const handleVisibility = ({ // Check if the appearance of "See more" would cause overflow if (!seeMorePresent && fits.visibleCount < pillWidths.length) { - const maybeFitsLess = fitToWidth(topBarWidth - actionsWidth - seeMoreWidth) + const maybeFitsLess = fitToWidth( + leftoverWidth - actionsWidth - seeMoreWidth + ) if (maybeFitsLess.visibleCount < fits.visibleCount) { return setVisibility({ width: maybeFitsLess.lastValidWidth, @@ -113,6 +118,12 @@ export const FiltersBar = () => { }) useLayoutEffect(() => { + const topLeftActions = containerRef.current?.parentElement + const topBar = topLeftActions?.parentElement + const datepicker = topBar?.children[1] as HTMLElement | undefined + const sitepicker = topLeftActions?.children[0] as HTMLElement | undefined + const filterButton = topLeftActions?.children[2] as HTMLElement | undefined + const resizeObserver = new ResizeObserver((_entries) => { const pillWidths = pillsRef.current ? Array.from(pillsRef.current.children).map((el) => @@ -123,15 +134,22 @@ export const FiltersBar = () => { setVisibility, pillWidths, pillGap: PILL_X_GAP, - topBarWidth: getElementWidthOrNull(containerRef.current), + leftoverWidth: + topBar && datepicker && sitepicker && filterButton + ? getElementWidthOrNull(topBar)! - + getElementWidthOrNull(datepicker)! - + getElementWidthOrNull(sitepicker)! - + getElementWidthOrNull(filterButton)! - + 2 * LEFT_ACTIONS_GAP_PX + : null, actionsWidth: getElementWidthOrNull(actionsRef.current), seeMorePresent: !!seeMoreRef.current, seeMoreWidth: SEE_MORE_WIDTH_PX + SEE_MORE_GAP_PX }) }) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) + if (containerRef.current && topBar) { + resizeObserver.observe(topBar) } return () => { @@ -146,23 +164,23 @@ export const FiltersBar = () => { return (
- -
+
{visibility !== null && visibility.visibleCount !== query.filters.length && ( { > {opened && typeof visibility.visibleCount === 'number' ? ( - + ) : null} )} -
) } -export const ClearAction = () => ( + +const ClearAction = () => ( ({ ...search, filters: null, labels: null })} > - + Clear all filters ) diff --git a/assets/js/dashboard/nav-menu/top-bar.tsx b/assets/js/dashboard/nav-menu/top-bar.tsx index d2134a4fea9c..5c1074153585 100644 --- a/assets/js/dashboard/nav-menu/top-bar.tsx +++ b/assets/js/dashboard/nav-menu/top-bar.tsx @@ -10,6 +10,7 @@ import Filters from '../filters' import classNames from 'classnames' import { useInView } from 'react-intersection-observer' import { FilterMenu } from './filter-menu' +import SegmentExpandedContextProvider from '../segments/segment-expanded-context' interface TopBarProps { showCurrentVisitors: boolean @@ -28,27 +29,36 @@ export function TopBar({ showCurrentVisitors, extraBar }: TopBarProps) {
-
+
- {showCurrentVisitors && ( + {saved_segments && !extraBar && showCurrentVisitors && ( )} - {saved_segments ? : } + {saved_segments && !!extraBar && extraBar} + {saved_segments ? ( + + + + ) : ( + + )}
- {!!saved_segments && !!extraBar && extraBar}
) diff --git a/assets/js/dashboard/segments/segment-authorship.tsx b/assets/js/dashboard/segments/segment-authorship.tsx new file mode 100644 index 000000000000..e0c2acc50521 --- /dev/null +++ b/assets/js/dashboard/segments/segment-authorship.tsx @@ -0,0 +1,55 @@ +/** @format */ + +import React from 'react' +import { SavedSegment } from './segments' +import { PlausibleSite, useSiteContext } from '../site-context' +import { formatDayShort, parseUTCDate } from '../util/date' + +const getAuthorLabel = ( + site: Pick, + owner_id: number +) => { + if (!site.members) { + return '' + } + + if (!owner_id || !site.members[owner_id]) { + return '(Removed User)' + } + + // if (owner_id === user.id) { + // return 'You' + // } + + return site.members[owner_id] +} + +export const SegmentAuthorship = ({ + className, + owner_id, + inserted_at, + updated_at +}: SavedSegment & { + className?: string +}) => { + const site = useSiteContext() + + const authorLabel = getAuthorLabel(site, owner_id) + + const showUpdatedAt = updated_at !== inserted_at + + return ( +
+
+ {`Created at ${formatDayShort(parseUTCDate(inserted_at))}`} + {!showUpdatedAt && !!authorLabel && ` by ${authorLabel}`} +
+ {showUpdatedAt && ( +
+ {`Last updated at ${formatDayShort(parseUTCDate(updated_at))}`} + {!!authorLabel && ` by ${authorLabel}`} +
+ )} +
+ ) +} diff --git a/assets/js/dashboard/segments/segment-expanded-context.tsx b/assets/js/dashboard/segments/segment-expanded-context.tsx new file mode 100644 index 000000000000..64e593f33268 --- /dev/null +++ b/assets/js/dashboard/segments/segment-expanded-context.tsx @@ -0,0 +1,68 @@ +/* @format */ +import React, { + createContext, + ReactNode, + useContext, + useLayoutEffect, + useState +} from 'react' +import { useLocation } from 'react-router-dom' +import { SavedSegment } from './segments' +import { useQueryContext } from '../query-context' + +export type SegmentExpandedLocationState = { + expandedSegment: SavedSegment | null + modal: 'create' | 'update' | 'delete' | null +} + +const segmentExpandedContextDefaultValue: SegmentExpandedLocationState = { + expandedSegment: null, + modal: null +} + +const SegmentExpandedContext = createContext< + typeof segmentExpandedContextDefaultValue +>(segmentExpandedContextDefaultValue) + +export const useSegmentExpandedContext = () => { + return useContext(SegmentExpandedContext) +} + +export default function SegmentExpandedContextProvider({ + children +}: { + children: ReactNode +}) { + const { query } = useQueryContext() + + const { state: locationState } = useLocation() as { + state?: SegmentExpandedLocationState + } + + const [expandedSegment, setExpandedSegment] = useState( + null + ) + + useLayoutEffect(() => { + if (locationState?.expandedSegment) { + setExpandedSegment(locationState?.expandedSegment) + } + if (locationState?.expandedSegment === null) { + setExpandedSegment(null) + } + }, [locationState?.expandedSegment]) + + useLayoutEffect(() => { + if (!query.filters.length) { + setExpandedSegment(null) + } + }, [query.filters.length]) + + return ( + + {children} + + ) +} diff --git a/assets/js/dashboard/segments/segment-modals.tsx b/assets/js/dashboard/segments/segment-modals.tsx new file mode 100644 index 000000000000..3be2d6099407 --- /dev/null +++ b/assets/js/dashboard/segments/segment-modals.tsx @@ -0,0 +1,630 @@ +/** @format */ + +import React, { ReactNode, useCallback, useEffect, useState } from 'react' +import ModalWithRouting from '../stats/modals/modal' +import { + formatSegmentIdAsLabelKey, + getFilterSegmentsByNameInsensitive, + isSegmentFilter, + SavedSegment, + SegmentData, + SegmentType +} from './segments' +import { + EditSegmentIcon, + useSegmentPrefetch, + useSegmentsListQuery +} from './segments-dropdown' +import { SearchInput } from '../components/search-input' +import { useQueryContext } from '../query-context' +import { AppNavigationLink } from '../navigation/use-app-navigate' +import { cleanLabels, plainFilterText, styledFilterText } from '../util/filters' +import { rootRoute } from '../router' +import { FilterPillsList } from '../nav-menu/filter-pills-list' +import classNames from 'classnames' +import { + XMarkIcon, + ChevronUpIcon, + ChevronDownIcon, + // TrashIcon, + CheckIcon +} from '@heroicons/react/24/outline' +import { FilterPill } from '../nav-menu/filter-pill' +import { Filter } from '../query' +import { SegmentAuthorship } from './segment-authorship' +// import { SegmentExpandedLocationState } from './segment-expanded-context' + +const buttonClass = + 'transition border text-md font-medium py-3 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500' + +const primaryNeutralButtonClass = classNames( + buttonClass, + 'bg-indigo-600 hover:bg-indigo-700 text-white border-transparent' +) + +const primaryNegativeButtonClass = classNames( + buttonClass, + 'border-transparent bg-red-500 hover:bg-red-600 text-white border-transparent' +) + +const secondaryButtonClass = classNames( + buttonClass, + 'border-indigo-500 text-indigo-500 hover:border-indigo-600 hover:text-indigo-600', + 'dark:hover:border-indigo-400 dark:hover:text-indigo-400' +) + +const SegmentActionModal = ({ + children, + onClose +}: { + children: ReactNode + onClose: () => void +}) => ( + + {children} + +) + +export const CreateSegmentModal = ({ + segment, + onClose, + onSave, + canTogglePersonal, + namePlaceholder +}: { + segment?: SavedSegment + onClose: () => void + onSave: (input: Pick) => void + canTogglePersonal: boolean + namePlaceholder: string +}) => { + const [name, setName] = useState( + segment?.name ? `Copy of ${segment.name}` : '' + ) + const [type, setType] = useState( + segment?.type === SegmentType.site && canTogglePersonal + ? SegmentType.site + : SegmentType.personal + ) + + return ( + + Create segment + + + + + + + + ) +} + +export const DeleteSegmentModal = ({ + onClose, + onSave, + segment +}: { + onClose: () => void + onSave: (input: Pick) => void + segment: SavedSegment & { segment_data?: SegmentData } +}) => { + return ( + + + { + { personal: 'Delete personal segment', site: 'Delete site segment' }[ + segment.type + ] + } + {` "${segment.name}"?`} + + {segment?.segment_data && ( + ({ + // className: 'dark:!bg-gray-700', + plainText: plainFilterText(segment.segment_data!.labels, filter), + children: styledFilterText(segment.segment_data!.labels, filter), + interactive: false + }))} + /> + )} + + + + + + + ) +} + +const FormTitle = ({ children }: { children?: ReactNode }) => ( +

{children}

+) + +const ButtonsRow = ({ children }: { children?: ReactNode }) => ( +
{children}
+) + +const SegmentNameInput = ({ + namePlaceholder, + value, + onChange +}: { + namePlaceholder: string + value: string + onChange: (value: string) => void +}) => { + return ( + <> + + onChange(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" + /> + + ) +} + +const radioClassName = + 'w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:border-gray-600' +const radioLabelClassName = + 'ms-3 text-sm font-medium text-gray-900 dark:text-gray-300' +const SegmentTypeInput = ({ + value, + onChange, + disabled +}: { + value: SegmentType + onChange: (value: SegmentType) => void + disabled?: boolean +}) => ( + <> +
+
+ onChange(SegmentType.personal)} + className={radioClassName} + disabled={disabled} + /> + +
+
+ onChange(SegmentType.site)} + className={radioClassName} + disabled={disabled} + /> + +
+
+ +) + +export const UpdateSegmentModal = ({ + close, + onSave, + segment, + canTogglePersonal, + namePlaceholder +}: { + close: () => void + onSave: (input: Pick) => void + segment: SavedSegment + canTogglePersonal: boolean + namePlaceholder: string +}) => { + const [name, setName] = useState(segment.name) + const [type, setType] = useState(segment.type) + + return ( + + Update segment + + + + + + + + ) +} + +const SegmentRow = ({ + id, + name, + toggleSelected, + selected +}: SavedSegment & { toggleSelected: () => void; selected: boolean }) => { + const { prefetchSegment, data, expandSegment, fetchSegment } = + useSegmentPrefetch({ + id + }) + const [segmentDataVisible, setSegmentDataVisible] = useState(false) + const toggleSegmentDataVisible = useCallback(async () => { + setSegmentDataVisible((currentVisible) => { + if (currentVisible) { + return false + } + fetchSegment() + return true + }) + }, [fetchSegment]) + return ( +
+ + + + + {segmentDataVisible && ( +
+ {data?.segment_data ? ( + ({ + // className: 'dark:!bg-gray-700', + plainText: plainFilterText(data.segment_data.labels, filter), + children: styledFilterText(data.segment_data.labels, filter), + interactive: false + }))} + /> + ) : ( + 'loading' + )} + {!!data && } + {!!data && ( +
+ + {/* s} + state={ + { + expandedSegment: data, + modal: 'delete' + } as SegmentExpandedLocationState + } + // onClick={async () => { + // expandSegment(data ?? (await fetchSegment())) + // }} + > + + Delete + */} +
+ )} +
+ )} +
+ ) +} + +export const AllSegmentsModal = () => { + const { query } = useQueryContext() + const querySegmentIds: number[] = + (query.filters.find(isSegmentFilter)?.[2] as number[]) ?? [] + const { data } = useSegmentsListQuery() + const [search, setSearch] = useState() + const [selectedSegmentIds, setSelectedSegmentIds] = + useState(querySegmentIds) + const getToggleSelected = useCallback( + (id: number) => () => + setSelectedSegmentIds((current) => + current.includes(id) + ? current.filter((i) => i !== id) + : current.concat([id]) + ), + [] + ) + + const proposedSegmentFilter: Filter | null = selectedSegmentIds.length + ? ['is', 'segment', selectedSegmentIds] + : null + + const labelsForProposedSegmentFilter = !data + ? {} + : Object.fromEntries( + data?.flatMap((d) => + selectedSegmentIds.includes(d.id) + ? [[formatSegmentIdAsLabelKey(d.id), d.name]] + : [] + ) + ) + + const searchResults = data?.filter(getFilterSegmentsByNameInsensitive(search)) + + const personalSegments = searchResults?.filter( + (i) => i.type === SegmentType.personal + ) + const siteSegments = searchResults?.filter((i) => i.type === SegmentType.site) + + const [upToPersonalSegment, setUpToPersonalSegment] = useState(4) + const [upToSiteSegment, setUpToSiteSegment] = useState(4) + + useEffect(() => { + setUpToPersonalSegment(4) + setUpToSiteSegment(4) + }, [data, search]) + + return ( + +
+
+

Segments

+
+ setSearch(v)} /> +
+
+ +
+ {[ + { + segments: personalSegments, + title: 'Personal', + sliceEnd: upToPersonalSegment, + showMore: () => setUpToPersonalSegment((curr) => curr + 10) + }, + { + segments: siteSegments, + title: 'Site', + sliceEnd: upToSiteSegment, + showMore: () => setUpToSiteSegment((curr) => curr + 10) + } + ] + .filter((i) => !!i.segments?.length) + .map(({ segments, title, sliceEnd, showMore }) => ( + <> +

+ {title} +

+ {segments!.slice(0, sliceEnd).map((item) => ( + + ))} + {segments?.length && sliceEnd < segments.length && ( + + )} + + ))} + {!personalSegments?.length && !siteSegments?.length && ( +

No segments found.

+ )} +
+ +
+

Selected filter

+ + {!!data && !!proposedSegmentFilter && ( +
+ setSelectedSegmentIds([])} + > + + + } + > + {styledFilterText( + labelsForProposedSegmentFilter, + proposedSegmentFilter + )} + +
+ )} + {proposedSegmentFilter === null && ( +

No segments selected.

+ )} + + { + const nonSegmentFilters = query.filters.filter( + (f) => !isSegmentFilter(f) + ) + if (!proposedSegmentFilter) { + return { + ...s, + filters: nonSegmentFilters, + labels: cleanLabels( + nonSegmentFilters, + query.labels, + 'segment', + {} + ) + } + } + const filters = nonSegmentFilters.concat([proposedSegmentFilter]) + const labels = cleanLabels( + filters, + query.labels, + 'segment', + Object.fromEntries( + selectedSegmentIds.map((id) => [ + formatSegmentIdAsLabelKey(id), + data?.find((i) => i.id === id)?.name ?? '' + ]) + ) + ) + return { + ...s, + filters, + labels + } + }} + > + Apply + + { + const nonSegmentFilters = query.filters.filter( + (f) => !isSegmentFilter(f) + ) + return { + ...s, + filters: nonSegmentFilters, + labels: cleanLabels( + nonSegmentFilters, + query.labels, + 'segment', + {} + ) + } + }} + > + Clear + + +
+
+ ) +} diff --git a/assets/js/dashboard/segments/segments-dropdown.tsx b/assets/js/dashboard/segments/segments-dropdown.tsx new file mode 100644 index 000000000000..7872a91fdb14 --- /dev/null +++ b/assets/js/dashboard/segments/segments-dropdown.tsx @@ -0,0 +1,488 @@ +/** @format */ + +import React, { ReactNode, useCallback, useMemo, useState } from 'react' +import { + DropdownLinkGroup, + DropdownNavigationLink, + DropdownSubtitle +} from '../components/dropdown' +import { useQueryContext } from '../query-context' +import { useSiteContext } from '../site-context' +import { + formatSegmentIdAsLabelKey, + getFilterSegmentsByNameInsensitive, + isSegmentFilter, + parseApiSegmentData, + SavedSegment, + SegmentData, + SegmentType +} from './segments' +import { QueryFunction, useQuery, useQueryClient } from '@tanstack/react-query' +import { cleanLabels } from '../util/filters' +import { + AppNavigationLink, + useAppNavigate +} from '../navigation/use-app-navigate' +import classNames from 'classnames' +import { Tooltip } from '../util/tooltip' +import { useUserContext } from '../user-context' +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/solid' +import { + SegmentExpandedLocationState, + useSegmentExpandedContext +} from './segment-expanded-context' +import { filterRoute, rootRoute } from '../router' +import { SearchInput } from '../components/search-input' +import { SegmentAuthorship } from './segment-authorship' + +export const useSegmentsListQuery = () => { + const site = useSiteContext() + const { query } = useQueryContext() + const segmentsFilter = query.filters.find(isSegmentFilter) + const appliedSegmentIds = segmentsFilter + ? (segmentsFilter[2] as number[]) + : [] + return 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 => res.json()) + + return response.sort( + (a, b) => + appliedSegmentIds.findIndex((id) => id === b.id) - + appliedSegmentIds.findIndex((id) => id === a.id) + ) + } + }) +} + +const linkClass = 'text-xs' + +export const SegmentsList = ({ closeList }: { closeList: () => void }) => { + const { expandedSegment } = useSegmentExpandedContext() + const { query } = useQueryContext() + + const { data } = useSegmentsListQuery() + + const segmentFilter = query.filters.find(isSegmentFilter) + const appliedSegmentIds = (segmentFilter ? segmentFilter[2] : []) as number[] + + const [search, setSearch] = useState() + + if (expandedSegment) { + return ( + <> + ({ + ...s, + filters: [['is', 'segment', [expandedSegment.id]]], + labels: { + [formatSegmentIdAsLabelKey(expandedSegment.id)]: + expandedSegment.name + } + })} + state={ + { + expandedSegment: null, + modal: null + } as SegmentExpandedLocationState + } + > + +
Cancel editing
+
+ + + {expandedSegment.name} + + s} + navigateOptions={{ + state: { + expandedSegment: expandedSegment, + modal: 'update' + } as SegmentExpandedLocationState + }} + onClick={closeList} + > + Update segment + + s} + navigateOptions={{ + state: { + expandedSegment: expandedSegment, + modal: 'create' + } as SegmentExpandedLocationState + }} + onClick={closeList} + > + Save as a new segment + + s} + navigateOptions={{ + state: { + expandedSegment: expandedSegment, + modal: 'delete' + } as SegmentExpandedLocationState + }} + onClick={closeList} + > + Delete segment + + + + ) + } + + const filteredData = data?.filter(getFilterSegmentsByNameInsensitive(search)) + + const personalSegments = filteredData?.filter( + (i) => i.type === SegmentType.personal + ) + const siteSegments = filteredData?.filter((i) => i.type === SegmentType.site) + + return ( + <> + {!!data?.length && ( + + Segments + +
+ +
+ {[ + { segments: personalSegments, title: 'Personal' }, + { segments: siteSegments, title: 'Site' } + ] + .filter((i) => !!i.segments?.length) + .map(({ segments, title }) => ( + <> + + {title} + + + {segments!.slice(0, 3).map((s) => { + return ( + +
{s.name}
+ +
+ } + > + + + ) + })} + + ))} + {!!data?.length && ( + s} + onLinkClick={closeList} + > + View all + + + )} + + )} + + + {/* s} + navigateOptions={{ + state: { + modal: 'create', + expandedSegment: null + } as SegmentExpandedLocationState + }} + onLinkClick={closeList} + {...((query.filters.some(isSegmentFilter) || + !query.filters.length) && { + 'aria-disabled': true, + navigateOptions: undefined, + onLinkClick: undefined + })} + > + Save selection as segment + */} + + + ) +} + +const SaveSelectionAsSegment = ({ closeList }: { closeList: () => void }) => { + const { query } = useQueryContext() + const disabledReason = !query.filters.length + ? 'Add filters to the dashboard to save a segment.' + : query.filters.some(isSegmentFilter) + ? 'Remove the segment filter to save a segment. Segments can not contain other segments.' + : null + if (disabledReason === null) { + return ( + s} + navigateOptions={{ + state: { + modal: 'create', + expandedSegment: null + } as SegmentExpandedLocationState + }} + onLinkClick={closeList} + > + Save as segment + + ) + } + + return ( + {disabledReason}
}> + s} + aria-disabled={true} + > + Save as segment + + + ) +} + +export const useSegmentPrefetch = ({ id }: Pick) => { + const site = useSiteContext() + const queryClient = useQueryClient() + const queryKey = useMemo(() => ['segments', id] as const, [id]) + const navigate = useAppNavigate() + + const getSegmentFn: QueryFunction< + SavedSegment & { segment_data: SegmentData }, + typeof queryKey + > = useCallback( + 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) + } + }, + [site] + ) + + const getSegment = useQuery({ + enabled: false, + queryKey: queryKey, + queryFn: getSegmentFn + }) + + const prefetchSegment = useCallback( + () => + queryClient.prefetchQuery({ + queryKey, + queryFn: getSegmentFn, + staleTime: 120_000 + }), + [queryClient, getSegmentFn, queryKey] + ) + + const fetchSegment = useCallback( + () => + queryClient.fetchQuery({ + queryKey, + queryFn: getSegmentFn + }), + [queryClient, getSegmentFn, queryKey] + ) + + const expandSegment = useCallback( + (segment: SavedSegment & { segment_data: SegmentData }) => { + try { + navigate({ + path: rootRoute.path, + search: (search) => ({ + ...search, + filters: segment.segment_data.filters, + labels: segment.segment_data.labels + }), + state: { + expandedSegment: { + id: segment.id, + name: segment.name, + type: segment.type, + owner_id: segment.owner_id + } + } as SegmentExpandedLocationState + }) + } catch (_error) { + return + } + }, + [navigate] + ) + + return { prefetchSegment, data: getSegment.data, fetchSegment, expandSegment } +} + +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 { query } = useQueryContext() + const { prefetchSegment, expandSegment, data, fetchSegment } = + useSegmentPrefetch({ id }) + + return ( + { + 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 : ( + <> + + + ) + } + > +
{name}
+
+ ) +} + +export const iconButtonClass = + 'flex items-center justify-center w-5 h-5 fill-current hover:fill-indigo-600' + +export const EditSegment = ({ + children, + className, + onClick, + onMouseEnter +}: { + children?: ReactNode + onClick: () => Promise + onMouseEnter?: () => Promise + className?: string +}) => { + return ( + + ) +} + +export const EditSegmentIcon = ({ className }: { className?: string }) => ( + + + +) diff --git a/assets/js/dashboard/segments/segments.ts b/assets/js/dashboard/segments/segments.ts new file mode 100644 index 000000000000..14803252e544 --- /dev/null +++ b/assets/js/dashboard/segments/segments.ts @@ -0,0 +1,63 @@ +/** @format */ + +import { DashboardQuery, Filter } from '../query' +import { plainFilterText, remapFromApiFilters } from '../util/filters' + +export enum SegmentType { + personal = 'personal', + site = 'site' +} + +export type SavedSegment = { + id: number + name: string + type: SegmentType + owner_id: number + inserted_at: string + updated_at: string +} + +export type SegmentData = { + filters: Filter[] + labels: Record +} + +const SEGMENT_LABEL_KEY_PREFIX = 'segment-' + +export function getFilterSegmentsByNameInsensitive( + search?: string +): (s: SavedSegment) => boolean { + return (s) => + search?.trim().length + ? s.name.toLowerCase().includes(search.trim().toLowerCase()) + : true +} + +export const getSegmentNamePlaceholder = (query: DashboardQuery) => + query.filters + .reduce( + (combinedName, filter) => + combinedName.length > 100 + ? combinedName + : `${combinedName}${combinedName.length ? ' and ' : ''}${plainFilterText(query.labels, filter)}`, + '' + ) + .slice(0, 255) + +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 +}) diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 012ca3371494..67323ddfe1f2 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -26,6 +26,8 @@ describe('parseSiteFromDataset', () => { data-embedded="" data-is-dbip="false" data-current-user-role="owner" + data-current-user-id="1" + data-members='{"1":"Test User"}' data-flags="{}" data-valid-intervals-by-period='{"12mo":["day","week","month"],"30d":["day","week"],"6mo":["day","week","month"],"7d":["hour","day"],"all":["week","month"],"custom":["day","week","month"],"day":["minute","hour"],"month":["day","week"],"realtime":["minute"],"year":["day","week","month"]}' {...attrs} @@ -61,7 +63,8 @@ describe('parseSiteFromDataset', () => { realtime: ['minute'], year: ['day', 'week', 'month'] }, - shared: false + shared: false, + members: {"1": "Test User"} } it('parses from dom string map correctly', () => { diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index 80e6f2ba5219..6d781ea36bc5 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -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>, - shared: false + shared: false, + members: null as null | Record } export type PlausibleSite = typeof siteContextDefaultValue diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js index 4a94245994ae..5b9b6f5f3567 100644 --- a/assets/js/dashboard/site-switcher.js +++ b/assets/js/dashboard/site-switcher.js @@ -234,7 +234,7 @@ export default class SiteSwitcher extends React.Component { : 'cursor-default' return ( -
+
-
- {this.props.children} -
+
+
+ +
+ {this.props.children}
- -, - document.getElementById("modal_root"), - ); +
, + document.getElementById('modal_root') + ) } } export default function ModalWithRouting(props) { const navigate = useAppNavigate() - return + const defaultCloseHandler = () => + navigate({ path: rootRoute.path, search: (s) => s }) + const closeHandler = props.onClose ?? defaultCloseHandler + return ( + <> + + + navigate({ path: rootRoute.path, search: (search) => search }) + } + {...props} + /> + + ) } diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index 2908778e20f5..cae4cf5f9625 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -46,7 +46,7 @@ export function FilterLink({ return ( (userContextDefaultValue) export const useUserContext = () => { return useContext(UserContext) } export default function UserContextProvider({ - role, - loggedIn, + user, children }: { - role: Role - loggedIn: boolean + user: UserContextValue children: ReactNode }) { - return ( - - {children} - - ) + return {children} } diff --git a/assets/js/dashboard/util/filters.js b/assets/js/dashboard/util/filters.js index d8d13a70dab5..4f3dbd66382b 100644 --- a/assets/js/dashboard/util/filters.js +++ b/assets/js/dashboard/util/filters.js @@ -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) { @@ -139,11 +148,11 @@ export function useIsRealtimeDashboard() { return useMemo(() => isRealTimeDashboard({ period }), [period]) } -export function plainFilterText(query, [operation, filterKey, clauses]) { +export function plainFilterText(labels, [operation, filterKey, clauses]) { const formattedFilter = formattedFilters[filterKey] if (formattedFilter) { - return `${formattedFilter} ${FILTER_OPERATIONS_DISPLAY_NAMES[operation]} ${clauses.map((value) => getLabel(query.labels, filterKey, value)).reduce((prev, curr) => `${prev} or ${curr}`)}` + return `${formattedFilter} ${FILTER_OPERATIONS_DISPLAY_NAMES[operation]} ${clauses.map((value) => getLabel(labels, filterKey, value)).reduce((prev, curr) => `${prev} or ${curr}`)}` } else if (filterKey.startsWith(EVENT_PROPS_PREFIX)) { const propKey = getPropertyKeyFromFilterKey(filterKey) return `Property ${propKey} ${FILTER_OPERATIONS_DISPLAY_NAMES[operation]} ${clauses.reduce((prev, curr) => `${prev} or ${curr}`)}` @@ -152,7 +161,7 @@ export function plainFilterText(query, [operation, filterKey, clauses]) { throw new Error(`Unknown filter: ${filterKey}`) } -export function styledFilterText(query, [operation, filterKey, clauses]) { +export function styledFilterText(labels, [operation, filterKey, clauses]) { const formattedFilter = formattedFilters[filterKey] if (formattedFilter) { @@ -161,7 +170,7 @@ export function styledFilterText(query, [operation, filterKey, clauses]) { {formattedFilter} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '} {clauses .map((value) => ( - {getLabel(query.labels, filterKey, value)} + {getLabel(labels, filterKey, value)} )) .reduce((prev, curr) => [prev, ' or ', curr])}{' '} @@ -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:' -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] +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] }) +} - return JSON.stringify(apiFilters) +export function serializeApiFilters(filters) { + 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) { diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index 06121a8166e3..4f11ad048329 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -64,7 +64,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 @@ -92,6 +92,15 @@ export type FilterWithGoals = [ * filter operation */ export type FilterOperationWithGoals = "is" | "contains"; +/** + * @minItems 3 + * @maxItems 3 + */ +export type FilterForSegment = [FilterOperationForSegments, "segment", Clauses]; +/** + * filter operation + */ +export type FilterOperationForSegments = "is"; /** * @minItems 2 * @maxItems 2 diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 5d26d3f2f5cb..f3558309c619 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -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 ( // not interactive component, default value is suitable - + 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 diff --git a/lib/plausible/segment.ex b/lib/plausible/segment.ex new file mode 100644 index 000000000000..fc5809e15b19 --- /dev/null +++ b/lib/plausible/segment.ex @@ -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 diff --git a/lib/plausible/segment/segments.md b/lib/plausible/segment/segments.md new file mode 100644 index 000000000000..4c6a203450ce --- /dev/null +++ b/lib/plausible/segment/segments.md @@ -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) \ No newline at end of file diff --git a/lib/plausible/site.ex b/lib/plausible/site.ex index 20915c0a37a6..95cb5b29b468 100644 --- a/lib/plausible/site.ex +++ b/lib/plausible/site.ex @@ -47,6 +47,7 @@ defmodule Plausible.Site do has_many :invitations, Plausible.Auth.Invitation has_many :goals, Plausible.Goal, preload_order: [desc: :id] has_many :revenue_goals, Plausible.Goal, where: [currency: {:not, nil}] + has_many :segments, Plausible.Segment, preload_order: [asc: :name] has_one :google_auth, GoogleAuth has_one :weekly_report, Plausible.Site.WeeklyReport has_one :monthly_report, Plausible.Site.MonthlyReport diff --git a/lib/plausible/stats/filter_suggestions.ex b/lib/plausible/stats/filter_suggestions.ex index f3134f198f4e..10b086206a3f 100644 --- a/lib/plausible/stats/filter_suggestions.ex +++ b/lib/plausible/stats/filter_suggestions.ex @@ -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}%" diff --git a/lib/plausible/stats/filters/filters.ex b/lib/plausible/stats/filters/filters.ex index c68e2b3f8651..a72952841d3d 100644 --- a/lib/plausible/stats/filters/filters.ex +++ b/lib/plausible/stats/filters/filters.ex @@ -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 """ diff --git a/lib/plausible/stats/filters/filters_parser.ex b/lib/plausible/stats/filters/filters_parser.ex new file mode 100644 index 000000000000..5d5187f77ca4 --- /dev/null +++ b/lib/plausible/stats/filters/filters_parser.ex @@ -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 diff --git a/lib/plausible/stats/filters/query_parser.ex b/lib/plausible/stats/filters/query_parser.ex index b7410964b854..5ad6459ac8f6 100644 --- a/lib/plausible/stats/filters/query_parser.ex +++ b/lib/plausible/stats/filters/query_parser.ex @@ -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."} @@ -554,7 +463,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 -> @@ -599,22 +508,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 diff --git a/lib/plausible/stats/filters/segments.ex b/lib/plausible/stats/filters/segments.ex new file mode 100644 index 000000000000..a55dd7279110 --- /dev/null +++ b/lib/plausible/stats/filters/segments.ex @@ -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 diff --git a/lib/plausible/stats/legacy/legacy_query_builder.ex b/lib/plausible/stats/legacy/legacy_query_builder.ex index f555fa491e98..84696e5f73fd 100644 --- a/lib/plausible/stats/legacy/legacy_query_builder.ex +++ b/lib/plausible/stats/legacy/legacy_query_builder.ex @@ -19,6 +19,7 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do |> put_dimensions(params) |> put_interval(params) |> put_parsed_filters(params) + |> put_preloaded_segments(site) |> put_preloaded_goals(site) |> put_order_by(params) |> 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( diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex index c4e18a01ec4c..b1a96dad168d 100644 --- a/lib/plausible/stats/query.ex +++ b/lib/plausible/stats/query.ex @@ -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: %{}, diff --git a/lib/plausible/stats/query_optimizer.ex b/lib/plausible/stats/query_optimizer.ex index 827dfebb169c..b8601b69621d 100644 --- a/lib/plausible/stats/query_optimizer.ex +++ b/lib/plausible/stats/query_optimizer.ex @@ -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 diff --git a/lib/plausible/stats/table_decider.ex b/lib/plausible/stats/table_decider.ex index 6820f7c607fd..5e17027263c4 100644 --- a/lib/plausible/stats/table_decider.ex +++ b/lib/plausible/stats/table_decider.ex @@ -75,8 +75,14 @@ defmodule Plausible.Stats.TableDecider do defp metric_partitioner(_, :average_revenue), do: :event defp metric_partitioner(_, :total_revenue), do: :event defp metric_partitioner(_, :scroll_depth), do: :event - defp metric_partitioner(_, :pageviews), do: :event - defp metric_partitioner(_, :events), do: :event + defp metric_partitioner(q, :pageviews) do + IO.inspect([:pageviews, q.filters, q.dimensions]) + :event + end + defp metric_partitioner(q, :events) do + IO.inspect([:events, q.filters, q.dimensions]) + :event + end defp metric_partitioner(_, :bounce_rate), do: :session defp metric_partitioner(_, :visit_duration), do: :session defp metric_partitioner(_, :views_per_visit), do: :session diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex index 0adebbd506d0..d50ab685fb3b 100644 --- a/lib/plausible_web/controllers/api/external_stats_controller.ex +++ b/lib/plausible_web/controllers/api/external_stats_controller.ex @@ -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) -> diff --git a/lib/plausible_web/controllers/api/helpers.ex b/lib/plausible_web/controllers/api/helpers.ex index 1c3a9e98bc59..109e3a1263dc 100644 --- a/lib/plausible_web/controllers/api/helpers.ex +++ b/lib/plausible_web/controllers/api/helpers.ex @@ -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) diff --git a/lib/plausible_web/controllers/api/internal/segments_controller.ex b/lib/plausible_web/controllers/api/internal/segments_controller.ex new file mode 100644 index 000000000000..ffbed5212911 --- /dev/null +++ b/lib/plausible_web/controllers/api/internal/segments_controller.ex @@ -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: [desc: segment.updated_at] + ) + 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 diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 89b73d5caed6..b39942326715 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -295,7 +295,7 @@ defmodule PlausibleWeb.Api.StatsController do end defp fetch_top_stats(site, query, current_user) 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? -> @@ -393,7 +393,7 @@ defmodule PlausibleWeb.Api.StatsController do end defp fetch_other_top_stats(site, query, current_user) 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] @@ -474,7 +474,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]) @@ -561,10 +561,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" -> @@ -588,7 +588,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]) @@ -616,7 +616,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]) @@ -644,7 +644,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]) @@ -672,7 +672,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]) @@ -700,7 +700,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]) @@ -728,7 +728,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]) @@ -854,7 +854,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]) @@ -882,7 +882,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, @@ -918,7 +918,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, @@ -991,7 +991,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]) @@ -1051,7 +1051,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]) @@ -1095,7 +1095,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]) @@ -1127,7 +1127,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]) @@ -1159,7 +1159,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]) @@ -1200,7 +1200,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]) @@ -1232,7 +1232,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]) @@ -1273,7 +1273,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]) @@ -1363,7 +1363,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 @@ -1380,7 +1380,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 @@ -1551,10 +1551,10 @@ defmodule PlausibleWeb.Api.StatsController do end requires_goal_filter? = metric in [:conversion_rate, :events] - has_goal_filter? = Filters.filtering_on_dimension?(query, "event:goal") + has_goal_filter? = Filters.filtering_on_dimension?(query.filters, "event:goal") requires_page_filter? = metric == :scroll_depth - has_page_filter? = Filters.filtering_on_dimension?(query, "event:page") + has_page_filter? = Filters.filtering_on_dimension?(query.filters, "event:page") cond do requires_goal_filter? and not has_goal_filter? -> @@ -1592,7 +1592,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 diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index c040f5c6a774..1de4ace5d3f0 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -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 diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 335fe37ca39b..bc42d63281f7 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -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 diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 4a2d69ab5b05..5b004cb4d8d0 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -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!() diff --git a/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index e715c5957184..d6853b19277f 100644 --- a/priv/json-schemas/query-api-schema.json +++ b/priv/json-schemas/query-api-schema.json @@ -351,6 +351,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, @@ -396,10 +401,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": { diff --git a/test/plausible/segment_schema_test.exs b/test/plausible/segment_schema_test.exs new file mode 100644 index 000000000000..93622b631eca --- /dev/null +++ b/test/plausible/segment_schema_test.exs @@ -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 diff --git a/test/plausible/stats/query_parser_test.exs b/test/plausible/stats/query_parser_test.exs index 53e998d23a99..8fc156525658 100644 --- a/test/plausible/stats/query_parser_test.exs +++ b/test/plausible/stats/query_parser_test.exs @@ -50,7 +50,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 @@ -498,6 +498,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do "metrics" => ["visitors"], "date_range" => "all", "filters" => [ + ["is", "segment", [200]], [ "or", [ @@ -517,6 +518,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do metrics: [:visitors], utc_time_range: @date_range_day, filters: [ + [:is, "segment", [200]], [ :or, [ diff --git a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs new file mode 100644 index 000000000000..9d193be380dd --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs @@ -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 diff --git a/test/plausible_web/controllers/api/stats_controller/countries_test.exs b/test/plausible_web/controllers/api/stats_controller/countries_test.exs index db3b2f06972b..c6ca8142f202 100644 --- a/test/plausible_web/controllers/api/stats_controller/countries_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/countries_test.exs @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex index 62de00560b30..0be437e7ac55 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -402,6 +402,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),