Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event exclusion feature to funnels #5607

Merged
merged 24 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2323f02
expand actionfilter functionality
alexkim205 Aug 17, 2021
5cecd1e
exclusion row component
alexkim205 Aug 17, 2021
3048bde
Merge remote-tracking branch 'origin' into feat/funnel-event-exclusions
alexkim205 Aug 17, 2021
f4783c7
exclusion filter typing
alexkim205 Aug 17, 2021
55168f7
fixing funnel exclusion form edge cases
alexkim205 Aug 17, 2021
8a70a37
styling in vertical layout
alexkim205 Aug 18, 2021
98bfb3d
add spacing
alexkim205 Aug 18, 2021
40d2b0f
typing and remove consoles
alexkim205 Aug 18, 2021
f3bdc49
move styling to JSX
alexkim205 Aug 18, 2021
ddbb923
copy
alexkim205 Aug 18, 2021
a28c914
Merge remote-tracking branch 'origin' into feat/funnel-event-exclusions
alexkim205 Aug 18, 2021
dc686fa
fix overlapping step boundaries
alexkim205 Aug 18, 2021
8d9a9ca
improve exclusion filter vertical sizing
alexkim205 Aug 18, 2021
55af5ad
new layout for vertical filter
alexkim205 Aug 18, 2021
2c0bd83
adjust copy & padding
paolodamico Aug 18, 2021
15c4be2
add flex wrapping to nested steps
alexkim205 Aug 19, 2021
9651028
Merge remote-tracking branch 'origin' into feat/funnel-event-exclusions
alexkim205 Aug 19, 2021
d7af7cd
add renderRow to ActionFilter
alexkim205 Aug 19, 2021
1d24300
Merge branch 'master' into feat/funnel-event-exclusions
mariusandra Aug 23, 2021
d2e0f60
Merge remote-tracking branch 'origin' into feat/funnel-event-exclusions
alexkim205 Aug 23, 2021
4a0b44b
Merge remote-tracking branch 'origin' into feat/funnel-event-exclusions
alexkim205 Aug 23, 2021
aff35cb
refactor insight empty states
alexkim205 Aug 23, 2021
6cdf663
fix empty states of dashboard items
alexkim205 Aug 24, 2021
8cbc925
fix types
alexkim205 Aug 24, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/lib/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
median,
humanFriendlyDuration,
colonDelimitedDuration,
areObjectValuesEmpty,
} from './utils'

describe('capitalizeFirstLetter()', () => {
Expand Down Expand Up @@ -258,3 +259,17 @@ describe('colonDelimitedDuration()', () => {
expect(colonDelimitedDuration(undefined)).toEqual('')
})
})

describe('areObjectValuesEmpty()', () => {
it('returns correct value for objects with empty values', () => {
expect(areObjectValuesEmpty({ a: '', b: null, c: undefined })).toEqual(true)
expect(areObjectValuesEmpty({ a: undefined, b: undefined })).toEqual(true)
expect(areObjectValuesEmpty({})).toEqual(true)
})
it('returns correct value for objects with at least one non-empty value', () => {
expect(areObjectValuesEmpty({ a: '', b: null, c: 'hello' })).toEqual(false)
expect(areObjectValuesEmpty({ a: true, b: 'hello' })).toEqual(false)
expect(areObjectValuesEmpty('hello')).toEqual(false)
expect(areObjectValuesEmpty(null)).toEqual(false)
})
})
6 changes: 4 additions & 2 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ export function uuid(): string {
)
}

