diff --git a/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx b/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx index fa05cbb2ae2f3..3d1348293a0c7 100644 --- a/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { PropertyOperator } from '~/types' import { Col, Select, SelectProps } from 'antd' -import { isOperatorFlag, isOperatorMulti, operatorMap } from 'lib/utils' +import { isMobile, isOperatorFlag, isOperatorMulti, operatorMap } from 'lib/utils' import { PropertyValue } from './PropertyValue' import { ColProps } from 'antd/lib/col' @@ -70,6 +70,7 @@ export function OperatorValueSelect({ onSet={(newValue: string | number | string[] | null) => { onChange(currentOperator || PropertyOperator.Exact, newValue) }} + autoFocus={!isMobile()} /> )} diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.js b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.js deleted file mode 100644 index 2440e4b10e83e..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.js +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { AutoComplete, Select } from 'antd' -import { useThrottledCallback } from 'use-debounce' -import api from 'lib/api' -import { isMobile, isOperatorFlag, isOperatorMulti, isOperatorRegex, isValidRegex } from 'lib/utils' -import { SelectGradientOverflow } from 'lib/components/SelectGradientOverflow' - -export function PropertyValue({ - propertyKey, - type, - endpoint = undefined, - placeholder = undefined, - style = {}, - bordered = true, - onSet, - value, - operator, - outerOptions = undefined, -}) { - const isMultiSelect = isOperatorMulti(operator) - const [input, setInput] = useState(isMultiSelect ? '' : value) - const [optionsCache, setOptionsCache] = useState({}) - const [options, setOptions] = useState({}) - - const loadPropertyValues = useThrottledCallback((newInput) => { - if (type === 'cohort') { - return - } - let key = propertyKey.split('__')[0] - setOptions({ [propertyKey]: { ...options[propertyKey], status: 'loading' }, ...options }) - setOptionsCache({ ...optionsCache, [newInput]: 'loading' }) - if (outerOptions) { - setOptions({ - [propertyKey]: { values: [...new Set([...outerOptions.map((option) => option)])], status: true }, - ...options, - }) - setOptionsCache({ ...optionsCache, [newInput]: true }) - } else { - api.get(endpoint || 'api/' + type + '/values/?key=' + key + (newInput ? '&value=' + newInput : '')).then( - (propValues) => { - setOptions({ - [propertyKey]: { values: [...new Set([...propValues.map((option) => option)])], status: true }, - ...options, - }) - setOptionsCache({ ...optionsCache, [newInput]: true }) - } - ) - } - }, 300) - - function setValue(newValue) { - onSet(newValue) - setInput('') - } - - useEffect(() => { - loadPropertyValues('') - }, [propertyKey]) - - let displayOptions - displayOptions = ((options[propertyKey] && options[propertyKey].values) || []).filter( - (option) => input === '' || (option && option.name?.toLowerCase().indexOf(input?.toLowerCase()) > -1) - ) - - const validationError = getValidationError(operator, value) - - const commonInputProps = { - autoFocus: !value && !isMobile(), - style: { width: '100%', ...style }, - value: (isMultiSelect ? value : input) || placeholder, - loading: optionsCache[input] === 'loading', - onSearch: (newInput) => { - setInput(newInput) - if (!optionsCache[newInput] && !isOperatorFlag(operator)) { - loadPropertyValues(newInput) - } - }, - ['data-attr']: 'prop-val', - dropdownMatchSelectWidth: 350, - bordered, - placeholder, - allowClear: value, - onKeyDown: (e) => { - if (e.key === 'Escape') { - e.target.blur() - } - if (!isMultiSelect && e.key === 'Enter') { - setValue(input) - } - }, - } - - return ( - <> - {!isMultiSelect ? ( - { - setInput(val ?? null) - }} - onSelect={(val) => { - setValue(val ?? null) - }} - > - {input && ( - - Specify: {input} - - )} - {displayOptions.map(({ name, id }, index) => ( - - {name === true && 'true'} - {name === false && 'false'} - {name} - - ))} - - ) : ( - { - if (isMultiSelect && payload.length > 0) { - setValue(val) - } else { - setValue(payload?.value ?? null) - } - }} - > - {input && ( - - Specify: {input} - - )} - {displayOptions.map(({ name, id }, index) => ( - - {name === true && 'true'} - {name === false && 'false'} - {name} - - ))} - - )} - {validationError &&

{validationError}

} - - ) -} - -function getValidationError(operator, value) { - if (isOperatorRegex(operator) && !isValidRegex(value)) { - return 'Value is not a valid regular expression' - } - - return null -} diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx new file mode 100644 index 0000000000000..3985f59c9c4dd --- /dev/null +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect, useRef } from 'react' +import { AutoComplete, Select } from 'antd' +import { useThrottledCallback } from 'use-debounce' +import api from 'lib/api' +import { isOperatorFlag, isOperatorMulti, isOperatorRegex, isValidRegex, toString } from 'lib/utils' +import { SelectGradientOverflow } from 'lib/components/SelectGradientOverflow' +import { PropertyOperator } from '~/types' + +type PropValue = { + id?: number + name?: string | boolean +} + +type Option = { + label?: string + name?: string + status?: 'loading' | 'loaded' + values?: PropValue[] +} + +interface PropertyValueProps { + propertyKey: string + type: string + endpoint?: string // Endpoint to fetch options from + placeholder?: string + style?: Partial + bordered?: boolean + onSet: CallableFunction + value?: string | number | Array | null + operator?: PropertyOperator + outerOptions?: Option[] // If no endpoint provided, options are given here + autoFocus?: boolean + allowCustom?: boolean +} + +function matchesLowerCase(needle?: string, haystack?: string): boolean { + if (typeof haystack !== 'string' || typeof needle !== 'string') { + return false + } + return haystack.toLowerCase().indexOf(needle.toLowerCase()) > -1 +} + +function getValidationError(operator: PropertyOperator, value: any): string | null { + if (isOperatorRegex(operator) && !isValidRegex(value)) { + return 'Value is not a valid regular expression' + } + return null +} + +export function PropertyValue({ + propertyKey, + type, + endpoint = undefined, + placeholder = undefined, + style = {}, + bordered = true, + onSet, + value, + operator, + outerOptions = undefined, + autoFocus = false, + allowCustom = true, +}: PropertyValueProps): JSX.Element { + const isMultiSelect = operator && isOperatorMulti(operator) + const [input, setInput] = useState(isMultiSelect ? '' : toString(value)) + const [options, setOptions] = useState({} as Record) + const autoCompleteRef = useRef(null) + + // update the input field if passed a new `value` prop + useEffect(() => { + if (!value) { + setInput('') + } else if (value !== input) { + const valueObject = options[propertyKey]?.values?.find((v) => v.id === value) + if (valueObject) { + setInput(toString(valueObject.name)) + } else { + setInput(toString(value)) + } + } + }, [value]) + + const loadPropertyValues = useThrottledCallback((newInput) => { + if (type === 'cohort') { + return + } + const key = propertyKey.split('__')[0] + setOptions({ [propertyKey]: { ...options[propertyKey], status: 'loading' }, ...options }) + if (outerOptions) { + setOptions({ + [propertyKey]: { + values: [...Array.from(new Set(outerOptions))], + status: 'loaded', + }, + ...options, + }) + } else { + api.get(endpoint || 'api/' + type + '/values/?key=' + key + (newInput ? '&value=' + newInput : '')).then( + (propValues: PropValue[]) => { + setOptions({ + [propertyKey]: { + values: [...Array.from(new Set(propValues))], + status: 'loaded', + }, + ...options, + }) + } + ) + } + }, 300) + + function setValue(newValue: PropertyValueProps['value']): void { + onSet(newValue) + if (isMultiSelect) { + setInput('') + } + } + + useEffect(() => { + loadPropertyValues('') + }, [propertyKey]) + + const displayOptions = (options[propertyKey]?.values || []).filter( + (option) => input === '' || matchesLowerCase(input, toString(option?.name)) + ) + + const validationError = operator ? getValidationError(operator, value) : null + + const commonInputProps = { + style: { width: '100%', ...style }, + loading: options[input]?.status === 'loading', + onSearch: (newInput: string) => { + setInput(newInput) + if (!Object.keys(options).includes(newInput) && !(operator && isOperatorFlag(operator))) { + loadPropertyValues(newInput) + } + }, + ['data-attr']: 'prop-val', + dropdownMatchSelectWidth: 350, + bordered, + placeholder, + allowClear: Boolean(value), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Escape' && e.target instanceof HTMLElement) { + e.target.blur() + } + if (!isMultiSelect && e.key === 'Enter') { + // We have not explicitly selected a dropdown item by pressing the up/down keys + if (autoCompleteRef.current?.querySelectorAll('.ant-select-item-option-active')?.length === 0) { + setValue(input) + } + } + }, + } + + return ( + <> + {isMultiSelect ? ( + { + if (Array.isArray(payload) && payload.length > 0) { + setValue(val) + } else if (payload instanceof Option) { + setValue(payload?.value ?? []) + } else { + setValue([]) + } + }} + > + {input && !displayOptions.some(({ name }) => input === toString(name)) && ( + + Specify: {input} + + )} + {displayOptions.map(({ name: _name }, index) => { + const name = toString(_name) + return ( + + {name} + + ) + })} + + ) : ( + { + setInput('') + setValue('') + }} + onChange={(val) => { + setInput(toString(val)) + }} + onSelect={(val, option) => { + setInput(option.title) + setValue(toString(val)) + }} + ref={autoCompleteRef} + > + {[ + ...(input && allowCustom && !displayOptions.some(({ name }) => input === toString(name)) + ? [ + + Specify: {input} + , + ] + : []), + ...displayOptions.map(({ name: _name, id }, index) => { + const name = toString(_name) + return ( + + {name} + + ) + }), + ]} + + )} + {validationError &&

{validationError}

} + + ) +} diff --git a/frontend/src/lib/components/SelectGradientOverflow.tsx b/frontend/src/lib/components/SelectGradientOverflow.tsx index f52a1416bb1c2..019e6125ca641 100644 --- a/frontend/src/lib/components/SelectGradientOverflow.tsx +++ b/frontend/src/lib/components/SelectGradientOverflow.tsx @@ -1,8 +1,9 @@ import React, { ReactElement, RefObject, useEffect, useRef, useState } from 'react' import { Select, Tag, Tooltip } from 'antd' import { RefSelectProps, SelectProps } from 'antd/lib/select' -import './SelectGradientOverflow.scss' import { CloseButton } from './CloseButton' +import { toString } from 'lib/utils' +import './SelectGradientOverflow.scss' interface DropdownGradientRendererProps { updateScrollGradient: () => void @@ -28,7 +29,7 @@ type CustomTagProps = Parameters['tagRender'], undefine function CustomTag({ label, onClose, value }: CustomTagProps): JSX.Element { return ( - + {label} diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 25aacc73b3a19..3b9b6390c4765 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -39,11 +39,6 @@ export const annotationScopeToName = new Map([ export const PERSON_DISTINCT_ID_MAX_SIZE = 3 // Event constants -export const PAGEVIEW = '$pageview' -export const AUTOCAPTURE = '$autocapture' -export const SCREEN = '$screen' -export const CUSTOM_EVENT = 'custom_event' - export const ACTION_TYPE = 'action_type' export const EVENT_TYPE = 'event_type' diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index 94c05c5a72bc0..d16874ac8d5b8 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -261,7 +261,7 @@ export function isOperatorRegex(operator: string): boolean { return ['regex', 'not_regex'].includes(operator) } -export function isValidRegex(value: string): boolean { +export function isValidRegex(value: any): boolean { try { new RegExp(value) return true @@ -855,3 +855,7 @@ export function maybeAddCommasToInteger(value: any): any { const internationalNumberFormat = new Intl.NumberFormat('en-US') return internationalNumberFormat.format(value) } + +export function toString(input?: any | null): string { + return input?.toString() || '' +} diff --git a/frontend/src/scenes/insights/InsightTabs/PathTab.tsx b/frontend/src/scenes/insights/InsightTabs/PathTab.tsx index 3620c3660b3fa..182d9432d7d35 100644 --- a/frontend/src/scenes/insights/InsightTabs/PathTab.tsx +++ b/frontend/src/scenes/insights/InsightTabs/PathTab.tsx @@ -1,14 +1,7 @@ import React from 'react' import { useValues, useActions } from 'kea' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' -import { - PAGEVIEW, - AUTOCAPTURE, - CUSTOM_EVENT, - pathOptionsToLabels, - pathOptionsToProperty, - pathsLogic, -} from 'scenes/paths/pathsLogic' +import { pathOptionsToLabels, pathOptionsToProperty, pathsLogic } from 'scenes/paths/pathsLogic' import { Select } from 'antd' import { PropertyValue } from 'lib/components/PropertyFilters' import { TestAccountFilter } from '../TestAccountFilter' @@ -17,6 +10,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { PathTabHorizontal } from './PathTabHorizontal' import { FEATURE_FLAGS } from 'lib/constants' import { BaseTabProps } from '../Insights' +import { PathType } from '~/types' export function PathTab(props: BaseTabProps): JSX.Element { const { featureFlags } = useValues(featureFlagLogic) @@ -32,8 +26,8 @@ function DefaultPathTab(): JSX.Element { <>

Path Type

setFilter({ path_type: value, start_point: null })} style={{ paddingTop: 2 }} @@ -54,20 +48,22 @@ export function PathTabHorizontal({ annotationsToCreate }: BaseTabProps): JSX.El starting at ({ - name, - })) + filter.path_type === PathType.CustomEvent + ? customEventNames.map((name) => ({ + name, + })) + : undefined } onSet={(value: string | number): void => setFilter({ start_point: value })} - propertyKey={pathOptionsToProperty[filter.path_type || PAGEVIEW]} + propertyKey={pathOptionsToProperty[filter.path_type || PathType.PageView]} type="event" style={{ width: 200, paddingTop: 2 }} value={filter.start_point} placeholder={'Select start element'} - operator={null} + autoFocus={false} + allowCustom={filter.path_type !== PathType.AutoCapture} /> diff --git a/frontend/src/scenes/paths/Paths.js b/frontend/src/scenes/paths/Paths.js index 1ce5b6af6d27c..508fbe1190b1d 100644 --- a/frontend/src/scenes/paths/Paths.js +++ b/frontend/src/scenes/paths/Paths.js @@ -6,9 +6,13 @@ import { Modal, Button, Spin } from 'antd' import { EventElements } from 'scenes/events/EventElements' import * as d3 from 'd3' import * as Sankey from 'd3-sankey' -import { AUTOCAPTURE, PAGEVIEW, pathsLogic } from 'scenes/paths/pathsLogic' +import { pathsLogic } from 'scenes/paths/pathsLogic' import { useWindowSize } from 'lib/hooks/useWindowSize' +// TODO: Replace with PathType enums when moving to TypeScript +const PAGEVIEW = '$pageview' +const AUTOCAPTURE = '$autocapture' + function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { var retval retval = 'M' + (x + r) + ',' + y diff --git a/frontend/src/scenes/paths/pathsLogic.ts b/frontend/src/scenes/paths/pathsLogic.ts index 110a09eddb802..7e26330ff8a8c 100644 --- a/frontend/src/scenes/paths/pathsLogic.ts +++ b/frontend/src/scenes/paths/pathsLogic.ts @@ -5,33 +5,28 @@ import { router } from 'kea-router' import { ViewType, insightLogic } from 'scenes/insights/insightLogic' import { insightHistoryLogic } from 'scenes/insights/InsightHistoryPanel/insightHistoryLogic' import { pathsLogicType } from './pathsLogicType' -import { FilterType, PropertyFilter } from '~/types' +import { FilterType, PathType, PropertyFilter } from '~/types' import { dashboardItemsModel } from '~/models/dashboardItemsModel' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' -export const PAGEVIEW = '$pageview' -export const SCREEN = '$screen' -export const AUTOCAPTURE = '$autocapture' -export const CUSTOM_EVENT = 'custom_event' - export const pathOptionsToLabels = { - [`${PAGEVIEW}`]: 'Page views (Web)', - [`${SCREEN}`]: 'Screen views (Mobile)', - [`${AUTOCAPTURE}`]: 'Autocaptured events', - [`${CUSTOM_EVENT}`]: 'Custom events', + [PathType.PageView]: 'Page views (Web)', + [PathType.Screen]: 'Screen views (Mobile)', + [PathType.AutoCapture]: 'Autocaptured events', + [PathType.CustomEvent]: 'Custom events', } export const pathOptionsToProperty = { - [`${PAGEVIEW}`]: '$current_url', - [`${SCREEN}`]: '$screen_name', - [`${AUTOCAPTURE}`]: 'autocaptured_event', - [`${CUSTOM_EVENT}`]: 'custom_event', + [PathType.PageView]: '$current_url', + [PathType.Screen]: '$screen_name', + [PathType.AutoCapture]: 'autocaptured_event', + [PathType.CustomEvent]: 'custom_event', } function cleanPathParams(filters: Partial): Partial { return { start_point: filters.start_point, - path_type: filters.path_type || '$pageview', + path_type: filters.path_type || PathType.PageView, date_from: filters.date_from, date_to: filters.date_to, insight: ViewType.PATHS, diff --git a/frontend/src/scenes/trends/trendsLogic.ts b/frontend/src/scenes/trends/trendsLogic.ts index 099a25c022f2a..5bf94f827e693 100644 --- a/frontend/src/scenes/trends/trendsLogic.ts +++ b/frontend/src/scenes/trends/trendsLogic.ts @@ -8,8 +8,6 @@ import { ACTIONS_LINE_GRAPH_CUMULATIVE, ACTIONS_LINE_GRAPH_LINEAR, ACTIONS_TABLE, - PAGEVIEW, - SCREEN, EVENT_TYPE, ACTION_TYPE, ShownAsValue, @@ -17,7 +15,16 @@ import { import { ViewType, insightLogic, defaultFilterTestAccounts, TRENDS_BASED_INSIGHTS } from '../insights/insightLogic' import { insightHistoryLogic } from '../insights/InsightHistoryPanel/insightHistoryLogic' import { SESSIONS_WITH_RECORDINGS_FILTER } from 'scenes/sessions/filters/constants' -import { ActionFilter, ActionType, FilterType, PersonType, PropertyFilter, TrendResult, EntityTypes } from '~/types' +import { + ActionFilter, + ActionType, + FilterType, + PersonType, + PropertyFilter, + TrendResult, + EntityTypes, + PathType, +} from '~/types' import { cohortLogic } from 'scenes/persons/cohortLogic' import { trendsLogicType } from './trendsLogicType' import { dashboardItemsModel } from '~/models/dashboardItemsModel' @@ -124,7 +131,11 @@ function getDefaultFilters(currentFilters: Partial, eventNames: stri the first random event). We load this default events when `currentTeam` is loaded (because that's when `eventNames` become available) and on every view change (through the urlToAction map) */ if (!currentFilters.actions?.length && !currentFilters.events?.length && eventNames.length) { - const event = eventNames.includes(PAGEVIEW) ? PAGEVIEW : eventNames.includes(SCREEN) ? SCREEN : eventNames[0] + const event = eventNames.includes(PathType.PageView) + ? PathType.PageView + : eventNames.includes(PathType.Screen) + ? PathType.Screen + : eventNames[0] const defaultFilters = { [EntityTypes.EVENTS]: [ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 638046980dabf..1261ab7f0db87 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,12 +1,8 @@ import { ACTION_TYPE, - AUTOCAPTURE, - CUSTOM_EVENT, EVENT_TYPE, OrganizationMembershipLevel, PluginsAccessLevel, - PAGEVIEW, - SCREEN, ShownAsValue, RETENTION_RECURRING, RETENTION_FIRST_TIME, @@ -544,7 +540,13 @@ export type DisplayType = export type InsightType = 'TRENDS' | 'SESSIONS' | 'FUNNELS' | 'RETENTION' | 'PATHS' | 'LIFECYCLE' | 'STICKINESS' export type ShownAsType = ShownAsValue // DEPRECATED: Remove when releasing `remove-shownas` export type BreakdownType = 'cohort' | 'person' | 'event' -export type PathType = typeof PAGEVIEW | typeof AUTOCAPTURE | typeof SCREEN | typeof CUSTOM_EVENT + +export enum PathType { + PageView = '$pageview', + AutoCapture = '$autocapture', + Screen = '$screen', + CustomEvent = 'custom_event', +} export type RetentionType = typeof RETENTION_RECURRING | typeof RETENTION_FIRST_TIME diff --git a/posthog/queries/paths.py b/posthog/queries/paths.py index 72f96f1a0570d..7e8546946d582 100644 --- a/posthog/queries/paths.py +++ b/posthog/queries/paths.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from django.db import connection from django.db.models import F, OuterRef, Q @@ -18,11 +18,13 @@ class Paths(BaseQuery): def _event_subquery(self, event: str, key: str): return Event.objects.filter(pk=OuterRef(event)).values(key)[:1] - def _apply_start_point(self, start_comparator: str, query_string: str, start_point: str) -> str: + def _apply_start_point( + self, start_comparator: str, query_string: str, sql_params: Tuple[str, ...], start_point: str + ) -> Tuple[str, Tuple[str, ...]]: marked = "\ - SELECT *, CASE WHEN {} '{}' THEN timestamp ELSE NULL END as mark from ({}) as sessionified\ + SELECT *, CASE WHEN {} %s THEN timestamp ELSE NULL END as mark from ({}) as sessionified\ ".format( - start_comparator, start_point, query_string + start_comparator, query_string ) marked_plus = "\ @@ -39,7 +41,8 @@ def _apply_start_point(self, start_comparator: str, query_string: str, start_poi ".format( marked_plus ) - return sessionified + + return sessionified, (start_point,) + sql_params def _add_elements(self, query_string: str) -> str: element = 'SELECT \'<\'|| e."tag_name" || \'> \' || e."text" as tag_name_source, e."text" as text_source FROM "posthog_element" e JOIN \ @@ -102,8 +105,11 @@ def calculate_paths(self, filter: PathFilter, team: Team): ) if filter and filter.start_point: - sessionified = self._apply_start_point( - start_comparator=start_comparator, query_string=sessionified, start_point=filter.start_point, + sessionified, sessions_sql_params = self._apply_start_point( + start_comparator=start_comparator, + query_string=sessionified, + sql_params=sessions_sql_params, + start_point=filter.start_point, ) final = "\