export function isObjectEmpty(obj: Record<string, any>): boolean {
return obj && Object.keys(obj).length === 0 && obj.constructor === Object
export function areObjectValuesEmpty(obj: Record<string, any>): boolean {
return (
!!obj && typeof obj === 'object' && !Object.values(obj).some((x) => x !== null && x !== '' && x !== undefined)
)
}

export function toParams(obj: Record<string, any>): string {
Expand Down
135 changes: 61 additions & 74 deletions frontend/src/scenes/funnels/funnelLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isBreakpoint, kea } from 'kea'
import equal from 'fast-deep-equal'
import api from 'lib/api'
import { insightLogic } from 'scenes/insights/insightLogic'
import { autocorrectInterval, objectsEqual, sum, uuid } from 'lib/utils'
import { autocorrectInterval, sum, uuid } from 'lib/utils'
import { insightHistoryLogic } from 'scenes/insights/InsightHistoryPanel/insightHistoryLogic'
import { funnelsModel } from '~/models/funnelsModel'
import { dashboardItemsModel } from '~/models/dashboardItemsModel'
Expand All @@ -20,84 +20,35 @@ import {
ViewType,
FunnelStepWithNestedBreakdown,
FunnelTimeConversionMetrics,
FunnelRequestParams,
LoadedRawFunnelResults,
FlattenedFunnelStep,
FunnelStepWithConversionMetrics,
BinCountValue,
FunnelConversionWindow,
FunnelConversionWindowTimeUnit,
FunnelExclusionEntityFilter,
} from '~/types'
import { FunnelLayout, BinCountAuto } from 'lib/constants'
import { preflightLogic } from 'scenes/PreflightCheck/logic'
import { FunnelStepReference } from 'scenes/insights/InsightTabs/FunnelTab/FunnelStepReferencePicker'
import { cleanBinResult, formatDisplayPercentage, getLastFilledStep, getReferenceStep } from './funnelUtils'
import {
aggregateBreakdownResult,
cleanBinResult,
deepCleanFunnelExclusionEvents,
EMPTY_FUNNEL_RESULTS,
formatDisplayPercentage,
getLastFilledStep,
getReferenceStep,
isBreakdownFunnelResults,
isStepsEmpty,
isValidBreakdownParameter,
pollFunnel,
} from './funnelUtils'
import { personsModalLogic } from 'scenes/trends/personsModalLogic'
import { router } from 'kea-router'
import { getDefaultEventName } from 'lib/utils/getAppContext'
import { dashboardsModel } from '~/models/dashboardsModel'

function aggregateBreakdownResult(
breakdownList: FunnelStep[][],
breakdownProperty?: string | number | number[]
): FunnelStepWithNestedBreakdown[] {
if (breakdownList.length) {
return breakdownList[0].map((step, i) => ({
...step,
count: breakdownList.reduce((total, breakdownSteps) => total + breakdownSteps[i].count, 0),
breakdown: breakdownProperty,
nested_breakdown: breakdownList.reduce(
(allEntries, breakdownSteps) => [...allEntries, breakdownSteps[i]],
[]
),
average_conversion_time: null,
people: [],
}))
}
return []
}

function isBreakdownFunnelResults(results: FunnelStep[] | FunnelStep[][]): results is FunnelStep[][] {
return Array.isArray(results) && (results.length === 0 || Array.isArray(results[0]))
}

// breakdown parameter could be a string (property breakdown) or object/number (list of cohort ids)
function isValidBreakdownParameter(breakdown: FunnelRequestParams['breakdown']): boolean {
return ['string', 'null', 'undefined', 'number'].includes(typeof breakdown) || Array.isArray(breakdown)
}

function wait(ms = 1000): Promise<any> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

const SECONDS_TO_POLL = 3 * 60

const EMPTY_FUNNEL_RESULTS = {
results: [],
timeConversionResults: {
bins: [],
average_conversion_time: 0,
},
}

async function pollFunnel<T = FunnelStep[]>(apiParams: FunnelRequestParams): Promise<FunnelResult<T>> {
// Tricky: This API endpoint has wildly different return types depending on parameters.
const { refresh, ...bodyParams } = apiParams
let result = await api.create('api/insight/funnel/?' + (refresh ? 'refresh=true' : ''), bodyParams)
const start = window.performance.now()
while (result.result.loading && (window.performance.now() - start) / 1000 < SECONDS_TO_POLL) {
await wait()
result = await api.create('api/insight/funnel', bodyParams)
}
// if endpoint is still loading after 3 minutes just return default
if (result.loading) {
throw { status: 0, statusText: 'Funnel timeout' }
}
return result
}

export const cleanFunnelParams = (filters: Partial<FilterType>, discardFiltersNotUsedByFunnels = false): FilterType => {
const breakdownEnabled = filters.funnel_viz_type === FunnelVizType.Steps

Expand All @@ -124,14 +75,13 @@ export const cleanFunnelParams = (filters: Partial<FilterType>, discardFiltersNo
? { funnel_step_breakdown: filters.funnel_step_breakdown }
: {}),
...(filters.bin_count && filters.bin_count !== BinCountAuto ? { bin_count: filters.bin_count } : {}),
exclusions: deepCleanFunnelExclusionEvents(filters),
interval: autocorrectInterval(filters),
breakdown: breakdownEnabled ? filters.breakdown || undefined : undefined,
breakdown_type: breakdownEnabled ? filters.breakdown_type || undefined : undefined,
insight: ViewType.FUNNELS,
}
}
const isStepsEmpty = (filters: FilterType): boolean =>
[...(filters.actions || []), ...(filters.events || [])].length === 0

export const funnelLogic = kea<funnelLogicType>({
props: {} as {
Expand All @@ -140,6 +90,7 @@ export const funnelLogic = kea<funnelLogicType>({
cachedResults?: any
preventLoading?: boolean
refresh?: boolean
exclusionFilters?: Partial<FilterType>
},

key: (props) => {
Expand All @@ -153,6 +104,11 @@ export const funnelLogic = kea<funnelLogicType>({
refresh,
mergeWithExisting,
}),
setEventExclusionFilters: (filters: Partial<FilterType>) => ({ filters }),
setOneEventExclusionFilter: (eventFilter: FunnelExclusionEntityFilter, index: number) => ({
eventFilter,
index,
}),
saveFunnelInsight: (name: string) => ({ name }),
setConversionWindow: (conversionWindow: FunnelConversionWindow) => ({ conversionWindow }),
openPersonsModal: (
Expand Down Expand Up @@ -301,17 +257,24 @@ export const funnelLogic = kea<funnelLogicType>({
clearFunnel: (state) => ({ new_entity: state.new_entity }),
},
],
exclusionFilters: [
(props.exclusionFilters || { type: EntityTypes.EVENTS }) as FilterType,
{
setEventExclusionFilters: (state, { filters }) => ({ ...state, ...filters, type: EntityTypes.EVENTS }),
setOneEventExclusionFilter: (state, { eventFilter, index }) => ({
...state,
events: state.events ? state.events.map((e, e_i) => (e_i === index ? eventFilter : e)) : [],
}),
},
],
alexkim205 marked this conversation as resolved.
Show resolved Hide resolved
people: {
clearFunnel: () => [],
},
conversionWindow: [
{
funnel_window_interval_unit: FunnelConversionWindowTimeUnit.Day,
funnel_window_interval: 14,
} as {
funnel_window_interval_unit: FunnelConversionWindowTimeUnit
funnel_window_interval: number
},
} as FunnelConversionWindow,
{
setConversionWindow: (
state,
Expand Down Expand Up @@ -456,11 +419,15 @@ export const funnelLogic = kea<funnelLogicType>({
},
],
areFiltersValid: [
() => [selectors.filters],
(filters) => {
return (filters.events?.length || 0) + (filters.actions?.length || 0) > 1
() => [selectors.numberOfSeries],
(numberOfSeries) => {
return numberOfSeries > 1
},
],
numberOfSeries: [
() => [selectors.filters],
(filters): number => (filters.events?.length || 0) + (filters.actions?.length || 0),
],
conversionMetrics: [
() => [selectors.stepsWithCount, selectors.histogramStep],
(stepsWithCount, timeStep): FunnelTimeConversionMetrics => {
Expand Down Expand Up @@ -607,6 +574,13 @@ export const funnelLogic = kea<funnelLogicType>({
return binCount
},
],
exclusionDefaultStepRange: [
() => [selectors.numberOfSeries, selectors.areFiltersValid],
(numberOfSeries, areFiltersValid): Omit<FunnelExclusionEntityFilter, 'id' | 'name'> => ({
funnel_from_step: 0,
funnel_to_step: areFiltersValid ? numberOfSeries - 1 : 1,
}),
],
}),

listeners: ({ actions, values, props }) => ({
Expand Down Expand Up @@ -673,6 +647,18 @@ export const funnelLogic = kea<funnelLogicType>({
setConversionWindow: async () => {
actions.loadResults()
},
setEventExclusionFilters: () => {
actions.setFilters(
{
...values.filters,
exclusions: values.exclusionFilters.events as FunnelExclusionEntityFilter[],
},
true
)
},
setOneEventExclusionFilter: () => {
actions.setEventExclusionFilters(values.exclusionFilters)
},
}),
actionToUrl: ({ values, props }) => ({
setFilters: () => {
Expand All @@ -695,7 +681,7 @@ export const funnelLogic = kea<funnelLogicType>({
const currentParams = cleanFunnelParams(values.filters, true)
const paramsToCheck = cleanFunnelParams(searchParams, true)

if (!objectsEqual(currentParams, paramsToCheck)) {
if (!equal(currentParams, paramsToCheck)) {
const cleanedParams = cleanFunnelParams(searchParams)
if (isStepsEmpty(cleanedParams)) {
const event = getDefaultEventName()
Expand All @@ -709,6 +695,7 @@ export const funnelLogic = kea<funnelLogicType>({
]
}
actions.setFilters(cleanedParams, true, false)
actions.setEventExclusionFilters({ events: cleanedParams.exclusions })
alexkim205 marked this conversation as resolved.
Show resolved Hide resolved
}
}
},
Expand Down
97 changes: 95 additions & 2 deletions frontend/src/scenes/funnels/funnelUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { compactNumber } from 'lib/utils'
import { clamp, compactNumber } from 'lib/utils'
import { FunnelStepReference } from 'scenes/insights/InsightTabs/FunnelTab/FunnelStepReferencePicker'
import { getChartColors } from 'lib/colors'
import { FunnelStep, FunnelsTimeConversionBins } from '~/types'
import api from 'lib/api'
import {
FilterType,
FunnelExclusionEntityFilter,
FunnelRequestParams,
FunnelResult,
FunnelStep,
FunnelStepWithNestedBreakdown,
FunnelsTimeConversionBins,
} from '~/types'

const PERCENTAGE_DISPLAY_PRECISION = 1 // Number of decimals to show in percentages

Expand Down Expand Up @@ -89,3 +98,87 @@ export function cleanBinResult(binsResult: FunnelsTimeConversionBins): FunnelsTi
average_conversion_time: binsResult.average_conversion_time ?? 0,
}
}

export function aggregateBreakdownResult(
breakdownList: FunnelStep[][],
breakdownProperty?: string | number | number[]
): FunnelStepWithNestedBreakdown[] {
if (breakdownList.length) {
return breakdownList[0].map((step, i) => ({
...step,
count: breakdownList.reduce((total, breakdownSteps) => total + breakdownSteps[i].count, 0),
breakdown: breakdownProperty,
nested_breakdown: breakdownList.reduce(
(allEntries, breakdownSteps) => [...allEntries, breakdownSteps[i]],
[]
),
average_conversion_time: null,
people: [],
}))
}
return []
}

export function isBreakdownFunnelResults(results: FunnelStep[] | FunnelStep[][]): results is FunnelStep[][] {
return Array.isArray(results) && (results.length === 0 || Array.isArray(results[0]))
}

// breakdown parameter could be a string (property breakdown) or object/number (list of cohort ids)
export function isValidBreakdownParameter(breakdown: FunnelRequestParams['breakdown']): boolean {
return ['string', 'null', 'undefined', 'number'].includes(typeof breakdown) || Array.isArray(breakdown)
}

export function wait(ms = 1000): Promise<any> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

export const SECONDS_TO_POLL = 3 * 60

export const EMPTY_FUNNEL_RESULTS = {
results: [],
timeConversionResults: {
bins: [],
average_conversion_time: 0,
},
}

export async function pollFunnel<T = FunnelStep[]>(apiParams: FunnelRequestParams): Promise<FunnelResult<T>> {
// Tricky: This API endpoint has wildly different return types depending on parameters.
alexkim205 marked this conversation as resolved.
Show resolved Hide resolved
const { refresh, ...bodyParams } = apiParams
let result = await api.create('api/insight/funnel/?' + (refresh ? 'refresh=true' : ''), bodyParams)
const start = window.performance.now()
while (result.result.loading && (window.performance.now() - start) / 1000 < SECONDS_TO_POLL) {
await wait()
result = await api.create('api/insight/funnel', bodyParams)
}
// if endpoint is still loading after 3 minutes just return default
if (result.loading) {
throw { status: 0, statusText: 'Funnel timeout' }
}
return result
}

export const isStepsEmpty = (filters: FilterType): boolean =>
[...(filters.actions || []), ...(filters.events || [])].length === 0

export const deepCleanFunnelExclusionEvents = (filters: FilterType): FunnelExclusionEntityFilter[] | undefined => {
if (!filters.exclusions) {
return filters.exclusions
}

const lastIndex = Math.max((filters.events?.length || 0) + (filters.actions?.length || 0) - 1, 1)
return filters.exclusions.map((event) => {
const funnel_from_step = event.funnel_from_step ? clamp(event.funnel_from_step, 0, lastIndex - 1) : 0
return {
...event,
...{ funnel_from_step },
...{
funnel_to_step: event.funnel_to_step
? clamp(event.funnel_to_step, funnel_from_step + 1, lastIndex)
: lastIndex,
},
}
})
}
Loading