From adcfe31dedc25c3ace913c193e7da8d1e078c43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Obermu=CC=88ller?= Date: Tue, 4 Apr 2023 08:12:55 -0400 Subject: [PATCH 1/4] convert feedback form --- .../funnels/funnelCorrelationFeedbackLogic.ts | 75 +++++++++++++++++++ frontend/src/scenes/funnels/funnelLogic.ts | 50 ------------- .../Funnels/FunnelCorrelationFeedbackForm.tsx | 6 +- 3 files changed, 78 insertions(+), 53 deletions(-) create mode 100644 frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts diff --git a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts new file mode 100644 index 0000000000000..302ce603d9969 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts @@ -0,0 +1,75 @@ +import { actions, connect, kea, key, listeners, path, props, reducers } from 'kea' +import { lemonToast } from '@posthog/lemon-ui' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' + +import type { funnelCorrelationFeedbackLogicType } from './funnelCorrelationFeedbackLogicType' +import { funnelLogic } from './funnelLogic' +import { InsightLogicProps } from '~/types' + +export const funnelCorrelationFeedbackLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps('insight_funnel')), + path((key) => ['scenes', 'funnels', 'funnelCorrelationFeedbackLogic', key]), + + connect((props) => ({ + actions: [funnelLogic(props), ['loadEventCorrelations', 'loadPropertyCorrelations']], + })), + + actions({ + sendCorrelationAnalysisFeedback: true, + hideCorrelationAnalysisFeedback: true, + setCorrelationFeedbackRating: (rating: number) => ({ rating }), + setCorrelationDetailedFeedback: (comment: string) => ({ comment }), + setCorrelationDetailedFeedbackVisible: (visible: boolean) => ({ visible }), + }), + reducers({ + correlationFeedbackHidden: [ + true, + { + // don't load the feedback form until after some results were loaded + loadEventCorrelations: () => false, + loadPropertyCorrelations: () => false, + sendCorrelationAnalysisFeedback: () => true, + hideCorrelationAnalysisFeedback: () => true, + }, + ], + correlationDetailedFeedbackVisible: [ + false, + { + setCorrelationDetailedFeedbackVisible: (_, { visible }) => visible, + }, + ], + correlationFeedbackRating: [ + 0, + { + setCorrelationFeedbackRating: (_, { rating }) => rating, + }, + ], + correlationDetailedFeedback: [ + '', + { + setCorrelationDetailedFeedback: (_, { comment }) => comment, + }, + ], + }), + listeners(({ actions, values }) => ({ + sendCorrelationAnalysisFeedback: () => { + eventUsageLogic.actions.reportCorrelationAnalysisDetailedFeedback( + values.correlationFeedbackRating, + values.correlationDetailedFeedback + ) + actions.setCorrelationFeedbackRating(0) + actions.setCorrelationDetailedFeedback('') + lemonToast.success('Thanks for your feedback! Your comments help us improve') + }, + setCorrelationFeedbackRating: ({ rating }) => { + const feedbackBoxVisible = rating > 0 + actions.setCorrelationDetailedFeedbackVisible(feedbackBoxVisible) + if (feedbackBoxVisible) { + // Don't send event when resetting reducer + eventUsageLogic.actions.reportCorrelationAnalysisFeedback(rating) + } + }, + })), +]) diff --git a/frontend/src/scenes/funnels/funnelLogic.ts b/frontend/src/scenes/funnels/funnelLogic.ts index 6cf03adad7aea..6bb4655f1ecd3 100644 --- a/frontend/src/scenes/funnels/funnelLogic.ts +++ b/frontend/src/scenes/funnels/funnelLogic.ts @@ -170,12 +170,7 @@ export const funnelLogic = kea({ // Correlation related actions setCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), setPropertyCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), - setCorrelationDetailedFeedback: (comment: string) => ({ comment }), - setCorrelationFeedbackRating: (rating: number) => ({ rating }), - setCorrelationDetailedFeedbackVisible: (visible: boolean) => ({ visible }), - sendCorrelationAnalysisFeedback: true, hideSkewWarning: true, - hideCorrelationAnalysisFeedback: true, setFunnelCorrelationDetails: (payload: FunnelCorrelation | null) => ({ payload }), setPropertyNames: (propertyNames: string[]) => ({ propertyNames }), @@ -332,34 +327,6 @@ export const funnelLogic = kea({ hideSkewWarning: () => true, }, ], - correlationFeedbackHidden: [ - true, - { - // don't load the feedback form until after some results were loaded - loadEventCorrelations: () => false, - loadPropertyCorrelations: () => false, - sendCorrelationAnalysisFeedback: () => true, - hideCorrelationAnalysisFeedback: () => true, - }, - ], - correlationDetailedFeedbackVisible: [ - false, - { - setCorrelationDetailedFeedbackVisible: (_, { visible }) => visible, - }, - ], - correlationFeedbackRating: [ - 0, - { - setCorrelationFeedbackRating: (_, { rating }) => rating, - }, - ], - correlationDetailedFeedback: [ - '', - { - setCorrelationDetailedFeedback: (_, { comment }) => comment, - }, - ], eventWithPropertyCorrelations: { loadEventWithPropertyCorrelationsSuccess: (state, { eventWithPropertyCorrelations }) => { return { @@ -1215,23 +1182,6 @@ export const funnelLogic = kea({ { property_names: propertyNames.length === values.allProperties.length ? '$all' : propertyNames } ) }, - sendCorrelationAnalysisFeedback: () => { - eventUsageLogic.actions.reportCorrelationAnalysisDetailedFeedback( - values.correlationFeedbackRating, - values.correlationDetailedFeedback - ) - actions.setCorrelationFeedbackRating(0) - actions.setCorrelationDetailedFeedback('') - lemonToast.success('Thanks for your feedback! Your comments help us improve') - }, - setCorrelationFeedbackRating: ({ rating }) => { - const feedbackBoxVisible = rating > 0 - actions.setCorrelationDetailedFeedbackVisible(feedbackBoxVisible) - if (feedbackBoxVisible) { - // Don't send event when resetting reducer - eventUsageLogic.actions.reportCorrelationAnalysisFeedback(rating) - } - }, [visibilitySensorLogic({ id: values.correlationPropKey }).actionTypes.setVisible]: async ( { visible, diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationFeedbackForm.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationFeedbackForm.tsx index 831d7ee9c3100..59d499953c164 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationFeedbackForm.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationFeedbackForm.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react' import { useActions, useValues } from 'kea' import { insightLogic } from 'scenes/insights/insightLogic' -import { funnelLogic } from 'scenes/funnels/funnelLogic' +import { funnelCorrelationFeedbackLogic } from 'scenes/funnels/funnelCorrelationFeedbackLogic' import { LemonButton, LemonTextArea } from '@posthog/lemon-ui' import { IconClose } from 'lib/lemon-ui/icons' @@ -11,14 +11,14 @@ import { CommentOutlined } from '@ant-design/icons' export const FunnelCorrelationFeedbackForm = (): JSX.Element | null => { const { insightProps } = useValues(insightLogic) const { correlationFeedbackHidden, correlationDetailedFeedbackVisible, correlationFeedbackRating } = useValues( - funnelLogic(insightProps) + funnelCorrelationFeedbackLogic(insightProps) ) const { sendCorrelationAnalysisFeedback, hideCorrelationAnalysisFeedback, setCorrelationFeedbackRating, setCorrelationDetailedFeedback, - } = useActions(funnelLogic(insightProps)) + } = useActions(funnelCorrelationFeedbackLogic(insightProps)) const detailedFeedbackRef = useRef(null) From 7819e18790e040765a29654a1643878b3637f6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Obermu=CC=88ller?= Date: Tue, 4 Apr 2023 08:15:39 -0400 Subject: [PATCH 2/4] add to data exploration --- .../src/scenes/insights/views/Funnels/FunnelCorrelation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx index 95f14cc64af7a..43f6bca8a9d82 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx @@ -33,7 +33,7 @@ export const FunnelCorrelation = (): JSX.Element | null => { )} {!isUsingDataExploration && } - {!isUsingDataExploration && } + {!isUsingDataExploration && } From 2986eed899686f5d3622811f360a9edc8af79aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Obermu=CC=88ller?= Date: Tue, 4 Apr 2023 12:08:56 -0400 Subject: [PATCH 3/4] tests --- .../funnelCorrelationFeedbackLogic.test.ts | 109 ++++++++++++++++++ .../funnels/funnelCorrelationFeedbackLogic.ts | 2 +- .../src/scenes/funnels/funnelLogic.test.ts | 79 ------------- 3 files changed, 110 insertions(+), 80 deletions(-) create mode 100644 frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts diff --git a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts new file mode 100644 index 0000000000000..58880d9167c04 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts @@ -0,0 +1,109 @@ +import posthog from 'posthog-js' +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { AvailableFeature, InsightLogicProps, InsightType } from '~/types' +import { useAvailableFeatures } from '~/mocks/features' +import { funnelCorrelationFeedbackLogic } from './funnelCorrelationFeedbackLogic' + +describe('funnelCorrelationFeedbackLogic', () => { + let logic: ReturnType + + beforeEach(() => { + useAvailableFeatures([AvailableFeature.CORRELATION_ANALYSIS]) + initKeaTests(false) + }) + + const defaultProps: InsightLogicProps = { + dashboardItemId: undefined, + cachedInsight: { + short_id: undefined, + filters: { + insight: InsightType.FUNNELS, + actions: [ + { id: '$pageview', order: 0 }, + { id: '$pageview', order: 1 }, + ], + }, + result: [], + }, + } + + beforeEach(async () => { + logic = funnelCorrelationFeedbackLogic(defaultProps) + logic.mount() + }) + + it('opens detailed feedback on selecting a valid rating', async () => { + await expectLogic(logic, () => { + logic.actions.setCorrelationFeedbackRating(1) + }) + .toMatchValues(logic, { + correlationFeedbackRating: 1, + }) + .toDispatchActions(logic, [ + (action) => + action.type === logic.actionTypes.setCorrelationDetailedFeedbackVisible && + action.payload.visible === true, + ]) + .toMatchValues(logic, { + correlationDetailedFeedbackVisible: true, + }) + }) + + it('doesnt opens detailed feedback on selecting an invalid rating', async () => { + await expectLogic(logic, () => { + logic.actions.setCorrelationFeedbackRating(0) + }) + .toMatchValues(logic, { + correlationFeedbackRating: 0, + }) + .toDispatchActions(logic, [ + (action) => + action.type === logic.actionTypes.setCorrelationDetailedFeedbackVisible && + action.payload.visible === false, + ]) + .toMatchValues(logic, { + correlationDetailedFeedbackVisible: false, + }) + }) + + it('captures emoji feedback properly', async () => { + jest.spyOn(posthog, 'capture') + await expectLogic(logic, () => { + logic.actions.setCorrelationFeedbackRating(1) + }) + .toMatchValues(logic, { + // reset after sending feedback + correlationFeedbackRating: 1, + }) + .toDispatchActions(eventUsageLogic, ['reportCorrelationAnalysisFeedback']) + + expect(posthog.capture).toBeCalledWith('correlation analysis feedback', { rating: 1 }) + }) + + it('goes away on sending feedback, capturing it properly', async () => { + jest.spyOn(posthog, 'capture') + await expectLogic(logic, () => { + logic.actions.setCorrelationFeedbackRating(2) + logic.actions.setCorrelationDetailedFeedback('tests') + logic.actions.sendCorrelationAnalysisFeedback() + }) + .toMatchValues(logic, { + // reset after sending feedback + correlationFeedbackRating: 0, + correlationDetailedFeedback: '', + correlationFeedbackHidden: true, + }) + .toDispatchActions(eventUsageLogic, ['reportCorrelationAnalysisDetailedFeedback']) + .toFinishListeners() + + await expectLogic(eventUsageLogic).toFinishListeners() + + expect(posthog.capture).toBeCalledWith('correlation analysis feedback', { rating: 2 }) + expect(posthog.capture).toBeCalledWith('correlation analysis detailed feedback', { + rating: 2, + comments: 'tests', + }) + }) +}) diff --git a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts index 302ce603d9969..0c0e6f9cf8738 100644 --- a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts +++ b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts @@ -12,7 +12,7 @@ export const funnelCorrelationFeedbackLogic = kea ['scenes', 'funnels', 'funnelCorrelationFeedbackLogic', key]), - connect((props) => ({ + connect((props: InsightLogicProps) => ({ actions: [funnelLogic(props), ['loadEventCorrelations', 'loadPropertyCorrelations']], })), diff --git a/frontend/src/scenes/funnels/funnelLogic.test.ts b/frontend/src/scenes/funnels/funnelLogic.test.ts index d722584148c98..02532c560be12 100644 --- a/frontend/src/scenes/funnels/funnelLogic.test.ts +++ b/frontend/src/scenes/funnels/funnelLogic.test.ts @@ -1,6 +1,5 @@ import { DEFAULT_EXCLUDED_PERSON_PROPERTIES, funnelLogic } from './funnelLogic' import { MOCK_DEFAULT_TEAM, MOCK_TEAM_ID } from 'lib/api.mock' -import posthog from 'posthog-js' import { expectLogic, partial } from 'kea-test-utils' import { initKeaTests } from '~/test/init' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' @@ -1054,84 +1053,6 @@ describe('funnelLogic', () => { }) }) - describe('Correlation Feedback flow', () => { - beforeEach(async () => { - await initFunnelLogic() - }) - it('opens detailed feedback on selecting a valid rating', async () => { - await expectLogic(logic, () => { - logic.actions.setCorrelationFeedbackRating(1) - }) - .toMatchValues(logic, { - correlationFeedbackRating: 1, - }) - .toDispatchActions(logic, [ - (action) => - action.type === logic.actionTypes.setCorrelationDetailedFeedbackVisible && - action.payload.visible === true, - ]) - .toMatchValues(logic, { - correlationDetailedFeedbackVisible: true, - }) - }) - - it('doesnt opens detailed feedback on selecting an invalid rating', async () => { - await expectLogic(logic, () => { - logic.actions.setCorrelationFeedbackRating(0) - }) - .toMatchValues(logic, { - correlationFeedbackRating: 0, - }) - .toDispatchActions(logic, [ - (action) => - action.type === logic.actionTypes.setCorrelationDetailedFeedbackVisible && - action.payload.visible === false, - ]) - .toMatchValues(logic, { - correlationDetailedFeedbackVisible: false, - }) - }) - - it('Captures emoji feedback properly', async () => { - jest.spyOn(posthog, 'capture') - await expectLogic(logic, () => { - logic.actions.setCorrelationFeedbackRating(1) - }) - .toMatchValues(logic, { - // reset after sending feedback - correlationFeedbackRating: 1, - }) - .toDispatchActions(eventUsageLogic, ['reportCorrelationAnalysisFeedback']) - - expect(posthog.capture).toBeCalledWith('correlation analysis feedback', { rating: 1 }) - }) - - it('goes away on sending feedback, capturing it properly', async () => { - jest.spyOn(posthog, 'capture') - await expectLogic(logic, () => { - logic.actions.setCorrelationFeedbackRating(2) - logic.actions.setCorrelationDetailedFeedback('tests') - logic.actions.sendCorrelationAnalysisFeedback() - }) - .toMatchValues(logic, { - // reset after sending feedback - correlationFeedbackRating: 0, - correlationDetailedFeedback: '', - correlationFeedbackHidden: true, - }) - .toDispatchActions(eventUsageLogic, ['reportCorrelationAnalysisDetailedFeedback']) - .toFinishListeners() - - await expectLogic(eventUsageLogic).toFinishListeners() - - expect(posthog.capture).toBeCalledWith('correlation analysis feedback', { rating: 2 }) - expect(posthog.capture).toBeCalledWith('correlation analysis detailed feedback', { - rating: 2, - comments: 'tests', - }) - }) - }) - describe('funnel simple vs. advanced mode', () => { beforeEach(async () => { await initFunnelLogic() From 57e94d38ce7e0f7b848176b6d9b96f626b5f990d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Oberm=C3=BCller?= Date: Thu, 6 Apr 2023 07:47:01 -0400 Subject: [PATCH 4/4] feat(data exploration): extract funnel correlation usage logic (#14975) --- .../funnelCorrelationDetailsLogic.test.ts | 85 + .../funnels/funnelCorrelationDetailsLogic.ts | 105 ++ .../funnels/funnelCorrelationFeedbackLogic.ts | 10 +- .../scenes/funnels/funnelCorrelationLogic.ts | 270 +++ .../funnels/funnelCorrelationUsageLogic.ts | 196 +++ .../src/scenes/funnels/funnelLogic.test.ts | 713 +------- frontend/src/scenes/funnels/funnelLogic.ts | 688 +------- .../funnelPropertyCorrelationLogic.test.ts | 235 +++ .../funnels/funnelPropertyCorrelationLogic.ts | 188 +++ .../src/scenes/funnels/funnelUtils.test.ts | 99 +- frontend/src/scenes/funnels/funnelUtils.ts | 130 +- .../insights/EmptyStates/timeout-state.json | 1462 ----------------- .../src/scenes/insights/insightDataLogic.ts | 2 +- .../views/Funnels/CorrelationActionsCell.tsx | 18 +- .../views/Funnels/CorrelationMatrix.tsx | 13 +- .../views/Funnels/FunnelCorrelation.tsx | 36 +- .../views/Funnels/FunnelCorrelationTable.tsx | 114 +- .../FunnelPropertyCorrelationTable.tsx | 112 +- 18 files changed, 1544 insertions(+), 2932 deletions(-) create mode 100644 frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.test.ts create mode 100644 frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.ts create mode 100644 frontend/src/scenes/funnels/funnelCorrelationLogic.ts create mode 100644 frontend/src/scenes/funnels/funnelCorrelationUsageLogic.ts create mode 100644 frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.test.ts create mode 100644 frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.ts delete mode 100644 frontend/src/scenes/insights/EmptyStates/timeout-state.json diff --git a/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.test.ts b/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.test.ts new file mode 100644 index 0000000000000..839ac0bd41f26 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.test.ts @@ -0,0 +1,85 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { FunnelCorrelationResultsType, FunnelCorrelationType, InsightLogicProps, InsightType } from '~/types' + +import { funnelCorrelationDetailsLogic } from './funnelCorrelationDetailsLogic' + +const funnelResults = [ + { + action_id: '$pageview', + count: 19, + name: '$pageview', + order: 0, + type: 'events', + }, + { + action_id: '$pageview', + count: 7, + name: '$pageview', + order: 1, + type: 'events', + }, + { + action_id: '$pageview', + count: 4, + name: '$pageview', + order: 2, + type: 'events', + }, +] + +describe('funnelCorrelationDetailsLogic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests(false) + }) + + const defaultProps: InsightLogicProps = { + dashboardItemId: undefined, + cachedInsight: { + short_id: undefined, + filters: { + insight: InsightType.FUNNELS, + actions: [ + { id: '$pageview', order: 0 }, + { id: '$pageview', order: 1 }, + ], + }, + result: funnelResults, + }, + } + + beforeEach(async () => { + logic = funnelCorrelationDetailsLogic(defaultProps) + logic.mount() + }) + + describe('correlationMatrixAndScore', () => { + it('returns calculated values based on selected details', async () => { + await expectLogic(logic, () => + logic.actions.setFunnelCorrelationDetails({ + event: { event: 'some event', elements: [], properties: {} }, + success_people_url: '', + failure_people_url: '', + success_count: 2, + failure_count: 4, + odds_ratio: 3, + correlation_type: FunnelCorrelationType.Success, + result_type: FunnelCorrelationResultsType.Events, + }) + ).toMatchValues({ + correlationMatrixAndScore: { + correlationScore: expect.anything(), + correlationScoreStrength: 'weak', + truePositive: 2, + falsePositive: 2, + trueNegative: 11, + falseNegative: 4, + }, + }) + + expect(logic.values.correlationMatrixAndScore.correlationScore).toBeCloseTo(0.204) + }) + }) +}) diff --git a/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.ts new file mode 100644 index 0000000000000..7602686ef7c2f --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationDetailsLogic.ts @@ -0,0 +1,105 @@ +import { kea, props, key, path, connect, selectors, reducers, actions } from 'kea' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { FunnelCorrelation, InsightLogicProps } from '~/types' + +import { insightLogic } from 'scenes/insights/insightLogic' +import { funnelLogic } from './funnelLogic' +import { funnelDataLogic } from './funnelDataLogic' + +import type { funnelCorrelationDetailsLogicType } from './funnelCorrelationDetailsLogicType' + +export const funnelCorrelationDetailsLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps('insight_funnel')), + path((key) => ['scenes', 'funnels', 'funnelCorrelationDetailsLogic', key]), + connect((props: InsightLogicProps) => ({ + values: [ + insightLogic(props), + ['isUsingDataExploration'], + funnelLogic(props), + ['steps as legacySteps'], + funnelDataLogic(props), + ['steps as dataExplorationSteps'], + ], + })), + + actions({ + setFunnelCorrelationDetails: (payload: FunnelCorrelation | null) => ({ payload }), + }), + + reducers({ + funnelCorrelationDetails: [ + null as null | FunnelCorrelation, + { + setFunnelCorrelationDetails: (_, { payload }) => payload, + }, + ], + }), + + selectors({ + steps: [ + (s) => [s.isUsingDataExploration, s.dataExplorationSteps, s.legacySteps], + (isUsingDataExploration, dataExplorationApiParams, legacyApiParams) => { + return isUsingDataExploration ? dataExplorationApiParams : legacyApiParams + }, + ], + + correlationMatrixAndScore: [ + (s) => [s.funnelCorrelationDetails, s.steps], + ( + funnelCorrelationDetails, + steps + ): { + truePositive: number + falsePositive: number + trueNegative: number + falseNegative: number + correlationScore: number + correlationScoreStrength: 'weak' | 'moderate' | 'strong' | null + } => { + if (!funnelCorrelationDetails) { + return { + truePositive: 0, + falsePositive: 0, + trueNegative: 0, + falseNegative: 0, + correlationScore: 0, + correlationScoreStrength: null, + } + } + + const successTotal = steps[steps.length - 1].count + const failureTotal = steps[0].count - successTotal + const success = funnelCorrelationDetails.success_count + const failure = funnelCorrelationDetails.failure_count + + const truePositive = success // has property, converted + const falseNegative = failure // has property, but dropped off + const trueNegative = failureTotal - failure // doesn't have property, dropped off + const falsePositive = successTotal - success // doesn't have property, converted + + // Phi coefficient: https://en.wikipedia.org/wiki/Phi_coefficient + const correlationScore = + (truePositive * trueNegative - falsePositive * falseNegative) / + Math.sqrt( + (truePositive + falsePositive) * + (truePositive + falseNegative) * + (trueNegative + falsePositive) * + (trueNegative + falseNegative) + ) + + const correlationScoreStrength = + Math.abs(correlationScore) > 0.5 ? 'strong' : Math.abs(correlationScore) > 0.3 ? 'moderate' : 'weak' + + return { + correlationScore, + truePositive, + falsePositive, + trueNegative, + falseNegative, + correlationScoreStrength, + } + }, + ], + }), +]) diff --git a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts index 0c0e6f9cf8738..14d89ea0b8f42 100644 --- a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts +++ b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.ts @@ -4,8 +4,9 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import type { funnelCorrelationFeedbackLogicType } from './funnelCorrelationFeedbackLogicType' -import { funnelLogic } from './funnelLogic' import { InsightLogicProps } from '~/types' +import { funnelCorrelationLogic } from './funnelCorrelationLogic' +import { funnelPropertyCorrelationLogic } from './funnelPropertyCorrelationLogic' export const funnelCorrelationFeedbackLogic = kea([ props({} as InsightLogicProps), @@ -13,7 +14,12 @@ export const funnelCorrelationFeedbackLogic = kea ['scenes', 'funnels', 'funnelCorrelationFeedbackLogic', key]), connect((props: InsightLogicProps) => ({ - actions: [funnelLogic(props), ['loadEventCorrelations', 'loadPropertyCorrelations']], + actions: [ + funnelCorrelationLogic(props), + ['loadEventCorrelations'], + funnelPropertyCorrelationLogic(props), + ['loadPropertyCorrelations'], + ], })), actions({ diff --git a/frontend/src/scenes/funnels/funnelCorrelationLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationLogic.ts new file mode 100644 index 0000000000000..4d9d388817438 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationLogic.ts @@ -0,0 +1,270 @@ +import { kea, props, key, path, selectors, listeners, connect, reducers, actions, defaults } from 'kea' +import { + FunnelCorrelation, + FunnelCorrelationResultsType, + FunnelCorrelationType, + FunnelsFilterType, + InsightLogicProps, +} from '~/types' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { funnelLogic } from './funnelLogic' +import api from 'lib/api' + +import type { funnelCorrelationLogicType } from './funnelCorrelationLogicType' +import { loaders } from 'kea-loaders' +import { lemonToast } from '@posthog/lemon-ui' +import { teamLogic } from 'scenes/teamLogic' +import { funnelDataLogic } from './funnelDataLogic' +import { cleanFilters } from 'scenes/insights/utils/cleanFilters' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { insightLogic } from 'scenes/insights/insightLogic' +import { appendToCorrelationConfig } from './funnelUtils' + +export const funnelCorrelationLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps('insight_funnel')), + path((key) => ['scenes', 'funnels', 'funnelCorrelationLogic', key]), + connect((props: InsightLogicProps) => ({ + values: [ + insightLogic(props), + ['isUsingDataExploration'], + funnelLogic(props), + ['filters'], + funnelDataLogic(props), + ['querySource'], + teamLogic, + ['currentTeamId', 'currentTeam'], + ], + })), + actions({ + setCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), + excludeEventFromProject: (eventName: string) => ({ eventName }), + + excludeEventPropertyFromProject: (eventName: string, propertyName: string) => ({ eventName, propertyName }), + addNestedTableExpandedKey: (expandKey: string) => ({ expandKey }), + removeNestedTableExpandedKey: (expandKey: string) => ({ expandKey }), + }), + defaults({ + // This is a hack to get `FunnelCorrelationResultsType` imported in `funnelCorrelationLogicType.ts` + __ignore: null as FunnelCorrelationResultsType | null, + }), + loaders(({ values }) => ({ + correlations: [ + { events: [] } as Record<'events', FunnelCorrelation[]>, + { + loadEventCorrelations: async (_, breakpoint) => { + await breakpoint(100) + + try { + const results: Omit[] = ( + await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { + ...values.apiParams, + funnel_correlation_type: 'events', + funnel_correlation_exclude_event_names: values.excludedEventNames, + }) + ).result?.events + + return { + events: results.map((result) => ({ + ...result, + result_type: FunnelCorrelationResultsType.Events, + })), + } + } catch (error) { + lemonToast.error('Failed to load correlation results', { toastId: 'funnel-correlation-error' }) + return { events: [] } + } + }, + }, + ], + eventWithPropertyCorrelations: [ + {} as Record, + { + loadEventWithPropertyCorrelations: async (eventName: string) => { + const results: Omit[] = ( + await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { + ...values.apiParams, + funnel_correlation_type: 'event_with_properties', + funnel_correlation_event_names: [eventName], + funnel_correlation_event_exclude_property_names: values.excludedEventPropertyNames, + }) + ).result?.events + + return { + [eventName]: results.map((result) => ({ + ...result, + result_type: FunnelCorrelationResultsType.EventWithProperties, + })), + } + }, + }, + ], + })), + reducers({ + correlationTypes: [ + [FunnelCorrelationType.Success, FunnelCorrelationType.Failure] as FunnelCorrelationType[], + { + setCorrelationTypes: (_, { types }) => types, + }, + ], + loadedEventCorrelationsTableOnce: [ + false, + { + loadEventCorrelations: () => true, + }, + ], + nestedTableExpandedKeys: [ + [] as string[], + { + removeNestedTableExpandedKey: (state, { expandKey }) => { + return state.filter((key) => key !== expandKey) + }, + addNestedTableExpandedKey: (state, { expandKey }) => { + return [...state, expandKey] + }, + loadEventCorrelationsSuccess: () => { + return [] + }, + }, + ], + eventWithPropertyCorrelations: { + loadEventCorrelationsSuccess: () => { + return {} + }, + loadEventWithPropertyCorrelationsSuccess: (state, { eventWithPropertyCorrelations }) => { + return { + ...state, + ...eventWithPropertyCorrelations, + } + }, + }, + }), + selectors({ + // apiParams for data exploration and legacy mode + apiParams: [ + (s) => [s.isUsingDataExploration, s.dataExplorationApiParams, s.legacyApiParams], + (isUsingDataExploration, dataExplorationApiParams, legacyApiParams) => { + return isUsingDataExploration ? dataExplorationApiParams : legacyApiParams + }, + ], + dataExplorationApiParams: [ + (s) => [s.querySource], + (querySource) => { + const cleanedParams: Partial = querySource + ? cleanFilters(queryNodeToFilter(querySource)) + : {} + return cleanedParams + }, + ], + legacyApiParams: [ + (s) => [s.filters], + (filters) => { + const cleanedParams: Partial = cleanFilters(filters) + return cleanedParams + }, + ], + // aggregationGroupTypeIndex for data exploration and legacy mode + aggregationGroupTypeIndex: [ + (s) => [s.isUsingDataExploration, s.querySource, s.filters], + (isUsingDataExploration, querySource, filters) => { + return isUsingDataExploration + ? querySource?.aggregation_group_type_index + : filters?.aggregation_group_type_index + }, + ], + + // event correlation + correlationValues: [ + (s) => [s.correlations, s.correlationTypes, s.excludedEventNames], + (correlations, correlationTypes, excludedEventNames): FunnelCorrelation[] => { + return correlations.events + ?.filter( + (correlation) => + correlationTypes.includes(correlation.correlation_type) && + !excludedEventNames.includes(correlation.event.event) + ) + .map((value) => { + return { + ...value, + odds_ratio: + value.correlation_type === FunnelCorrelationType.Success + ? value.odds_ratio + : 1 / value.odds_ratio, + } + }) + .sort((first, second) => { + return second.odds_ratio - first.odds_ratio + }) + }, + ], + excludedEventNames: [ + (s) => [s.currentTeam], + (currentTeam): string[] => currentTeam?.correlation_config?.excluded_event_names || [], + ], + isEventExcluded: [ + (s) => [s.excludedEventNames], + (excludedEventNames) => (eventName: string) => + excludedEventNames.find((name) => name === eventName) !== undefined, + ], + + // event property correlation + excludedEventPropertyNames: [ + (s) => [s.currentTeam], + (currentTeam): string[] => currentTeam?.correlation_config?.excluded_event_property_names || [], + ], + isEventPropertyExcluded: [ + (s) => [s.excludedEventPropertyNames], + (excludedEventPropertyNames) => (propertyName: string) => + excludedEventPropertyNames.find((name) => name === propertyName) !== undefined, + ], + eventWithPropertyCorrelationsValues: [ + (s) => [s.eventWithPropertyCorrelations, s.correlationTypes, s.excludedEventPropertyNames], + ( + eventWithPropertyCorrelations, + correlationTypes, + excludedEventPropertyNames + ): Record => { + const eventWithPropertyCorrelationsValues: Record = {} + for (const key in eventWithPropertyCorrelations) { + if (eventWithPropertyCorrelations.hasOwnProperty(key)) { + eventWithPropertyCorrelationsValues[key] = eventWithPropertyCorrelations[key] + ?.filter( + (correlation) => + correlationTypes.includes(correlation.correlation_type) && + !excludedEventPropertyNames.includes(correlation.event.event.split('::')[1]) + ) + .map((value) => { + return { + ...value, + odds_ratio: + value.correlation_type === FunnelCorrelationType.Success + ? value.odds_ratio + : 1 / value.odds_ratio, + } + }) + .sort((first, second) => { + return second.odds_ratio - first.odds_ratio + }) + } + } + return eventWithPropertyCorrelationsValues + }, + ], + eventHasPropertyCorrelations: [ + (s) => [s.eventWithPropertyCorrelationsValues], + (eventWithPropertyCorrelationsValues): ((eventName: string) => boolean) => { + return (eventName) => { + return !!eventWithPropertyCorrelationsValues[eventName] + } + }, + ], + }), + listeners(({ values }) => ({ + excludeEventFromProject: async ({ eventName }) => { + appendToCorrelationConfig('excluded_event_names', values.excludedEventNames, eventName) + }, + excludeEventPropertyFromProject: async ({ propertyName }) => { + appendToCorrelationConfig('excluded_event_property_names', values.excludedEventPropertyNames, propertyName) + }, + })), +]) diff --git a/frontend/src/scenes/funnels/funnelCorrelationUsageLogic.ts b/frontend/src/scenes/funnels/funnelCorrelationUsageLogic.ts new file mode 100644 index 0000000000000..a603cfe51c75c --- /dev/null +++ b/frontend/src/scenes/funnels/funnelCorrelationUsageLogic.ts @@ -0,0 +1,196 @@ +import { BreakPointFunction, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' + +import { EntityTypes, FunnelCorrelationResultsType, FunnelsFilterType, InsightLogicProps } from '~/types' +import { visibilitySensorLogic } from 'lib/components/VisibilitySensor/visibilitySensorLogic' + +import type { funnelCorrelationUsageLogicType } from './funnelCorrelationUsageLogicType' +import { insightLogic } from 'scenes/insights/insightLogic' +import { insightDataLogic } from 'scenes/insights/insightDataLogic' +import { parseEventAndProperty } from './funnelUtils' +import { funnelLogic } from './funnelLogic' +import { funnelDataLogic } from './funnelDataLogic' +import { funnelCorrelationLogic } from './funnelCorrelationLogic' +import { funnelPropertyCorrelationLogic } from './funnelPropertyCorrelationLogic' + +export const funnelCorrelationUsageLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps('insight_funnel')), + path((key) => ['scenes', 'funnels', 'funnelCorrelationUsageLogic', key]), + + connect((props: InsightLogicProps) => ({ + logic: [eventUsageLogic], + + values: [ + insightLogic(props), + ['filters', 'isInDashboardContext'], + funnelPropertyCorrelationLogic(props), + ['allProperties'], + ], + + actions: [ + insightLogic(props), + ['loadResultsSuccess'], + insightDataLogic(props), + ['loadDataSuccess'], + funnelLogic(props), + ['hideSkewWarning as legacyHideSkewWarning', 'openCorrelationPersonsModal'], + funnelDataLogic(props), + ['hideSkewWarning'], + funnelCorrelationLogic(props), + [ + 'setCorrelationTypes', + 'excludeEventFromProject', + 'loadEventWithPropertyCorrelations', + 'excludeEventPropertyFromProject', + ], + funnelPropertyCorrelationLogic(props), + ['setPropertyCorrelationTypes', 'excludePropertyFromProject', 'setPropertyNames'], + eventUsageLogic, + ['reportCorrelationViewed', 'reportCorrelationInteraction'], + ], + })), + + reducers({ + shouldReportCorrelationViewed: [ + true as boolean, + { + loadResultsSuccess: () => true, + loadDataSuccess: () => true, + reportCorrelationViewed: (current, { propertiesTable }) => { + const correlationViewed = !propertiesTable + return correlationViewed ? false : current + }, + }, + ], + shouldReportPropertyCorrelationViewed: [ + true as boolean, + { + loadResultsSuccess: () => true, + loadDataSuccess: () => true, + reportCorrelationViewed: (current, { propertiesTable }) => { + const propertyCorrelationViewed = !!propertiesTable + return propertyCorrelationViewed ? false : current + }, + }, + ], + }), + + selectors({ + correlationPropKey: [ + () => [(_, props) => props], + (props): string => `correlation-${keyForInsightLogicProps('insight_funnel')(props)}`, + ], + }), + + listeners(({ values, actions }) => ({ + // skew warning + legacyHideSkewWarning: () => { + actions.reportCorrelationInteraction(FunnelCorrelationResultsType.Events, 'hide skew warning') + }, + hideSkewWarning: () => { + actions.reportCorrelationInteraction(FunnelCorrelationResultsType.Events, 'hide skew warning') + }, + + // event correlation + [visibilitySensorLogic({ id: values.correlationPropKey }).actionTypes.setVisible]: async ( + { visible }: { visible: boolean }, + breakpoint: BreakPointFunction + ) => { + if (visible && values.shouldReportCorrelationViewed) { + actions.reportCorrelationViewed(values.filters, 0) + await breakpoint(10000) + actions.reportCorrelationViewed(values.filters, 10) + } + }, + setCorrelationTypes: ({ types }) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.Events, + 'set correlation types', + { types } + ) + }, + excludeEventFromProject: async ({ eventName }) => { + eventUsageLogic.actions.reportCorrelationInteraction(FunnelCorrelationResultsType.Events, 'exclude event', { + event_name: eventName, + }) + }, + + // property correlation + [visibilitySensorLogic({ id: `${values.correlationPropKey}-properties` }).actionTypes.setVisible]: async ( + { visible }: { visible: boolean }, + breakpoint: BreakPointFunction + ) => { + if (visible && values.shouldReportPropertyCorrelationViewed) { + actions.reportCorrelationViewed(values.filters, 0, true) + await breakpoint(10000) + actions.reportCorrelationViewed(values.filters, 10, true) + } + }, + setPropertyCorrelationTypes: ({ types }) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.Properties, + 'set property correlation types', + { types } + ) + }, + excludePropertyFromProject: ({ propertyName }) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.Events, + 'exclude person property', + { + person_property: propertyName, + } + ) + }, + + // event property correlation + setPropertyNames: async ({ propertyNames }) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.Properties, + 'set property names', + { property_names: propertyNames.length === values.allProperties.length ? '$all' : propertyNames } + ) + }, + loadEventWithPropertyCorrelations: async (eventName: string) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.EventWithProperties, + 'load event with properties', + { name: eventName } + ) + }, + excludeEventPropertyFromProject: async ({ propertyName }) => { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.EventWithProperties, + 'exclude event property', + { + property_name: propertyName, + } + ) + }, + + // person modal + openCorrelationPersonsModal: ({ correlation, success }) => { + if (values.isInDashboardContext) { + return + } + + if (correlation.result_type === FunnelCorrelationResultsType.Properties) { + eventUsageLogic.actions.reportCorrelationInteraction( + FunnelCorrelationResultsType.Properties, + 'person modal', + (values.filters as FunnelsFilterType)?.funnel_correlation_person_entity + ) + } else { + const { name, properties } = parseEventAndProperty(correlation.event) + eventUsageLogic.actions.reportCorrelationInteraction(correlation.result_type, 'person modal', { + id: name, + type: EntityTypes.EVENTS, + properties, + converted: success, + }) + } + }, + })), +]) diff --git a/frontend/src/scenes/funnels/funnelLogic.test.ts b/frontend/src/scenes/funnels/funnelLogic.test.ts index 02532c560be12..5b0bf66d2aaf3 100644 --- a/frontend/src/scenes/funnels/funnelLogic.test.ts +++ b/frontend/src/scenes/funnels/funnelLogic.test.ts @@ -1,25 +1,13 @@ -import { DEFAULT_EXCLUDED_PERSON_PROPERTIES, funnelLogic } from './funnelLogic' -import { MOCK_DEFAULT_TEAM, MOCK_TEAM_ID } from 'lib/api.mock' -import { expectLogic, partial } from 'kea-test-utils' +import { funnelLogic } from './funnelLogic' +import { MOCK_TEAM_ID } from 'lib/api.mock' +import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { insightLogic } from 'scenes/insights/insightLogic' -import { - AvailableFeature, - CorrelationConfigType, - FunnelCorrelation, - FunnelCorrelationResultsType, - FunnelCorrelationType, - FunnelsFilterType, - FunnelVizType, - InsightLogicProps, - InsightShortId, - InsightType, -} from '~/types' +import { AvailableFeature, InsightLogicProps, InsightShortId, InsightType } from '~/types' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' -import { groupPropertiesModel } from '~/models/groupPropertiesModel' import { router } from 'kea-router' import { urls } from 'scenes/urls' import { useMocks } from '~/mocks/jest' @@ -29,87 +17,8 @@ import { openPersonsModal } from 'scenes/trends/persons-modal/PersonsModal' jest.mock('scenes/trends/persons-modal/PersonsModal') -const Insight12 = '12' as InsightShortId const Insight123 = '123' as InsightShortId -export const mockInsight = { - id: Insight123, - short_id: 'SvoU2bMC', - name: null, - filters: { - breakdown: null, - breakdown_type: null, - display: 'FunnelViz', - events: [ - { - id: '$pageview', - type: 'events', - order: 0, - name: '$pageview', - custom_name: null, - math: null, - math_property: null, - properties: [], - }, - { - id: '$pageview', - type: 'events', - order: 1, - name: '$pageview', - custom_name: null, - math: null, - math_property: null, - properties: [], - }, - { - id: '$pageview', - type: 'events', - order: 2, - name: '$pageview', - custom_name: null, - math: null, - math_property: null, - properties: [], - }, - { - id: '$pageview', - type: 'events', - order: 3, - name: '$pageview', - custom_name: null, - math: null, - math_property: null, - properties: [], - }, - ], - funnel_from_step: 0, - funnel_to_step: 1, - funnel_viz_type: 'steps', - insight: 'FUNNELS', - layout: 'vertical', - }, - order: null, - deleted: false, - dashboard: null, - layouts: {}, - color: null, - last_refresh: null, - result: null, - created_at: '2021-09-22T18:22:20.036153Z', - description: null, - updated_at: '2021-09-22T19:03:49.322258Z', - tags: [], - favorited: false, - saved: false, - created_by: { - id: 1, - uuid: '017c0441-bcb2-0000-bccf-dfc24328c5f3', - distinct_id: 'fM7b6ZFi8MOssbkDI55ot8tMY2hkzrHdRy1qERa6rCK', - first_name: 'Alex', - email: 'alex@posthog.com', - }, -} - const funnelResults = [ { action_id: '$pageview', @@ -136,200 +45,24 @@ const funnelResults = [ describe('funnelLogic', () => { let logic: ReturnType - let correlationConfig: CorrelationConfigType = {} beforeEach(() => { useAvailableFeatures([AvailableFeature.CORRELATION_ANALYSIS, AvailableFeature.GROUP_ANALYTICS]) useMocks({ get: { - '/api/projects/@current': () => [ - 200, - { - ...MOCK_DEFAULT_TEAM, - correlation_config: correlationConfig, - }, - ], - '/api/projects/:team/insights/': (req) => { - if (req.url.searchParams.get('saved')) { - return [ - 200, - { - results: funnelResults, - }, - ] - } - const shortId = req.url.searchParams.get('short_id') || '' - if (shortId === '500') { - return [500, { status: 0, detail: 'error from the API' }] - } - return [ - 200, - { - results: [mockInsight], - }, - ] + '/api/projects/:team/insights/': { + results: [{}], }, - '/api/projects/:team/insights/trend/': { results: ['trends result from api'] }, + '/api/projects/:team/insights/:id/': {}, '/api/projects/:team/groups_types/': [], - '/some/people/url': { results: [{ people: [] }] }, - '/api/projects/:team/persons/funnel': { results: [], next: null }, - '/api/projects/:team/persons/properties': [ - { name: 'some property', count: 20 }, - { name: 'another property', count: 10 }, - { name: 'third property', count: 5 }, - ], - '/api/projects/:team/groups/property_definitions': { - '0': [ - { name: 'industry', count: 2 }, - { name: 'name', count: 1 }, - ], - '1': [{ name: 'name', count: 1 }], - }, - }, - patch: { - '/api/projects/:id': (req) => [ - 200, - { - ...MOCK_DEFAULT_TEAM, - correlation_config: { - ...correlationConfig, - excluded_person_property_names: (req.body as any)?.correlation_config - ?.excluded_person_property_names, - }, - }, - ], }, post: { - '/api/projects/:team/insights/': (req) => [ - 200, - { id: 12, short_id: Insight12, ...((req.body as any) || {}) }, - ], - '/api/projects/:team/insights/:id/viewed': [201], '/api/projects/:team/insights/funnel/': { - is_cached: true, - last_refresh: '2021-09-16T13:41:41.297295Z', result: funnelResults, - type: 'Funnel', - }, - '/api/projects/:team/insights/funnel/correlation': (req) => { - const data = req.body as any - if (data?.funnel_correlation_type === 'properties') { - const excludePropertyFromProjectNames = data?.funnel_correlation_exclude_names || [] - const includePropertyNames = data?.funnel_correlation_names || [] - return [ - 200, - { - is_cached: true, - last_refresh: '2021-09-16T13:41:41.297295Z', - result: { - events: [ - { - event: { event: 'some property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'success', - }, - { - event: { event: 'another property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - }, - ] - .filter( - (correlation) => - includePropertyNames.includes('$all') || - includePropertyNames.includes(correlation.event.event) - ) - .filter( - (correlation) => - !excludePropertyFromProjectNames.includes(correlation.event.event) - ), - }, - type: 'Funnel', - }, - ] - } else if (data?.funnel_correlation_type === 'events') { - return [ - 200, - { - is_cached: true, - last_refresh: '2021-09-16T13:41:41.297295Z', - result: { - events: [ - { - event: { event: 'some event' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'success', - }, - { - event: { event: 'another event' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - }, - ], - }, - type: 'Funnel', - }, - ] - } else if (data?.funnel_correlation_type === 'event_with_properties') { - const targetEvent = data?.funnel_correlation_event_names[0] - const excludedProperties = data?.funnel_correlation_event_exclude_property_names - return [ - 200, - { - result: { - events: [ - { - success_count: 1, - failure_count: 0, - odds_ratio: 29, - correlation_type: 'success', - event: { event: `some event::name::Hester` }, - }, - { - success_count: 1, - failure_count: 0, - odds_ratio: 29, - correlation_type: 'success', - event: { event: `some event::Another name::Alice` }, - }, - { - success_count: 1, - failure_count: 0, - odds_ratio: 25, - correlation_type: 'success', - event: { event: `another event::name::Aloha` }, - }, - { - success_count: 1, - failure_count: 0, - odds_ratio: 25, - correlation_type: 'success', - event: { event: `another event::Another name::Bob` }, - }, - ].filter( - (record) => - record.event.event.split('::')[0] === targetEvent && - !excludedProperties.includes(record.event.event.split('::')[1]) - ), - last_refresh: '2021-11-05T09:26:16.175923Z', - is_cached: false, - }, - }, - ] - } }, }, }) initKeaTests(false) - window.POSTHOG_APP_CONTEXT = undefined // to force API request to /api/project/@current }) const defaultProps: InsightLogicProps = { @@ -622,441 +355,11 @@ describe('funnelLogic', () => { }) }) - describe('selectors', () => { - beforeEach(async () => { - await initFunnelLogic() - }) - describe('Correlation Names parsing', () => { - const basicFunnelRecord: FunnelCorrelation = { - event: { event: '$pageview::bzzz', properties: {}, elements: [] }, - odds_ratio: 1, - correlation_type: FunnelCorrelationType.Success, - success_count: 1, - failure_count: 1, - success_people_url: '/some/people/url', - failure_people_url: '/some/people/url', - result_type: FunnelCorrelationResultsType.Events, - } - it('chooses the correct name based on Event type', async () => { - const result = logic.values.parseDisplayNameForCorrelation(basicFunnelRecord) - expect(result).toEqual({ - first_value: '$pageview::bzzz', - second_value: undefined, - }) - }) - - it('chooses the correct name based on Property type', async () => { - const result = logic.values.parseDisplayNameForCorrelation({ - ...basicFunnelRecord, - result_type: FunnelCorrelationResultsType.Properties, - }) - expect(result).toEqual({ - first_value: '$pageview', - second_value: 'bzzz', - }) - }) - - it('chooses the correct name based on EventWithProperty type', async () => { - const result = logic.values.parseDisplayNameForCorrelation({ - ...basicFunnelRecord, - result_type: FunnelCorrelationResultsType.EventWithProperties, - event: { - event: '$pageview::library::1.2', - properties: { random: 'x' }, - elements: [], - }, - }) - expect(result).toEqual({ - first_value: 'library', - second_value: '1.2', - }) - }) - - it('handles autocapture events on EventWithProperty type', async () => { - const result = logic.values.parseDisplayNameForCorrelation({ - ...basicFunnelRecord, - result_type: FunnelCorrelationResultsType.EventWithProperties, - event: { - event: '$autocapture::elements_chain::xyz_elements_a.link*', - properties: { $event_type: 'click' }, - elements: [ - { - tag_name: 'a', - href: '#', - attributes: { blah: 'https://example.com' }, - nth_child: 0, - nth_of_type: 0, - order: 0, - text: 'bazinga', - }, - ], - }, - }) - expect(result).toEqual({ - first_value: 'clicked link with text "bazinga"', - second_value: undefined, - }) - }) - - it('handles autocapture events without elements_chain on EventWithProperty type', async () => { - const result = logic.values.parseDisplayNameForCorrelation({ - ...basicFunnelRecord, - result_type: FunnelCorrelationResultsType.EventWithProperties, - event: { - event: '$autocapture::library::1.2', - properties: { random: 'x' }, - elements: [], - }, - }) - expect(result).toEqual({ - first_value: 'library', - second_value: '1.2', - }) - }) - }) - }) - - describe('funnel correlation matrix', () => { - beforeEach(async () => { - await initFunnelLogic() - }) - it('Selecting a record returns appropriate values', async () => { - await expectLogic(logic, () => - logic.actions.setFunnelCorrelationDetails({ - event: { event: 'some event', elements: [], properties: {} }, - success_people_url: '', - failure_people_url: '', - success_count: 2, - failure_count: 4, - odds_ratio: 3, - correlation_type: FunnelCorrelationType.Success, - result_type: FunnelCorrelationResultsType.Events, - }) - ).toMatchValues({ - correlationMatrixAndScore: { - correlationScore: expect.anything(), - correlationScoreStrength: 'weak', - truePositive: 2, - falsePositive: 2, - trueNegative: 11, - falseNegative: 4, - }, - }) - - expect(logic.values.correlationMatrixAndScore.correlationScore).toBeCloseTo(0.204) - }) - }) - - describe('funnel correlation properties', () => { - const props = { dashboardItemId: Insight123, syncWithUrl: true } - - it('Selecting all properties returns expected result', async () => { - await initFunnelLogic(props) - await expectLogic(logic, () => logic.actions.setPropertyNames(logic.values.allProperties)) - .toFinishListeners() - .toMatchValues({ - propertyCorrelations: { - events: [ - { - event: { event: 'some property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'success', - result_type: FunnelCorrelationResultsType.Properties, - }, - { - event: { event: 'another property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - result_type: FunnelCorrelationResultsType.Properties, - }, - ], - }, - }) - }) - - it('Deselecting all returns empty result', async () => { - await initFunnelLogic(props) - await expectLogic(logic, () => logic.actions.setPropertyNames([])) - .toDispatchActions(logic, ['loadPropertyCorrelationsSuccess']) - .toMatchValues({ - propertyCorrelations: { - events: [], - }, - }) - }) - - // TODO: loading of property correlations is now dependent on the table being shown in react - it.skip('are updated when results are loaded, when steps visualisation set', async () => { - await initFunnelLogic(props) - const filters = { - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - } - await router.actions.push(urls.insightNew(filters)) - - await expectLogic(logic) - .toFinishAllListeners() - .toMatchValues({ - steps: [ - { action_id: '$pageview', count: 19, name: '$pageview', order: 0, type: 'events' }, - { action_id: '$pageview', count: 7, name: '$pageview', order: 1, type: 'events' }, - { action_id: '$pageview', count: 4, name: '$pageview', order: 2, type: 'events' }, - ], - propertyCorrelations: { - events: [ - { - event: { event: 'some property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'success', - result_type: FunnelCorrelationResultsType.Properties, - }, - { - event: { event: 'another property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - result_type: FunnelCorrelationResultsType.Properties, - }, - ], - }, - }) - }) - it('are not updated when results are loaded, when steps visualisation set, with one funnel step', async () => { - await initFunnelLogic(props) - - await expectLogic(logic, () => { - logic.actions.loadResultsSuccess({ - filters: { - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - } as FunnelsFilterType, - result: [{ action_id: 'some event', order: 0 }], - }) - }) - .toFinishListeners() - .toMatchValues({ - steps: [{ action_id: 'some event', order: 0 }], - propertyCorrelations: { - events: [], - }, - correlations: { - events: [], - }, - }) - }) - it('are not triggered when results are loaded, when trends visualisation set', async () => { - await initFunnelLogic(props) - await expectLogic(logic, () => { - logic.actions.loadResultsSuccess({ - filters: { - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Trends, - } as FunnelsFilterType, - }) - }).toNotHaveDispatchedActions(['loadEventCorrelations', 'loadPropertyCorrelations']) - }) - - it('triggers update to correlation list when property excluded from project', async () => { - userLogic.mount() - await initFunnelLogic(props) - // - // // Make sure we have loaded the team already - // await expectLogic(teamLogic, () => teamLogic.actions.loadCurrentTeam()).toFinishAllListeners() - - await expectLogic(logic, () => { - logic.actions.setPropertyNames(logic.values.allProperties) - logic.actions.loadResultsSuccess({ filters: { insight: InsightType.FUNNELS } }) - logic.actions.excludePropertyFromProject('another property') - }) - .toFinishAllListeners() - .toMatchValues({ - propertyNames: ['some property', 'third property'], - excludedPropertyNames: DEFAULT_EXCLUDED_PERSON_PROPERTIES.concat(['another property']), - allProperties: ['some property', 'third property'], - }) - - expect(logic.values.propertyCorrelationValues).toEqual([ - { - event: { event: 'some property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'success', - result_type: FunnelCorrelationResultsType.Properties, - }, - ]) - }) - - it('isPropertyExcludedFromProject returns true initially, then false when excluded, and is persisted to team config', async () => { - await initFunnelLogic(props) - - expect(logic.values.isPropertyExcludedFromProject('some property')).toBe(false) - - await expectLogic(logic, () => - logic.actions.excludePropertyFromProject('some property') - ).toFinishAllListeners() - - expect(logic.values.isPropertyExcludedFromProject('some property')).toBe(true) - - await expectLogic(teamLogic).toMatchValues({ - currentTeam: partial({ - correlation_config: { - excluded_person_property_names: DEFAULT_EXCLUDED_PERSON_PROPERTIES.concat(['some property']), - }, - }), - }) - - // Also make sure that excluding the property again doesn't double - // up on the config list - await expectLogic(logic, () => - logic.actions.excludePropertyFromProject('some property') - ).toFinishAllListeners() - - await expectLogic(teamLogic).toMatchValues({ - currentTeam: partial({ - correlation_config: { - excluded_person_property_names: DEFAULT_EXCLUDED_PERSON_PROPERTIES.concat(['some property']), - }, - }), - }) - }) - - it('loads property exclude list from Project settings', async () => { - correlationConfig = { excluded_person_property_names: ['some property'] } - await initFunnelLogic(props) - - await expectLogic(teamLogic).toMatchValues({ - currentTeam: partial({ - correlation_config: { excluded_person_property_names: ['some property'] }, - }), - }) - - await expectLogic(logic, () => { - logic.actions.setPropertyNames(logic.values.allProperties) - logic.actions.loadResultsSuccess({ filters: { insight: InsightType.FUNNELS } }) - }) - .toFinishAllListeners() - .toMatchValues({ - propertyCorrelations: { - events: [ - { - event: { event: 'another property' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - result_type: FunnelCorrelationResultsType.Properties, - }, - ], - }, - }) - }) - - // TODO: loading of correlations is now dependent on the table being shown in react - it.skip('loads event exclude list from Project settings', async () => { - correlationConfig = { excluded_event_names: ['some event'] } - await initFunnelLogic(props) - - await expectLogic(teamLogic).toMatchValues({ - currentTeam: partial({ - correlation_config: { excluded_event_names: ['some event'] }, - }), - }) - - const filters = { - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - } - await router.actions.push(urls.insightNew(filters)) - - await expectLogic(logic) - .toFinishAllListeners() - .toMatchValues({ - correlationValues: [ - { - event: { event: 'another event' }, - success_count: 1, - failure_count: 1, - odds_ratio: 1, - correlation_type: 'failure', - result_type: FunnelCorrelationResultsType.Events, - }, - ], - }) - }) - - it('loads event property exclude list from Project settings', async () => { - correlationConfig = { excluded_event_property_names: ['name'] } - await initFunnelLogic(props) - - await expectLogic(teamLogic).toMatchValues({ - currentTeam: partial({ - correlation_config: { excluded_event_property_names: ['name'] }, - }), - }) - - await expectLogic(logic, () => { - logic.actions.loadEventWithPropertyCorrelations('some event') - }) - .toDispatchActions(logic, ['loadEventWithPropertyCorrelationsSuccess']) - .toFinishListeners() - .toMatchValues({ - eventWithPropertyCorrelations: { - 'some event': [ - { - event: { event: 'some event::Another name::Alice' }, - success_count: 1, - failure_count: 0, - odds_ratio: 29, - correlation_type: 'success', - result_type: FunnelCorrelationResultsType.EventWithProperties, - }, - ], - }, - }) - }) - - // TODO: fix this test - it.skip('Selecting all group properties selects correct properties', async () => { - await initFunnelLogic(props) - - groupPropertiesModel.mount() - groupPropertiesModel.actions.loadAllGroupProperties() - await expectLogic(groupPropertiesModel).toDispatchActions(['loadAllGroupPropertiesSuccess']) - - const filters = { - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - } - await router.actions.push(urls.insightNew(filters)) - - await expectLogic(logic, () => logic.actions.setFilters({ aggregation_group_type_index: 0 })) - .toFinishAllListeners() - .toMatchValues({ - allProperties: ['industry', 'name'], - propertyNames: ['industry', 'name'], - }) - - await expectLogic(logic, () => logic.actions.setFilters({ aggregation_group_type_index: 1 })) - .toFinishAllListeners() - .toMatchValues({ - allProperties: ['name'], - propertyNames: ['name'], - }) - }) - }) - describe('funnel simple vs. advanced mode', () => { beforeEach(async () => { await initFunnelLogic() }) + it("toggleAdvancedMode() doesn't trigger a load result", async () => { await expectLogic(logic, () => { logic.actions.toggleAdvancedMode() diff --git a/frontend/src/scenes/funnels/funnelLogic.ts b/frontend/src/scenes/funnels/funnelLogic.ts index 6bb4655f1ecd3..bc21c97e20dcc 100644 --- a/frontend/src/scenes/funnels/funnelLogic.ts +++ b/frontend/src/scenes/funnels/funnelLogic.ts @@ -1,23 +1,16 @@ -import { BreakPointFunction, kea } from 'kea' +import { kea } from 'kea' import equal from 'fast-deep-equal' -import api from 'lib/api' import { insightLogic } from 'scenes/insights/insightLogic' -import { autoCaptureEventToDescription, average, percentage, sum } from 'lib/utils' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { average, percentage, sum } from 'lib/utils' import type { funnelLogicType } from './funnelLogicType' import { - AnyPropertyFilter, BinCountValue, - CorrelationConfigType, - ElementPropertyFilter, - EntityTypes, FilterType, FlattenedFunnelStepByBreakdown, FunnelResultType, FunnelConversionWindowTimeUnit, FunnelCorrelation, FunnelCorrelationResultsType, - FunnelCorrelationType, FunnelsFilterType, FunnelStep, FunnelStepRangeEntityFilter, @@ -30,8 +23,6 @@ import { HistogramGraphDatum, InsightLogicProps, InsightType, - PropertyFilterType, - PropertyOperator, StepOrderValue, TrendResult, } from '~/types' @@ -51,46 +42,18 @@ import { stepsWithConversionMetrics, flattenedStepsByBreakdown, generateBaselineConversionUrl, + parseBreakdownValue, + parseEventAndProperty, } from './funnelUtils' import { dashboardsModel } from '~/models/dashboardsModel' import { cleanFilters } from 'scenes/insights/utils/cleanFilters' import { isFunnelsFilter, keyForInsightLogicProps } from 'scenes/insights/sharedUtils' -import { teamLogic } from '../teamLogic' -import { personPropertiesModel } from '~/models/personPropertiesModel' -import { groupPropertiesModel } from '~/models/groupPropertiesModel' -import { visibilitySensorLogic } from 'lib/components/VisibilitySensor/visibilitySensorLogic' -import { elementsToAction } from 'scenes/events/createActionFromEvent' import { groupsModel, Noun } from '~/models/groupsModel' import { dayjs } from 'lib/dayjs' -import { lemonToast } from 'lib/lemon-ui/lemonToast' import { LemonSelectOptions } from 'lib/lemon-ui/LemonSelect' import { openPersonsModal } from 'scenes/trends/persons-modal/PersonsModal' import { funnelTitle } from 'scenes/trends/persons-modal/persons-modal-utils' -// List of events that should be excluded, if we don't have an explicit list of -// excluded properties. Copied from -// https://github.com/PostHog/posthog/issues/6474#issuecomment-952044722 -export const DEFAULT_EXCLUDED_PERSON_PROPERTIES = [ - '$initial_geoip_postal_code', - '$initial_geoip_latitude', - '$initial_geoip_longitude', - '$geoip_latitude', - '$geoip_longitude', - '$geoip_postal_code', - '$geoip_continent_code', - '$geoip_continent_name', - '$initial_geoip_continent_code', - '$initial_geoip_continent_name', - '$geoip_time_zone', - '$geoip_country_code', - '$geoip_subdivision_1_code', - '$initial_geoip_subdivision_1_code', - '$geoip_subdivision_2_code', - '$initial_geoip_subdivision_2_code', - '$geoip_subdivision_name', - '$initial_geoip_subdivision_name', -] - export type OpenPersonsModelProps = { step: FunnelStep stepIndex?: number @@ -106,17 +69,11 @@ export const funnelLogic = kea({ values: [ insightLogic(props), ['filters as inflightFilters', 'insight', 'isInDashboardContext', 'hiddenLegendKeys'], - teamLogic, - ['currentTeamId', 'currentTeam'], - personPropertiesModel, - ['personProperties'], groupsModel, ['aggregationLabel'], - groupPropertiesModel, - ['groupProperties'], ], actions: [insightLogic(props), ['loadResults', 'loadResultsSuccess', 'toggleVisibility']], - logic: [eventUsageLogic, dashboardsModel], + logic: [dashboardsModel], }), actions: () => ({ @@ -154,10 +111,6 @@ export const funnelLogic = kea({ series, converted, }), - openCorrelationPersonsModal: (correlation: FunnelCorrelation, success: boolean) => ({ - correlation, - success, - }), setStepReference: (stepReference: FunnelStepReference) => ({ stepReference }), changeStepRange: (funnel_from_step?: number, funnel_to_step?: number) => ({ funnel_from_step, @@ -167,20 +120,6 @@ export const funnelLogic = kea({ setBinCount: (binCount: BinCountValue) => ({ binCount }), toggleAdvancedMode: true, - // Correlation related actions - setCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), - setPropertyCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), - hideSkewWarning: true, - setFunnelCorrelationDetails: (payload: FunnelCorrelation | null) => ({ payload }), - - setPropertyNames: (propertyNames: string[]) => ({ propertyNames }), - excludePropertyFromProject: (propertyName: string) => ({ propertyName }), - excludeEventFromProject: (eventName: string) => ({ eventName }), - excludeEventPropertyFromProject: (eventName: string, propertyName: string) => ({ eventName, propertyName }), - - addNestedTableExpandedKey: (expandKey: string) => ({ expandKey }), - removeNestedTableExpandedKey: (expandKey: string) => ({ expandKey }), - showTooltip: ( origin: [number, number, number], stepIndex: number, @@ -191,106 +130,14 @@ export const funnelLogic = kea({ series, }), hideTooltip: true, - }), - defaults: { - // This is a hack to get `FunnelCorrelationResultsType` imported in `funnelLogicType.ts` - __ignore: null as FunnelCorrelationResultsType | null, - }, - loaders: ({ values }) => ({ - correlations: [ - { events: [] } as Record<'events', FunnelCorrelation[]>, - { - loadEventCorrelations: async (_, breakpoint) => { - await breakpoint(100) - - try { - const results: Omit[] = ( - await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { - ...values.apiParams, - funnel_correlation_type: 'events', - funnel_correlation_exclude_event_names: values.excludedEventNames, - }) - ).result?.events - - return { - events: results.map((result) => ({ - ...result, - result_type: FunnelCorrelationResultsType.Events, - })), - } - } catch (error) { - lemonToast.error('Failed to load correlation results', { toastId: 'funnel-correlation-error' }) - return { events: [] } - } - }, - }, - ], - propertyCorrelations: [ - { events: [] } as Record<'events', FunnelCorrelation[]>, - { - loadPropertyCorrelations: async (_, breakpoint) => { - const targetProperties = - values.propertyNames.length >= values.allProperties.length ? ['$all'] : values.propertyNames - - if (targetProperties.length === 0) { - return { events: [] } - } - - await breakpoint(100) - try { - const results: Omit[] = ( - await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { - ...values.apiParams, - funnel_correlation_type: 'properties', - funnel_correlation_names: targetProperties, - funnel_correlation_exclude_names: values.excludedPropertyNames, - }) - ).result?.events - - return { - events: results.map((result) => ({ - ...result, - result_type: FunnelCorrelationResultsType.Properties, - })), - } - } catch (error) { - lemonToast.error('Failed to load correlation results', { toastId: 'funnel-correlation-error' }) - return { events: [] } - } - }, - }, - ], - eventWithPropertyCorrelations: [ - {} as Record, - { - loadEventWithPropertyCorrelations: async (eventName: string) => { - const results: Omit[] = ( - await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { - ...values.apiParams, - funnel_correlation_type: 'event_with_properties', - funnel_correlation_event_names: [eventName], - funnel_correlation_event_exclude_property_names: values.excludedEventPropertyNames, - }) - ).result?.events - - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.EventWithProperties, - 'load event with properties', - { name: eventName } - ) - - return { - [eventName]: results.map((result) => ({ - ...result, - result_type: FunnelCorrelationResultsType.EventWithProperties, - })), - } - }, - }, - ], + // Correlation related actions + hideSkewWarning: true, + openCorrelationPersonsModal: (correlation: FunnelCorrelation, success: boolean) => ({ + correlation, + success, + }), }), - reducers: ({ props }) => ({ people: { clearFunnel: () => [], @@ -309,88 +156,12 @@ export const funnelLogic = kea({ [insightLogic(props).actionTypes.abortQuery]: (_: any, { exception }: any) => exception ?? null, }, ], - correlationTypes: [ - [FunnelCorrelationType.Success, FunnelCorrelationType.Failure] as FunnelCorrelationType[], - { - setCorrelationTypes: (_, { types }) => types, - }, - ], - propertyCorrelationTypes: [ - [FunnelCorrelationType.Success, FunnelCorrelationType.Failure] as FunnelCorrelationType[], - { - setPropertyCorrelationTypes: (_, { types }) => types, - }, - ], skewWarningHidden: [ false, { hideSkewWarning: () => true, }, ], - eventWithPropertyCorrelations: { - loadEventWithPropertyCorrelationsSuccess: (state, { eventWithPropertyCorrelations }) => { - return { - ...state, - ...eventWithPropertyCorrelations, - } - }, - loadEventCorrelationsSuccess: () => { - return {} - }, - }, - propertyNames: [ - [] as string[], - { - setPropertyNames: (_, { propertyNames }) => propertyNames, - excludePropertyFromProject: (selectedProperties, { propertyName }) => { - return selectedProperties.filter((p) => p !== propertyName) - }, - }, - ], - nestedTableExpandedKeys: [ - [] as string[], - { - removeNestedTableExpandedKey: (state, { expandKey }) => { - return state.filter((key) => key !== expandKey) - }, - addNestedTableExpandedKey: (state, { expandKey }) => { - return [...state, expandKey] - }, - loadEventCorrelationsSuccess: () => { - return [] - }, - }, - ], - shouldReportCorrelationViewed: [ - true as boolean, - { - loadResultsSuccess: () => true, - [eventUsageLogic.actionTypes.reportCorrelationViewed]: (current, { propertiesTable }) => { - if (!propertiesTable) { - return false // don't report correlation viewed again, since it was for events earlier - } - return current - }, - }, - ], - shouldReportPropertyCorrelationViewed: [ - true as boolean, - { - loadResultsSuccess: () => true, - [eventUsageLogic.actionTypes.reportCorrelationViewed]: (current, { propertiesTable }) => { - if (propertiesTable) { - return false - } - return current - }, - }, - ], - funnelCorrelationDetails: [ - null as null | FunnelCorrelation, - { - setFunnelCorrelationDetails: (_, { payload }) => payload, - }, - ], isTooltipShown: [ false, { @@ -410,18 +181,6 @@ export const funnelLogic = kea({ showTooltip: (_, { origin }) => origin, }, ], - loadedEventCorrelationsTableOnce: [ - false, - { - loadEventCorrelations: () => true, - }, - ], - loadedPropertyCorrelationsTableOnce: [ - false, - { - loadPropertyCorrelations: () => true, - }, - ], }), selectors: ({ selectors }) => ({ @@ -707,247 +466,14 @@ export const funnelLogic = kea({ return !(e?.status === 400 && e?.type === 'validation_error') }, ], - correlationValues: [ - () => [selectors.correlations, selectors.correlationTypes, selectors.excludedEventNames], - (correlations, correlationTypes, excludedEventNames): FunnelCorrelation[] => { - return correlations.events - ?.filter( - (correlation) => - correlationTypes.includes(correlation.correlation_type) && - !excludedEventNames.includes(correlation.event.event) - ) - .map((value) => { - return { - ...value, - odds_ratio: - value.correlation_type === FunnelCorrelationType.Success - ? value.odds_ratio - : 1 / value.odds_ratio, - } - }) - .sort((first, second) => { - return second.odds_ratio - first.odds_ratio - }) - }, - ], - propertyCorrelationValues: [ - () => [selectors.propertyCorrelations, selectors.propertyCorrelationTypes, selectors.excludedPropertyNames], - (propertyCorrelations, propertyCorrelationTypes, excludedPropertyNames): FunnelCorrelation[] => { - return propertyCorrelations.events - .filter( - (correlation) => - propertyCorrelationTypes.includes(correlation.correlation_type) && - !excludedPropertyNames.includes(correlation.event.event.split('::')[0]) - ) - .map((value) => { - return { - ...value, - odds_ratio: - value.correlation_type === FunnelCorrelationType.Success - ? value.odds_ratio - : 1 / value.odds_ratio, - } - }) - .sort((first, second) => { - return second.odds_ratio - first.odds_ratio - }) - }, - ], - eventWithPropertyCorrelationsValues: [ - () => [ - selectors.eventWithPropertyCorrelations, - selectors.correlationTypes, - selectors.excludedEventPropertyNames, - ], - ( - eventWithPropertyCorrelations, - correlationTypes, - excludedEventPropertyNames - ): Record => { - const eventWithPropertyCorrelationsValues: Record = {} - for (const key in eventWithPropertyCorrelations) { - if (eventWithPropertyCorrelations.hasOwnProperty(key)) { - eventWithPropertyCorrelationsValues[key] = eventWithPropertyCorrelations[key] - ?.filter( - (correlation) => - correlationTypes.includes(correlation.correlation_type) && - !excludedEventPropertyNames.includes(correlation.event.event.split('::')[1]) - ) - .map((value) => { - return { - ...value, - odds_ratio: - value.correlation_type === FunnelCorrelationType.Success - ? value.odds_ratio - : 1 / value.odds_ratio, - } - }) - .sort((first, second) => { - return second.odds_ratio - first.odds_ratio - }) - } - } - return eventWithPropertyCorrelationsValues - }, - ], - eventHasPropertyCorrelations: [ - () => [selectors.eventWithPropertyCorrelationsValues], - (eventWithPropertyCorrelationsValues): ((eventName: string) => boolean) => { - return (eventName) => { - return !!eventWithPropertyCorrelationsValues[eventName] - } - }, - ], - parseDisplayNameForCorrelation: [ - () => [], - (): ((record: FunnelCorrelation) => { - first_value: string - second_value?: string - }) => { - return (record) => { - let first_value = undefined - let second_value = undefined - const values = record.event.event.split('::') - - if (record.result_type === FunnelCorrelationResultsType.Events) { - first_value = record.event.event - return { first_value, second_value } - } else if (record.result_type === FunnelCorrelationResultsType.Properties) { - first_value = values[0] - second_value = values[1] - return { first_value, second_value } - } else if (values[0] === '$autocapture' && values[1] === 'elements_chain') { - // special case for autocapture elements_chain - first_value = autoCaptureEventToDescription({ - ...record.event, - event: '$autocapture', - }) as string - return { first_value, second_value } - } else { - // FunnelCorrelationResultsType.EventWithProperties - // Events here come in the form of event::property::value - return { first_value: values[1], second_value: values[2] } - } - } - }, - ], - correlationPropKey: [ - () => [(_, props) => props], - (props): string => `correlation-${keyForInsightLogicProps('insight_funnel')(props)}`, - ], disableFunnelBreakdownBaseline: [ () => [(_, props) => props], (props: InsightLogicProps): boolean => !!props.cachedInsight?.disable_baseline, ], - - isPropertyExcludedFromProject: [ - () => [selectors.excludedPropertyNames], - (excludedPropertyNames) => (propertyName: string) => - excludedPropertyNames.find((name) => name === propertyName) !== undefined, - ], - isEventExcluded: [ - () => [selectors.excludedEventNames], - (excludedEventNames) => (eventName: string) => - excludedEventNames.find((name) => name === eventName) !== undefined, - ], - - isEventPropertyExcluded: [ - () => [selectors.excludedEventPropertyNames], - (excludedEventPropertyNames) => (propertyName: string) => - excludedEventPropertyNames.find((name) => name === propertyName) !== undefined, - ], - excludedPropertyNames: [ - () => [selectors.currentTeam], - (currentTeam): string[] => - currentTeam?.correlation_config?.excluded_person_property_names || DEFAULT_EXCLUDED_PERSON_PROPERTIES, - ], - excludedEventNames: [ - () => [selectors.currentTeam], - (currentTeam): string[] => currentTeam?.correlation_config?.excluded_event_names || [], - ], - excludedEventPropertyNames: [ - () => [selectors.currentTeam], - (currentTeam): string[] => currentTeam?.correlation_config?.excluded_event_property_names || [], - ], - inversePropertyNames: [ - (s) => [s.filters, s.personProperties, s.groupProperties], - (filters, personProperties, groupProperties) => (excludedPersonProperties: string[]) => { - const targetProperties = - filters.aggregation_group_type_index !== undefined - ? groupProperties(filters.aggregation_group_type_index) - : personProperties - return targetProperties - .map((property) => property.name) - .filter((property) => !excludedPersonProperties.includes(property)) - }, - ], - allProperties: [ - (s) => [s.inversePropertyNames, s.excludedPropertyNames], - (inversePropertyNames, excludedPropertyNames): string[] => { - return inversePropertyNames(excludedPropertyNames || []) - }, - ], aggregationTargetLabel: [ (s) => [s.filters, s.aggregationLabel], (filters, aggregationLabel): Noun => aggregationLabel(filters.aggregation_group_type_index), ], - correlationMatrixAndScore: [ - (s) => [s.funnelCorrelationDetails, s.steps], - ( - funnelCorrelationDetails, - steps - ): { - truePositive: number - falsePositive: number - trueNegative: number - falseNegative: number - correlationScore: number - correlationScoreStrength: 'weak' | 'moderate' | 'strong' | null - } => { - if (!funnelCorrelationDetails) { - return { - truePositive: 0, - falsePositive: 0, - trueNegative: 0, - falseNegative: 0, - correlationScore: 0, - correlationScoreStrength: null, - } - } - - const successTotal = steps[steps.length - 1].count - const failureTotal = steps[0].count - successTotal - const success = funnelCorrelationDetails.success_count - const failure = funnelCorrelationDetails.failure_count - - const truePositive = success // has property, converted - const falseNegative = failure // has property, but dropped off - const trueNegative = failureTotal - failure // doesn't have property, dropped off - const falsePositive = successTotal - success // doesn't have property, converted - - // Phi coefficient: https://en.wikipedia.org/wiki/Phi_coefficient - const correlationScore = - (truePositive * trueNegative - falsePositive * falseNegative) / - Math.sqrt( - (truePositive + falsePositive) * - (truePositive + falseNegative) * - (trueNegative + falsePositive) * - (trueNegative + falseNegative) - ) - - const correlationScoreStrength = - Math.abs(correlationScore) > 0.5 ? 'strong' : Math.abs(correlationScore) > 0.3 ? 'moderate' : 'weak' - - return { - correlationScore, - truePositive, - falsePositive, - trueNegative, - falseNegative, - correlationScoreStrength, - } - }, - ], advancedOptionsUsedCount: [ (s) => [s.filters, s.stepReference], (filters, stepReference): number => { @@ -1084,14 +610,8 @@ export const funnelLogic = kea({ label: breakdown, }), }) - - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Properties, - 'person modal', - values.filters.funnel_correlation_person_entity - ) } else { - const { name, properties } = parseEventAndProperty(correlation.event) + const { name } = parseEventAndProperty(correlation.event) openPersonsModal({ url: success ? correlation.success_people_url : correlation.failure_people_url, @@ -1101,13 +621,6 @@ export const funnelLogic = kea({ label: name, }), }) - - eventUsageLogic.actions.reportCorrelationInteraction(correlation.result_type, 'person modal', { - id: name, - type: EntityTypes.EVENTS, - properties, - converted: success, - }) } }, changeStepRange: ({ funnel_from_step, funnel_to_step }) => { @@ -1125,182 +638,5 @@ export const funnelLogic = kea({ toggleAdvancedMode: () => { actions.setFilters({ funnel_advanced: !values.filters.funnel_advanced }) }, - excludeEventPropertyFromProject: async ({ propertyName }) => { - appendToCorrelationConfig('excluded_event_property_names', values.excludedEventPropertyNames, propertyName) - - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.EventWithProperties, - 'exclude event property', - { - property_name: propertyName, - } - ) - }, - excludeEventFromProject: async ({ eventName }) => { - appendToCorrelationConfig('excluded_event_names', values.excludedEventNames, eventName) - - eventUsageLogic.actions.reportCorrelationInteraction(FunnelCorrelationResultsType.Events, 'exclude event', { - event_name: eventName, - }) - }, - excludePropertyFromProject: ({ propertyName }) => { - appendToCorrelationConfig('excluded_person_property_names', values.excludedPropertyNames, propertyName) - - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Events, - 'exclude person property', - { - person_property: propertyName, - } - ) - }, - hideSkewWarning: () => { - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Events, - 'hide skew warning' - ) - }, - setCorrelationTypes: ({ types }) => { - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Events, - 'set correlation types', - { types } - ) - }, - setPropertyCorrelationTypes: ({ types }) => { - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Properties, - 'set property correlation types', - { types } - ) - }, - setPropertyNames: async ({ propertyNames }) => { - actions.loadPropertyCorrelations({}) - eventUsageLogic.actions.reportCorrelationInteraction( - FunnelCorrelationResultsType.Properties, - 'set property names', - { property_names: propertyNames.length === values.allProperties.length ? '$all' : propertyNames } - ) - }, - [visibilitySensorLogic({ id: values.correlationPropKey }).actionTypes.setVisible]: async ( - { - visible, - }: { - visible: boolean - }, - breakpoint: BreakPointFunction - ) => { - if (visible && values.shouldReportCorrelationViewed) { - eventUsageLogic.actions.reportCorrelationViewed(values.filters, 0) - await breakpoint(10000) - eventUsageLogic.actions.reportCorrelationViewed(values.filters, 10) - } - }, - - [visibilitySensorLogic({ id: `${values.correlationPropKey}-properties` }).actionTypes.setVisible]: async ( - { - visible, - }: { - visible: boolean - }, - breakpoint: BreakPointFunction - ) => { - if (visible && values.shouldReportPropertyCorrelationViewed) { - eventUsageLogic.actions.reportCorrelationViewed(values.filters, 0, true) - await breakpoint(10000) - eventUsageLogic.actions.reportCorrelationViewed(values.filters, 10, true) - } - }, }), }) - -const appendToCorrelationConfig = ( - configKey: keyof CorrelationConfigType, - currentValue: string[], - configValue: string -): void => { - // Helper to handle updating correlationConfig within the Team model. Only - // handles further appending to current values. - - // When we exclude a property, we want to update the config stored - // on the current Team/Project. - const oldCurrentTeam = teamLogic.values.currentTeam - - // If we haven't actually retrieved the current team, we can't - // update the config. - if (oldCurrentTeam === null || !currentValue) { - console.warn('Attempt to update correlation config without first retrieving existing config') - return - } - - const oldCorrelationConfig = oldCurrentTeam.correlation_config - - const configList = [...Array.from(new Set(currentValue.concat([configValue])))] - - const correlationConfig = { - ...oldCorrelationConfig, - [configKey]: configList, - } - - teamLogic.actions.updateCurrentTeam({ - correlation_config: correlationConfig, - }) -} - -const parseBreakdownValue = ( - item: string -): { - breakdown: string - breakdown_value: string -} => { - const components = item.split('::') - if (components.length === 1) { - return { breakdown: components[0], breakdown_value: '' } - } else { - return { - breakdown: components[0], - breakdown_value: components[1], - } - } -} - -const parseEventAndProperty = ( - event: FunnelCorrelation['event'] -): { - name: string - properties?: AnyPropertyFilter[] -} => { - const components = event.event.split('::') - /* - The `event` is either an event name, or event::property::property_value - */ - if (components.length === 1) { - return { name: components[0] } - } else if (components[0] === '$autocapture') { - // We use elementsToAction to generate the required property filters - const elementData = elementsToAction(event.elements) - return { - name: components[0], - properties: Object.entries(elementData) - .filter(([, propertyValue]) => !!propertyValue) - .map(([propertyKey, propertyValue]) => ({ - key: propertyKey as ElementPropertyFilter['key'], - operator: PropertyOperator.Exact, - type: PropertyFilterType.Element, - value: [propertyValue as string], - })), - } - } else { - return { - name: components[0], - properties: [ - { - key: components[1], - operator: PropertyOperator.Exact, - value: components[2], - type: PropertyFilterType.Event, - }, - ], - } - } -} diff --git a/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.test.ts b/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.test.ts new file mode 100644 index 0000000000000..1b9e54a714659 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.test.ts @@ -0,0 +1,235 @@ +import { expectLogic, partial } from 'kea-test-utils' +import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' +import { useAvailableFeatures } from '~/mocks/features' +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { + AvailableFeature, + CorrelationConfigType, + FunnelCorrelationResultsType, + InsightLogicProps, + InsightShortId, + InsightType, +} from '~/types' +import { DEFAULT_EXCLUDED_PERSON_PROPERTIES, funnelPropertyCorrelationLogic } from './funnelPropertyCorrelationLogic' + +const Insight123 = '123' as InsightShortId + +describe('funnelPropertyCorrelationLogic', () => { + const props = { dashboardItemId: Insight123, syncWithUrl: true } + let logic: ReturnType + let correlationConfig: CorrelationConfigType = {} + + beforeEach(() => { + useAvailableFeatures([AvailableFeature.CORRELATION_ANALYSIS, AvailableFeature.GROUP_ANALYTICS]) + useMocks({ + get: { + '/api/projects/@current': () => [ + 200, + { + ...MOCK_DEFAULT_TEAM, + correlation_config: correlationConfig, + }, + ], + '/api/projects/:team/insights/': { results: [{}] }, + '/api/projects/:team/insights/:id/': {}, + '/api/projects/:team/groups_types/': [], + '/api/projects/:team/persons/properties': [ + { name: 'some property', count: 20 }, + { name: 'another property', count: 10 }, + { name: 'third property', count: 5 }, + ], + '/api/projects/:team/groups/property_definitions': { + '0': [ + { name: 'industry', count: 2 }, + { name: 'name', count: 1 }, + ], + '1': [{ name: 'name', count: 1 }], + }, + }, + patch: { + '/api/projects/:id': (req) => [ + 200, + { + ...MOCK_DEFAULT_TEAM, + correlation_config: { + ...correlationConfig, + excluded_person_property_names: (req.body as any)?.correlation_config + ?.excluded_person_property_names, + }, + }, + ], + }, + post: { + '/api/projects/:team/insights/funnel/correlation': (req) => { + const data = req.body as any + const excludePropertyFromProjectNames = data?.funnel_correlation_exclude_names || [] + const includePropertyNames = data?.funnel_correlation_names || [] + return [ + 200, + { + is_cached: true, + last_refresh: '2021-09-16T13:41:41.297295Z', + result: { + events: [ + { + event: { event: 'some property' }, + success_count: 1, + failure_count: 1, + odds_ratio: 1, + correlation_type: 'success', + }, + { + event: { event: 'another property' }, + success_count: 1, + failure_count: 1, + odds_ratio: 1, + correlation_type: 'failure', + }, + ] + .filter( + (correlation) => + includePropertyNames.includes('$all') || + includePropertyNames.includes(correlation.event.event) + ) + .filter( + (correlation) => + !excludePropertyFromProjectNames.includes(correlation.event.event) + ), + }, + type: 'Funnel', + }, + ] + }, + }, + }) + initKeaTests(false) + window.POSTHOG_APP_CONTEXT = undefined // to force API request to /api/project/@current + }) + + const defaultProps: InsightLogicProps = { + dashboardItemId: undefined, + cachedInsight: { + short_id: undefined, + filters: { + insight: InsightType.FUNNELS, + actions: [ + { id: '$pageview', order: 0 }, + { id: '$pageview', order: 1 }, + ], + }, + result: null, + }, + } + + async function initPropertyFunnelCorrelationLogic(props: InsightLogicProps = defaultProps): Promise { + teamLogic.mount() + await expectLogic(teamLogic).toFinishAllListeners() + userLogic.mount() + await expectLogic(userLogic).toFinishAllListeners() + logic = funnelPropertyCorrelationLogic(props) + logic.mount() + await expectLogic(logic).toFinishAllListeners() + } + + it('Selecting all properties returns expected result', async () => { + await initPropertyFunnelCorrelationLogic(props) + await expectLogic(logic, () => logic.actions.setPropertyNames(logic.values.allProperties)) + .toFinishListeners() + .toMatchValues({ + propertyCorrelations: { + events: [ + { + event: { event: 'some property' }, + success_count: 1, + failure_count: 1, + odds_ratio: 1, + correlation_type: 'success', + result_type: FunnelCorrelationResultsType.Properties, + }, + { + event: { event: 'another property' }, + success_count: 1, + failure_count: 1, + odds_ratio: 1, + correlation_type: 'failure', + result_type: FunnelCorrelationResultsType.Properties, + }, + ], + }, + }) + }) + + it('Deselecting all returns empty result', async () => { + await initPropertyFunnelCorrelationLogic(props) + await expectLogic(logic, () => logic.actions.setPropertyNames([])) + .toDispatchActions(logic, ['loadPropertyCorrelationsSuccess']) + .toMatchValues({ + propertyCorrelations: { + events: [], + }, + }) + }) + + it('isPropertyExcludedFromProject returns true initially, then false when excluded, and is persisted to team config', async () => { + await initPropertyFunnelCorrelationLogic(props) + + expect(logic.values.isPropertyExcludedFromProject('some property')).toBe(false) + + await expectLogic(logic, () => logic.actions.excludePropertyFromProject('some property')).toFinishAllListeners() + + expect(logic.values.isPropertyExcludedFromProject('some property')).toBe(true) + + await expectLogic(teamLogic).toMatchValues({ + currentTeam: partial({ + correlation_config: { + excluded_person_property_names: DEFAULT_EXCLUDED_PERSON_PROPERTIES.concat(['some property']), + }, + }), + }) + + // Also make sure that excluding the property again doesn't double + // up on the config list + await expectLogic(logic, () => logic.actions.excludePropertyFromProject('some property')).toFinishAllListeners() + + await expectLogic(teamLogic).toMatchValues({ + currentTeam: partial({ + correlation_config: { + excluded_person_property_names: DEFAULT_EXCLUDED_PERSON_PROPERTIES.concat(['some property']), + }, + }), + }) + }) + + it('loads property exclude list from Project settings', async () => { + correlationConfig = { excluded_person_property_names: ['some property'] } + await initPropertyFunnelCorrelationLogic(props) + + await expectLogic(teamLogic).toMatchValues({ + currentTeam: partial({ + correlation_config: { excluded_person_property_names: ['some property'] }, + }), + }) + + await expectLogic(logic, () => { + logic.actions.setPropertyNames(logic.values.allProperties) + }) + .toFinishAllListeners() + .toMatchValues({ + propertyCorrelations: { + events: [ + { + event: { event: 'another property' }, + success_count: 1, + failure_count: 1, + odds_ratio: 1, + correlation_type: 'failure', + result_type: FunnelCorrelationResultsType.Properties, + }, + ], + }, + }) + }) +}) diff --git a/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.ts b/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.ts new file mode 100644 index 0000000000000..463905a44c227 --- /dev/null +++ b/frontend/src/scenes/funnels/funnelPropertyCorrelationLogic.ts @@ -0,0 +1,188 @@ +import { kea, props, key, path, selectors, listeners, connect, reducers, actions, defaults } from 'kea' +import { loaders } from 'kea-loaders' + +import { teamLogic } from '../teamLogic' +import { personPropertiesModel } from '~/models/personPropertiesModel' +import { groupPropertiesModel } from '~/models/groupPropertiesModel' + +import { FunnelCorrelation, FunnelCorrelationResultsType, FunnelCorrelationType, InsightLogicProps } from '~/types' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { appendToCorrelationConfig } from './funnelUtils' +import api from 'lib/api' +import { lemonToast } from '@posthog/lemon-ui' + +import type { funnelPropertyCorrelationLogicType } from './funnelPropertyCorrelationLogicType' +import { funnelCorrelationLogic } from './funnelCorrelationLogic' + +// List of events that should be excluded, if we don't have an explicit list of +// excluded properties. Copied from +// https://github.com/PostHog/posthog/issues/6474#issuecomment-952044722 +export const DEFAULT_EXCLUDED_PERSON_PROPERTIES = [ + '$initial_geoip_postal_code', + '$initial_geoip_latitude', + '$initial_geoip_longitude', + '$geoip_latitude', + '$geoip_longitude', + '$geoip_postal_code', + '$geoip_continent_code', + '$geoip_continent_name', + '$initial_geoip_continent_code', + '$initial_geoip_continent_name', + '$geoip_time_zone', + '$geoip_country_code', + '$geoip_subdivision_1_code', + '$initial_geoip_subdivision_1_code', + '$geoip_subdivision_2_code', + '$initial_geoip_subdivision_2_code', + '$geoip_subdivision_name', + '$initial_geoip_subdivision_name', +] + +export const funnelPropertyCorrelationLogic = kea([ + props({} as InsightLogicProps), + key(keyForInsightLogicProps('insight_funnel')), + path((key) => ['scenes', 'funnels', 'funnelPropertyCorrelationLogic', key]), + connect((props: InsightLogicProps) => ({ + values: [ + funnelCorrelationLogic(props), + ['apiParams', 'aggregationGroupTypeIndex'], + teamLogic, + ['currentTeamId', 'currentTeam'], + personPropertiesModel, + ['personProperties'], + groupPropertiesModel, + ['groupProperties'], + ], + })), + actions({ + setPropertyCorrelationTypes: (types: FunnelCorrelationType[]) => ({ types }), + setPropertyNames: (propertyNames: string[]) => ({ propertyNames }), + excludePropertyFromProject: (propertyName: string) => ({ propertyName }), + }), + defaults({ + // This is a hack to get `FunnelCorrelationResultsType` imported in `funnelCorrelationLogicType.ts` + __ignore: null as FunnelCorrelationResultsType | null, + }), + loaders(({ values }) => ({ + propertyCorrelations: [ + { events: [] } as Record<'events', FunnelCorrelation[]>, + { + loadPropertyCorrelations: async (_, breakpoint) => { + const targetProperties = + values.propertyNames.length >= values.allProperties.length ? ['$all'] : values.propertyNames + + if (targetProperties.length === 0) { + return { events: [] } + } + + await breakpoint(100) + + try { + const results: Omit[] = ( + await api.create(`api/projects/${values.currentTeamId}/insights/funnel/correlation`, { + ...values.apiParams, + funnel_correlation_type: 'properties', + funnel_correlation_names: targetProperties, + funnel_correlation_exclude_names: values.excludedPropertyNames, + }) + ).result?.events + + return { + events: results.map((result) => ({ + ...result, + result_type: FunnelCorrelationResultsType.Properties, + })), + } + } catch (error) { + lemonToast.error('Failed to load correlation results', { toastId: 'funnel-correlation-error' }) + return { events: [] } + } + }, + }, + ], + })), + reducers({ + propertyCorrelationTypes: [ + [FunnelCorrelationType.Success, FunnelCorrelationType.Failure] as FunnelCorrelationType[], + { + setPropertyCorrelationTypes: (_, { types }) => types, + }, + ], + propertyNames: [ + [] as string[], + { + setPropertyNames: (_, { propertyNames }) => propertyNames, + excludePropertyFromProject: (selectedProperties, { propertyName }) => { + return selectedProperties.filter((p) => p !== propertyName) + }, + }, + ], + loadedPropertyCorrelationsTableOnce: [ + false, + { + loadPropertyCorrelations: () => true, + }, + ], + }), + selectors({ + propertyCorrelationValues: [ + (s) => [s.propertyCorrelations, s.propertyCorrelationTypes, s.excludedPropertyNames], + (propertyCorrelations, propertyCorrelationTypes, excludedPropertyNames): FunnelCorrelation[] => { + return propertyCorrelations.events + .filter( + (correlation) => + propertyCorrelationTypes.includes(correlation.correlation_type) && + !excludedPropertyNames.includes(correlation.event.event.split('::')[0]) + ) + .map((value) => { + return { + ...value, + odds_ratio: + value.correlation_type === FunnelCorrelationType.Success + ? value.odds_ratio + : 1 / value.odds_ratio, + } + }) + .sort((first, second) => { + return second.odds_ratio - first.odds_ratio + }) + }, + ], + excludedPropertyNames: [ + (s) => [s.currentTeam], + (currentTeam): string[] => + currentTeam?.correlation_config?.excluded_person_property_names || DEFAULT_EXCLUDED_PERSON_PROPERTIES, + ], + isPropertyExcludedFromProject: [ + (s) => [s.excludedPropertyNames], + (excludedPropertyNames) => (propertyName: string) => + excludedPropertyNames.find((name) => name === propertyName) !== undefined, + ], + inversePropertyNames: [ + (s) => [s.aggregationGroupTypeIndex, s.personProperties, s.groupProperties], + (aggregationGroupTypeIndex, personProperties, groupProperties) => (excludedPersonProperties: string[]) => { + const targetProperties = + aggregationGroupTypeIndex !== undefined + ? groupProperties(aggregationGroupTypeIndex) + : personProperties + return targetProperties + .map((property) => property.name) + .filter((property) => !excludedPersonProperties.includes(property)) + }, + ], + allProperties: [ + (s) => [s.inversePropertyNames, s.excludedPropertyNames], + (inversePropertyNames, excludedPropertyNames): string[] => { + return inversePropertyNames(excludedPropertyNames || []) + }, + ], + }), + listeners(({ actions, values }) => ({ + excludePropertyFromProject: ({ propertyName }) => { + appendToCorrelationConfig('excluded_person_property_names', values.excludedPropertyNames, propertyName) + }, + setPropertyNames: async () => { + actions.loadPropertyCorrelations({}) + }, + })), +]) diff --git a/frontend/src/scenes/funnels/funnelUtils.test.ts b/frontend/src/scenes/funnels/funnelUtils.test.ts index 802f0db987ec4..16b92f99941aa 100644 --- a/frontend/src/scenes/funnels/funnelUtils.test.ts +++ b/frontend/src/scenes/funnels/funnelUtils.test.ts @@ -5,8 +5,16 @@ import { getMeanAndStandardDeviation, getClampedStepRangeFilter, getVisibilityKey, + parseDisplayNameForCorrelation, } from './funnelUtils' -import { FilterType, FunnelConversionWindowTimeUnit, FunnelStepRangeEntityFilter } from '~/types' +import { + FilterType, + FunnelConversionWindowTimeUnit, + FunnelCorrelation, + FunnelCorrelationResultsType, + FunnelCorrelationType, + FunnelStepRangeEntityFilter, +} from '~/types' import { dayjs } from 'lib/dayjs' describe('getMeanAndStandardDeviation', () => { @@ -220,3 +228,92 @@ describe('getClampedStepRangeFilter', () => { }) }) }) + +describe('parseEventAndProperty', () => { + const basicFunnelRecord: FunnelCorrelation = { + event: { event: '$pageview::bzzz', properties: {}, elements: [] }, + odds_ratio: 1, + correlation_type: FunnelCorrelationType.Success, + success_count: 1, + failure_count: 1, + success_people_url: '/some/people/url', + failure_people_url: '/some/people/url', + result_type: FunnelCorrelationResultsType.Events, + } + it('chooses the correct name based on Event type', async () => { + const result = parseDisplayNameForCorrelation(basicFunnelRecord) + expect(result).toEqual({ + first_value: '$pageview::bzzz', + second_value: undefined, + }) + }) + + it('chooses the correct name based on Property type', async () => { + const result = parseDisplayNameForCorrelation({ + ...basicFunnelRecord, + result_type: FunnelCorrelationResultsType.Properties, + }) + expect(result).toEqual({ + first_value: '$pageview', + second_value: 'bzzz', + }) + }) + + it('chooses the correct name based on EventWithProperty type', async () => { + const result = parseDisplayNameForCorrelation({ + ...basicFunnelRecord, + result_type: FunnelCorrelationResultsType.EventWithProperties, + event: { + event: '$pageview::library::1.2', + properties: { random: 'x' }, + elements: [], + }, + }) + expect(result).toEqual({ + first_value: 'library', + second_value: '1.2', + }) + }) + + it('handles autocapture events on EventWithProperty type', async () => { + const result = parseDisplayNameForCorrelation({ + ...basicFunnelRecord, + result_type: FunnelCorrelationResultsType.EventWithProperties, + event: { + event: '$autocapture::elements_chain::xyz_elements_a.link*', + properties: { $event_type: 'click' }, + elements: [ + { + tag_name: 'a', + href: '#', + attributes: { blah: 'https://example.com' }, + nth_child: 0, + nth_of_type: 0, + order: 0, + text: 'bazinga', + }, + ], + }, + }) + expect(result).toEqual({ + first_value: 'clicked link with text "bazinga"', + second_value: undefined, + }) + }) + + it('handles autocapture events without elements_chain on EventWithProperty type', async () => { + const result = parseDisplayNameForCorrelation({ + ...basicFunnelRecord, + result_type: FunnelCorrelationResultsType.EventWithProperties, + event: { + event: '$autocapture::library::1.2', + properties: { random: 'x' }, + elements: [], + }, + }) + expect(result).toEqual({ + first_value: 'library', + second_value: '1.2', + }) + }) +}) diff --git a/frontend/src/scenes/funnels/funnelUtils.ts b/frontend/src/scenes/funnels/funnelUtils.ts index 37045bb5d9f21..312bd2e50ba3a 100644 --- a/frontend/src/scenes/funnels/funnelUtils.ts +++ b/frontend/src/scenes/funnels/funnelUtils.ts @@ -1,4 +1,4 @@ -import { clamp } from 'lib/utils' +import { autoCaptureEventToDescription, clamp } from 'lib/utils' import { FunnelStepRangeEntityFilter, FunnelStep, @@ -11,11 +11,20 @@ import { Breakdown, FunnelStepWithConversionMetrics, FlattenedFunnelStepByBreakdown, + FunnelCorrelation, + AnyPropertyFilter, + PropertyOperator, + ElementPropertyFilter, + PropertyFilterType, + FunnelCorrelationResultsType, + CorrelationConfigType, } from '~/types' import { dayjs } from 'lib/dayjs' import { combineUrl } from 'kea-router' import { FunnelsQuery } from '~/queries/schema' import { FunnelLayout } from 'lib/constants' +import { elementsToAction } from 'scenes/events/createActionFromEvent' +import { teamLogic } from 'scenes/teamLogic' /** Chosen via heuristics by eyeballing some values * Assuming a normal distribution, then 90% of values are within 1.5 standard deviations of the mean @@ -514,3 +523,122 @@ export const transformLegacyHiddenLegendKeys = ( } return hiddenLegendKeys } + +export const parseBreakdownValue = ( + item: string +): { + breakdown: string + breakdown_value: string +} => { + const components = item.split('::') + if (components.length === 1) { + return { breakdown: components[0], breakdown_value: '' } + } else { + return { + breakdown: components[0], + breakdown_value: components[1], + } + } +} + +export const parseEventAndProperty = ( + event: FunnelCorrelation['event'] +): { + name: string + properties?: AnyPropertyFilter[] +} => { + const components = event.event.split('::') + /* + The `event` is either an event name, or event::property::property_value + */ + if (components.length === 1) { + return { name: components[0] } + } else if (components[0] === '$autocapture') { + // We use elementsToAction to generate the required property filters + const elementData = elementsToAction(event.elements) + return { + name: components[0], + properties: Object.entries(elementData) + .filter(([, propertyValue]) => !!propertyValue) + .map(([propertyKey, propertyValue]) => ({ + key: propertyKey as ElementPropertyFilter['key'], + operator: PropertyOperator.Exact, + type: PropertyFilterType.Element, + value: [propertyValue as string], + })), + } + } else { + return { + name: components[0], + properties: [ + { + key: components[1], + operator: PropertyOperator.Exact, + value: components[2], + type: PropertyFilterType.Event, + }, + ], + } + } +} + +export const parseDisplayNameForCorrelation = ( + record: FunnelCorrelation +): { first_value: string; second_value?: string } => { + let first_value = undefined + let second_value = undefined + const values = record.event.event.split('::') + + if (record.result_type === FunnelCorrelationResultsType.Events) { + first_value = record.event.event + return { first_value, second_value } + } else if (record.result_type === FunnelCorrelationResultsType.Properties) { + first_value = values[0] + second_value = values[1] + return { first_value, second_value } + } else if (values[0] === '$autocapture' && values[1] === 'elements_chain') { + // special case for autocapture elements_chain + first_value = autoCaptureEventToDescription({ + ...record.event, + event: '$autocapture', + }) as string + return { first_value, second_value } + } else { + // FunnelCorrelationResultsType.EventWithProperties + // Events here come in the form of event::property::value + return { first_value: values[1], second_value: values[2] } + } +} + +export const appendToCorrelationConfig = ( + configKey: keyof CorrelationConfigType, + currentValue: string[], + configValue: string +): void => { + // Helper to handle updating correlationConfig within the Team model. Only + // handles further appending to current values. + + // When we exclude a property, we want to update the config stored + // on the current Team/Project. + const oldCurrentTeam = teamLogic.values.currentTeam + + // If we haven't actually retrieved the current team, we can't + // update the config. + if (oldCurrentTeam === null || !currentValue) { + console.warn('Attempt to update correlation config without first retrieving existing config') + return + } + + const oldCorrelationConfig = oldCurrentTeam.correlation_config + + const configList = [...Array.from(new Set(currentValue.concat([configValue])))] + + const correlationConfig = { + ...oldCorrelationConfig, + [configKey]: configList, + } + + teamLogic.actions.updateCurrentTeam({ + correlation_config: correlationConfig, + }) +} diff --git a/frontend/src/scenes/insights/EmptyStates/timeout-state.json b/frontend/src/scenes/insights/EmptyStates/timeout-state.json deleted file mode 100644 index ad8526a8d7567..0000000000000 --- a/frontend/src/scenes/insights/EmptyStates/timeout-state.json +++ /dev/null @@ -1,1462 +0,0 @@ -{ - "kea": { - "router": { - "location": { - "pathname": "/insights", - "search": "?insight=TRENDS&interval=day&display=ActionsLineGraph&actions=%5B%5D&events=%5B%7B%22id%22%3A%22%24pageview%22%2C%22name%22%3A%22%24pageview%22%2C%22type%22%3A%22events%22%2C%22order%22%3A0%7D%5D&properties=%5B%5D&filter_test_accounts=false", - "hash": "#fromItem=36&edit=true" - }, - "searchParams": { - "insight": "TRENDS", - "interval": "day", - "display": "ActionsLineGraph", - "actions": [], - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "properties": [], - "filter_test_accounts": false - }, - "hashParams": { - "fromItem": 36, - "edit": true - }, - "lastMethod": null - } - }, - "scenes": { - "organizationLogic": { - "currentOrganization": { - "id": "017d15d2-d1c8-0000-f3be-db08551065e8", - "name": "Hogflix", - "slug": "hogflix", - "created_at": "2021-11-12T20:24:37.580946Z", - "updated_at": "2021-11-19T05:32:26.185018Z", - "membership_level": 15, - "plugins_access_level": 9, - "teams": [ - { - "id": 2, - "uuid": "017d15d7-c4df-0000-9a71-a7bc0ba5f8f8", - "organization": "017d15d2-d1c8-0000-f3be-db08551065e8", - "api_token": "phc_R5kpRUNBf7GVsQ5oBTVX0ApKnMnjE9MJU4n3jVluTKG", - "name": "Hogflix Demo App", - "completed_snippet_onboarding": true, - "ingested_event": true, - "is_demo": true, - "timezone": "UTC", - "access_control": false, - "effective_membership_level": 15 - } - ], - "available_features": [], - "is_member_join_email_enabled": true - }, - "currentOrganizationLoading": false, - "organizationBeingDeleted": null - }, - "teamLogic": { - "currentTeam": { - "id": 2, - "uuid": "017d15d7-c4df-0000-9a71-a7bc0ba5f8f8", - "organization": "017d15d2-d1c8-0000-f3be-db08551065e8", - "api_token": "phc_R5kpRUNBf7GVsQ5oBTVX0ApKnMnjE9MJU4n3jVluTKG", - "app_urls": [], - "name": "Hogflix Demo App", - "slack_incoming_webhook": null, - "created_at": "2021-11-12T20:30:01.952257Z", - "updated_at": "2021-11-19T05:18:21.129579Z", - "anonymize_ips": false, - "completed_snippet_onboarding": true, - "ingested_event": true, - "test_account_filters": [ - { - "key": "email", - "type": "person", - "value": "@posthog.com", - "operator": "not_icontains" - }, - { - "key": "$host", - "value": ["localhost:8000"], - "operator": "is_not" - } - ], - "path_cleaning_filters": [], - "is_demo": true, - "timezone": "UTC", - "data_attributes": ["data-attr"], - "correlation_config": { - "excluded_event_names": ["$autocapture"], - "excluded_person_property_names": [] - }, - "autocapture_opt_out": false, - "session_recording_opt_in": false, - "capture_console_log_opt_in": false, - "effective_membership_level": 15, - "access_control": false - }, - "currentTeamLoading": false, - "teamBeingDeleted": null - }, - "userLogic": { - "user": { - "date_joined": "2021-11-12T20:24:39.015588Z", - "uuid": "017d15d2-d767-0000-a77c-37e2376ebf2d", - "distinct_id": "FEBH70MxhdPGwZXqv74SJobJ7lrC9NtawPNzS3nXXtH", - "first_name": "Jane Doe", - "email": "test@posthog.com", - "email_opt_in": true, - "anonymize_data": false, - "toolbar_mode": "toolbar", - "has_password": true, - "is_staff": false, - "is_impersonated": false, - "team": { - "id": 2, - "uuid": "017d15d7-c4df-0000-9a71-a7bc0ba5f8f8", - "organization": "017d15d2-d1c8-0000-f3be-db08551065e8", - "api_token": "phc_R5kpRUNBf7GVsQ5oBTVX0ApKnMnjE9MJU4n3jVluTKG", - "name": "Hogflix Demo App", - "completed_snippet_onboarding": true, - "ingested_event": true, - "is_demo": true, - "timezone": "UTC", - "access_control": false, - "effective_membership_level": 15 - }, - "organization": { - "id": "017d15d2-d1c8-0000-f3be-db08551065e8", - "name": "Hogflix", - "slug": "hogflix", - "created_at": "2021-11-12T20:24:37.580946Z", - "updated_at": "2021-11-19T05:32:26.185018Z", - "membership_level": 15, - "plugins_access_level": 9, - "teams": [ - { - "id": 2, - "uuid": "017d15d7-c4df-0000-9a71-a7bc0ba5f8f8", - "organization": "017d15d2-d1c8-0000-f3be-db08551065e8", - "api_token": "phc_R5kpRUNBf7GVsQ5oBTVX0ApKnMnjE9MJU4n3jVluTKG", - "name": "Hogflix Demo App", - "completed_snippet_onboarding": true, - "ingested_event": true, - "is_demo": true, - "timezone": "UTC", - "access_control": false, - "effective_membership_level": 15 - } - ], - "available_features": [], - "is_member_join_email_enabled": true - }, - "organizations": [ - { - "id": "017d15d2-d1c8-0000-f3be-db08551065e8", - "name": "Hogflix", - "slug": "hogflix", - "membership_level": 15 - } - ], - "events_column_config": { - "active": "DEFAULT" - } - }, - "userLoading": false - }, - "PreflightCheck": { - "preflightLogic": { - "preflight": { - "django": true, - "redis": true, - "plugins": false, - "celery": false, - "db": true, - "clickhouse": false, - "kafka": false, - "initiated": true, - "cloud": false, - "realm": "hosted-clickhouse", - "available_social_auth_providers": { - "github": false, - "gitlab": false, - "google-oauth2": false, - "saml": false - }, - "can_create_org": true, - "email_service_available": false, - "slack_service": { - "available": false - }, - "ee_available": true, - "db_backend": "clickhouse", - "available_timezones": { - "US/Pacific": -8, - "UTC": 0 - }, - "opt_out_capture": false, - "posthog_version": "1.30.0", - "is_debug": true, - "is_event_property_usage_enabled": false, - "licensed_users_available": null, - "site_url": "http://localhost:8000", - "instance_preferences": { - "debug_queries": false, - "disable_paid_fs": false - } - }, - "preflightLoading": false, - "preflightMode": null - } - }, - "App": { - "showingDelayedSpinner": true, - "featureFlagsTimedOut": true - }, - "sceneLogic": { - "scene": "Insights", - "loadedScenes": { - "404": { - "name": "404", - "sceneParams": { - "params": {}, - "searchParams": {}, - "hashParams": {} - } - }, - "4xx": { - "name": "4xx", - "sceneParams": { - "params": {}, - "searchParams": {}, - "hashParams": {} - } - }, - "ProjectUnavailable": { - "name": "ProjectUnavailable", - "sceneParams": { - "params": {}, - "searchParams": {}, - "hashParams": {} - } - }, - "Insights": { - "name": "Insights", - "sceneParams": { - "params": {}, - "searchParams": { - "insight": "TRENDS", - "interval": "day", - "display": "ActionsLineGraph", - "actions": [], - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "properties": [], - "filter_test_accounts": false - }, - "hashParams": { - "fromItem": 36, - "edit": true - } - }, - "lastTouch": 1637339771614 - } - }, - "loadingScene": null, - "upgradeModalFeatureNameAndCaption": null, - "lastReloadAt": null - }, - "instance": { - "SystemStatus": { - "systemStatusLogic": { - "systemStatus": { - "overview": [], - "internal_metrics": {} - }, - "systemStatusLoading": false, - "queries": null, - "queriesLoading": false, - "analyzeQueryResult": null, - "analyzeQueryResultLoading": false, - "tab": "overview", - "error": null, - "openSections": ["0"], - "analyzeModalOpen": false, - "analyzeQuery": "" - } - }, - "Licenses": { - "licenseLogic": { - "licenses": [ - { - "id": 1, - "key": "123", - "plan": "enterprise", - "valid_until": "2022-11-19T05:33:55.999116Z", - "created_at": "2021-11-19T05:33:56.018147Z" - } - ], - "licensesLoading": false, - "error": null - } - } - }, - "organization": { - "Settings": { - "bulkInviteLogic": { - "invitedTeamMembers": [], - "invitedTeamMembersLoading": false, - "invites": [ - { - "target_email": "", - "first_name": "", - "isValid": true - } - ] - } - } - }, - "feature-flags": { - "featureFlagLogic": { - "featureFlag": null, - "featureFlagLoading": false, - "featureFlagId": null - } - }, - "persons": { - "personsLogic": { - "persons": { - "next": null, - "previous": null, - "results": [] - }, - "personsLoading": false, - "person": null, - "personLoading": false, - "cohorts": null, - "cohortsLoading": false, - "deletedPerson": false, - "deletedPersonLoading": false, - "listFilters": {}, - "hasNewKeys": false, - "activeTab": null, - "splitMergeModalShown": false - } - }, - "billing": { - "billingLogic": { - "billing": null, - "billingLoading": false, - "plans": [], - "plansLoading": false, - "billingSubscription": null, - "billingSubscriptionLoading": false - } - }, - "insights": { - "InsightDateFilter": { - "insightDateFilterLogic": { - "dates": {}, - "highlightDateChange": false, - "initialLoad": false - } - }, - "insightLogic": { - "scene": { - "insight": { - "id": 36, - "short_id": "olZN59LI", - "name": "Stinky Flamingo", - "filters": { - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "actions": [], - "display": "ActionsLineGraph", - "insight": "TRENDS", - "interval": "day", - "properties": [], - "filter_test_accounts": false - }, - "filters_hash": "cache_f9f6c2699aa06287232c19cdc5038876", - "order": null, - "deleted": false, - "dashboard": null, - "dive_dashboard": null, - "layouts": {}, - "color": null, - "last_refresh": null, - "refreshing": false, - "result": [ - { - "action": { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "custom_name": null, - "math": null, - "math_property": null, - "math_group_type_index": null, - "properties": [] - }, - "label": "$pageview", - "count": 145, - "data": [30, 115, 0, 0, 0, 0, 0, 0], - "labels": [ - "12-Nov-2021", - "13-Nov-2021", - "14-Nov-2021", - "15-Nov-2021", - "16-Nov-2021", - "17-Nov-2021", - "18-Nov-2021", - "19-Nov-2021" - ], - "days": [ - "2021-11-12", - "2021-11-13", - "2021-11-14", - "2021-11-15", - "2021-11-16", - "2021-11-17", - "2021-11-18", - "2021-11-19" - ], - "persons_urls": [ - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-12", - "date_to": "2021-11-12" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-12&date_to=2021-11-12&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-13", - "date_to": "2021-11-13" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-13&date_to=2021-11-13&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-14", - "date_to": "2021-11-14" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-14&date_to=2021-11-14&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-15", - "date_to": "2021-11-15" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-15&date_to=2021-11-15&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-16", - "date_to": "2021-11-16" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-16&date_to=2021-11-16&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-17", - "date_to": "2021-11-17" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-17&date_to=2021-11-17&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-18", - "date_to": "2021-11-18" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-18&date_to=2021-11-18&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - }, - { - "filter": { - "entity_id": "$pageview", - "entity_type": "events", - "date_from": "2021-11-19", - "date_to": "2021-11-19" - }, - "url": "api/projects/2/persons/trends/?date_from=2021-11-19&date_to=2021-11-19&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%5B%5D%7D%5D&insight=TRENDS&interval=day&entity_id=%24pageview&entity_type=events" - } - ], - "filter": { - "date_from": "2021-11-12T00:00:00+00:00", - "date_to": "2021-11-19T05:22:40.089274+00:00", - "display": "ActionsLineGraph", - "events": [ - { - "id": "$pageview", - "type": "events", - "order": 0, - "name": "$pageview", - "custom_name": null, - "math": null, - "math_property": null, - "math_group_type_index": null, - "properties": [] - } - ], - "insight": "TRENDS", - "interval": "day" - } - } - ], - "created_at": "2021-11-19T16:34:27.112078Z", - "description": "", - "updated_at": "2021-11-19T16:34:27.112159Z", - "tags": [], - "favorited": false, - "saved": false, - "created_by": { - "id": 1, - "uuid": "017d15d2-d767-0000-a77c-37e2376ebf2d", - "distinct_id": "FEBH70MxhdPGwZXqv74SJobJ7lrC9NtawPNzS3nXXtH", - "first_name": "Jane Doe", - "email": "test@posthog.com" - }, - "is_sample": false - }, - "insightLoading": false, - "filters": { - "insight": "TRENDS", - "interval": "day", - "display": "ActionsLineGraph", - "actions": [], - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "properties": [], - "filter_test_accounts": false - }, - "savedInsight": { - "filters": { - "insight": "TRENDS", - "events": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "actions": [], - "display": "ActionsLineGraph", - "interval": "day", - "properties": [], - "filter_test_accounts": false - } - }, - "showTimeoutMessage": true, - "maybeShowTimeoutMessage": true, - "showErrorMessage": false, - "maybeShowErrorMessage": false, - "timeout": 21, - "lastRefresh": null, - "isLoading": true, - "isFirstLoad": false, - "controlsCollapsed": false, - "queryStartTimes": { - "5e058985-ce5d-4a3b-8d24-bd9ab267f55a": 1637339771334 - }, - "lastInsightModeSource": null, - "insightMode": "edit", - "tagLoading": false - } - }, - "ActionFilter": { - "entityFilterLogic": { - "trends_TRENDS": { - "selectedFilter": null, - "localFilters": [ - { - "id": "$pageview", - "name": "$pageview", - "type": "events", - "order": 0 - } - ], - "entityFilterVisible": [], - "modalVisible": false - } - }, - "renameModalLogic": { - "trends_TRENDS": { - "name": "" - } - } - }, - "InsightsTable": { - "insightsTableLogic": { - "calcColumnState": "total" - } - } - }, - "trends": { - "personsModalLogic": { - "people": null, - "peopleLoading": false, - "searchTerm": "", - "cohortModalVisible": false, - "firstLoadedPeople": null, - "loadingMorePeople": false, - "showingPeople": false, - "peopleParams": null - }, - "trendsLogic": { - "scene": { - "toggledLifecycles": ["new", "resurrecting", "returning", "dormant"], - "breakdownValuesLoading": false - } - } - }, - "funnels": { - "funnelLogic": { - "scene": { - "people": [], - "peopleLoading": false, - "correlations": { - "events": [] - }, - "correlationsLoading": false, - "propertyCorrelations": { - "events": [] - }, - "propertyCorrelationsLoading": false, - "eventWithPropertyCorrelations": {}, - "eventWithPropertyCorrelationsLoading": false, - "stepReference": "total", - "isGroupingOutliers": true, - "error": null, - "correlationTypes": ["success", "failure"], - "propertyCorrelationTypes": ["success", "failure"], - "skewWarningHidden": false, - "correlationFeedbackHidden": false, - "correlationDetailedFeedbackVisible": false, - "correlationFeedbackRating": 0, - "correlationDetailedFeedback": "", - "propertyNames": [], - "nestedTableExpandedKeys": [], - "shouldReportCorrelationViewed": true, - "shouldReportPropertyCorrelationViewed": true - } - } - } - }, - "lib": { - "logic": { - "featureFlagLogic": { - "receivedFeatureFlags": true - } - }, - "components": { - "PersonalAPIKeys": { - "personalAPIKeysLogic": { - "keys": [], - "keysLoading": false - } - }, - "CommandPalette": { - "commandPaletteLogic": { - "isPaletteShown": false, - "keyboardResultIndex": 0, - "hoverResultIndex": null, - "input": "", - "activeFlow": null, - "rawCommandRegistrations": { - "go-to": { - "key": "go-to", - "scope": "global", - "prefixes": ["open", "visit"], - "resolver": [ - { - "icon": {}, - "display": "Go to Dashboards" - }, - { - "icon": {}, - "display": "Go to Insights" - }, - { - "icon": {}, - "display": "Go to Trends" - }, - { - "icon": {}, - "display": "Go to Funnels" - }, - { - "icon": {}, - "display": "Go to Retention" - }, - { - "icon": {}, - "display": "Go to Paths" - }, - { - "icon": {}, - "display": "Go to Events" - }, - { - "icon": {}, - "display": "Go to Actions" - }, - { - "icon": {}, - "display": "Go to Persons", - "synonyms": ["people"] - }, - { - "icon": {}, - "display": "Go to Cohorts" - }, - { - "icon": {}, - "display": "Go to Feature Flags", - "synonyms": ["feature flags", "a/b tests"] - }, - { - "icon": {}, - "display": "Go to Annotations" - }, - { - "icon": {}, - "display": "Go to Team members", - "synonyms": ["organization", "members", "invites", "teammates"] - }, - { - "icon": {}, - "display": "Go to Project settings" - }, - { - "icon": {}, - "display": "Go to My settings", - "synonyms": ["account"] - }, - { - "icon": {}, - "display": "Go to Plugins", - "synonyms": ["integrations"] - }, - { - "icon": {}, - "display": "Go to System status page", - "synonyms": [ - "redis", - "celery", - "django", - "postgres", - "backend", - "service", - "online" - ] - }, - { - "icon": {}, - "display": "Create action" - }, - { - "icon": {}, - "display": "Log out" - } - ] - }, - "open-urls": { - "key": "open-urls", - "scope": "global", - "prefixes": ["open", "visit"] - }, - "debug-clickhouse-queries": { - "key": "debug-clickhouse-queries", - "scope": "global", - "resolver": { - "icon": {}, - "display": "Debug queries (ClickHouse)" - } - }, - "calculator": { - "key": "calculator", - "scope": "global" - }, - "create-personal-api-key": { - "key": "create-personal-api-key", - "scope": "global", - "resolver": { - "icon": {}, - "display": "Create Personal API Key" - } - }, - "create-dashboard": { - "key": "create-dashboard", - "scope": "global", - "resolver": { - "icon": {}, - "display": "Create Dashboard" - } - }, - "share-feedback": { - "key": "share-feedback", - "scope": "global", - "resolver": { - "icon": {}, - "display": "Share Feedback", - "synonyms": ["send opinion", "ask question", "message posthog", "github issue"] - } - }, - "insight-graph": { - "key": "insight-graph", - "resolver": [ - { - "icon": {}, - "display": "Toggle \"Compare Previous\" on Graph" - }, - { - "icon": {}, - "display": "Set Time Range to Custom" - }, - { - "icon": {}, - "display": "Set Time Range to Today" - }, - { - "icon": {}, - "display": "Set Time Range to Yesterday" - }, - { - "icon": {}, - "display": "Set Time Range to Last 24 hours" - }, - { - "icon": {}, - "display": "Set Time Range to Last 48 hours" - }, - { - "icon": {}, - "display": "Set Time Range to Last 7 days" - }, - { - "icon": {}, - "display": "Set Time Range to Last 14 days" - }, - { - "icon": {}, - "display": "Set Time Range to Last 30 days" - }, - { - "icon": {}, - "display": "Set Time Range to Last 90 days" - }, - { - "icon": {}, - "display": "Set Time Range to This month" - }, - { - "icon": {}, - "display": "Set Time Range to Previous month" - }, - { - "icon": {}, - "display": "Set Time Range to Year to date" - }, - { - "icon": {}, - "display": "Set Time Range to All time" - } - ], - "scope": "insights" - } - } - } - }, - "HelpButton": { - "HelpButton": { - "isHelpVisible": false - } - }, - "BackTo": { - "backTo": null - }, - "CompareFilter": { - "compareFilterLogic": { - "compare": false, - "disabled": false - } - }, - "SaveToDashboard": { - "saveToDashboardModalLogic": { - "36": { - "_dashboardId": null - } - } - }, - "PropertyFilters": { - "propertyFilterLogic": { - "trends-filters": { - "filters": [{}] - } - } - }, - "Annotations": { - "annotationsLogic": { - "36_annotations": { - "annotations": [], - "annotationsLoading": false, - "annotationsToCreate": [], - "diffType": "day" - } - } - }, - "VisibilitySensor": { - "visibilitySensorLogic": { - "correlation-scene": { - "innerHeight": 581, - "visible": false - }, - "correlation-scene-properties": { - "innerHeight": 581, - "visible": false - } - } - }, - "ChartFilter": { - "chartFilterLogic": { - "chartFilter": "ActionsLineGraph" - } - }, - "IntervalFilter": { - "intervalFilterLogic": { - "interval": "day", - "dateFrom": null - } - } - }, - "experimental": { - "NPSPrompt": { - "step": 0, - "hidden": true, - "payload": null - } - } - }, - "models": { - "actionsModel": { - "actions": [ - { - "id": 9, - "name": "new action", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "9", - "event": "correlation interaction", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-18T03:52:55.629603Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-18T03:52:55.629105Z", - "created_by": { - "id": 1, - "uuid": "017d15d2-d767-0000-a77c-37e2376ebf2d", - "distinct_id": "FEBH70MxhdPGwZXqv74SJobJ7lrC9NtawPNzS3nXXtH", - "first_name": "Jane Doe", - "email": "test@posthog.com" - }, - "team_id": 2 - }, - { - "id": 8, - "name": "Entered Free Trial", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "8", - "event": "entered_free_trial", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:03.711843Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.494238Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 7, - "name": "Purchase", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "7", - "event": "purchase", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:03.693882Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.465794Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 6, - "name": "Watched Movie", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "6", - "event": "watched_movie", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:03.273208Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.260641Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 5, - "name": "Rated App", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "5", - "event": "rated_app", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:03.255899Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.284401Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 4, - "name": "Installed App", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "4", - "event": "installed_app", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": null, - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:03.238262Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.313787Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 3, - "name": "Hogflix paid", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "3", - "event": "$autocapture", - "tag_name": null, - "text": null, - "href": null, - "selector": "button", - "url": "http://hogflix.com/2", - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:02.751479Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.431438Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 2, - "name": "Hogflix signed up", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "2", - "event": "$autocapture", - "tag_name": null, - "text": null, - "href": null, - "selector": "button", - "url": "http://hogflix.com/1", - "name": null, - "url_matching": "contains", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:02.724308Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.391735Z", - "created_by": null, - "team_id": 2 - }, - { - "id": 1, - "name": "Hogflix homepage view", - "post_to_slack": false, - "slack_message_format": "", - "steps": [ - { - "id": "1", - "event": "$pageview", - "tag_name": null, - "text": null, - "href": null, - "selector": null, - "url": "http://hogflix.com", - "name": null, - "url_matching": "exact", - "properties": [] - } - ], - "created_at": "2021-11-12T20:30:02.691259Z", - "deleted": false, - "is_calculating": false, - "last_calculated_at": "2021-11-12T20:30:05.350260Z", - "created_by": null, - "team_id": 2 - } - ], - "actionsLoading": false - }, - "annotationsModel": { - "globalAnnotations": [ - { - "id": 1, - "content": "123123", - "date_marker": "2021-11-17T06:00:00Z", - "creation_type": "USR", - "dashboard_item": null, - "created_by": { - "id": 1, - "uuid": "017d15d2-d767-0000-a77c-37e2376ebf2d", - "distinct_id": "FEBH70MxhdPGwZXqv74SJobJ7lrC9NtawPNzS3nXXtH", - "first_name": "Jane Doe", - "email": "test@posthog.com" - }, - "created_at": "2021-11-18T03:56:02.252300Z", - "updated_at": "2021-11-18T03:56:02.252964Z", - "deleted": false, - "scope": "organization" - } - ], - "globalAnnotationsLoading": false - }, - "cohortsModel": { - "cohorts": [ - { - "id": 1, - "name": "test", - "description": "", - "groups": [ - { - "days": null, - "count": null, - "label": null, - "end_date": null, - "event_id": null, - "action_id": null, - "properties": [ - { - "key": "is_demo", - "type": "person", - "value": ["true"], - "operator": "exact" - } - ], - "start_date": null, - "count_operator": null - } - ], - "deleted": false, - "is_calculating": false, - "created_by": { - "id": 1, - "uuid": "017d15d2-d767-0000-a77c-37e2376ebf2d", - "distinct_id": "FEBH70MxhdPGwZXqv74SJobJ7lrC9NtawPNzS3nXXtH", - "first_name": "Jane Doe", - "email": "test@posthog.com" - }, - "created_at": "2021-11-18T04:01:39.878752Z", - "last_calculation": "2021-11-18T17:50:19.809394Z", - "errors_calculating": 0, - "count": 0, - "is_static": false - } - ], - "cohortsLoading": false, - "pollTimeout": null - }, - "dashboardsModel": { - "rawDashboards": { - "2": { - "id": 2, - "name": "Web Analytics", - "description": "", - "pinned": false, - "items": null, - "created_at": "2021-11-12T20:30:02.798795Z", - "created_by": null, - "is_shared": false, - "deleted": false, - "creation_mode": "default", - "filters": {}, - "tags": [] - }, - "3": { - "id": 3, - "name": "App Analytics", - "description": "", - "pinned": false, - "items": null, - "created_at": "2021-11-12T20:30:03.292080Z", - "created_by": null, - "is_shared": false, - "deleted": false, - "creation_mode": "default", - "filters": {}, - "tags": [] - }, - "4": { - "id": 4, - "name": "Sales & Revenue", - "description": "", - "pinned": false, - "items": null, - "created_at": "2021-11-12T20:30:03.739938Z", - "created_by": null, - "is_shared": false, - "deleted": false, - "creation_mode": "default", - "filters": {}, - "tags": [] - } - }, - "rawDashboardsLoading": false, - "dashboard": null, - "dashboardLoading": false, - "redirect": true, - "lastDashboardId": 3, - "diveSourceId": null - }, - "personPropertiesModel": { - "personProperties": [ - { - "name": "is_demo", - "count": 320 - } - ], - "personPropertiesLoading": false - }, - "propertyDefinitionsModel": { - "propertyStorage": { - "count": 159, - "results": [ - { - "id": "017d15d7-d483-0000-f0ed-0a56c7f24580", - "name": "$active_feature_flags", - "description": null, - "tags": null, - "is_numerical": false, - "updated_at": null, - "updated_by": null, - "query_usage_30_day": null - } - ], - "next": null - }, - "propertyStorageLoading": false - }, - "groupsModel": { - "groupTypes": [ - { - "group_type": "project", - "group_type_index": 0 - }, - { - "group_type": "organization", - "group_type_index": 1 - }, - { - "group_type": "instance", - "group_type_index": 2 - } - ], - "groupTypesLoading": false - }, - "groupPropertiesModel": { - "allGroupProperties": { - "0": [ - { - "name": "id", - "count": 1 - }, - { - "name": "ingested_event", - "count": 1 - }, - { - "name": "is_demo", - "count": 1 - }, - { - "name": "name", - "count": 1 - }, - { - "name": "timezone", - "count": 1 - }, - { - "name": "uuid", - "count": 1 - } - ], - "1": [ - { - "name": "available_features", - "count": 1 - }, - { - "name": "created_at", - "count": 1 - }, - { - "name": "id", - "count": 1 - }, - { - "name": "name", - "count": 1 - }, - { - "name": "slug", - "count": 1 - } - ] - }, - "allGroupPropertiesLoading": false - } - }, - "layout": { - "navigation": { - "navigationLogic": { - "latestVersion": "1.30.0", - "latestVersionLoading": false, - "isSideBarShownRaw": true, - "isAnnouncementShown": true, - "isSitePopoverOpen": false, - "isInviteModalShown": false, - "isCreateOrganizationModalShown": false, - "isCreateProjectModalShown": false, - "isToolbarModalShown": false, - "isProjectSwitcherShown": false, - "hotkeyNavigationEngaged": false - } - } - } -} diff --git a/frontend/src/scenes/insights/insightDataLogic.ts b/frontend/src/scenes/insights/insightDataLogic.ts index 7cd25c735cfb3..62d6659fcf0b3 100644 --- a/frontend/src/scenes/insights/insightDataLogic.ts +++ b/frontend/src/scenes/insights/insightDataLogic.ts @@ -53,7 +53,7 @@ export const insightDataLogic = kea([ ['setInsight', 'loadInsightSuccess', 'loadResultsSuccess', 'saveInsight as insightLogicSaveInsight'], // TODO: need to pass empty query here, as otherwise dataNodeLogic will throw dataNodeLogic({ key: insightVizDataNodeKey(props), query: {} as DataNode }), - ['loadData'], + ['loadData', 'loadDataSuccess'], ], })), diff --git a/frontend/src/scenes/insights/views/Funnels/CorrelationActionsCell.tsx b/frontend/src/scenes/insights/views/Funnels/CorrelationActionsCell.tsx index 653cc01b25e91..bd4b4dcaba5ba 100644 --- a/frontend/src/scenes/insights/views/Funnels/CorrelationActionsCell.tsx +++ b/frontend/src/scenes/insights/views/Funnels/CorrelationActionsCell.tsx @@ -2,7 +2,9 @@ import { useState } from 'react' import { useActions, useValues } from 'kea' import { insightLogic } from 'scenes/insights/insightLogic' -import { funnelLogic } from 'scenes/funnels/funnelLogic' +import { funnelCorrelationLogic } from 'scenes/funnels/funnelCorrelationLogic' +import { funnelCorrelationDetailsLogic } from 'scenes/funnels/funnelCorrelationDetailsLogic' +import { funnelPropertyCorrelationLogic } from 'scenes/funnels/funnelPropertyCorrelationLogic' import { FunnelCorrelation, FunnelCorrelationResultsType } from '~/types' import { Popover } from 'lib/lemon-ui/Popover/Popover' @@ -11,9 +13,11 @@ import { IconEllipsis } from 'lib/lemon-ui/icons' export const EventCorrelationActionsCell = ({ record }: { record: FunnelCorrelation }): JSX.Element => { const { insightProps } = useValues(insightLogic) - const logic = funnelLogic(insightProps) - const { excludeEventPropertyFromProject, excludeEventFromProject, setFunnelCorrelationDetails } = useActions(logic) - const { isEventPropertyExcluded, isEventExcluded } = useValues(logic) + const { isEventExcluded, isEventPropertyExcluded } = useValues(funnelCorrelationLogic(insightProps)) + const { excludeEventFromProject, excludeEventPropertyFromProject } = useActions( + funnelCorrelationLogic(insightProps) + ) + const { setFunnelCorrelationDetails } = useActions(funnelCorrelationDetailsLogic(insightProps)) const components = record.event.event.split('::') const buttons: LemonButtonProps[] = [ @@ -45,9 +49,9 @@ export const EventCorrelationActionsCell = ({ record }: { record: FunnelCorrelat export const PropertyCorrelationActionsCell = ({ record }: { record: FunnelCorrelation }): JSX.Element => { const { insightProps } = useValues(insightLogic) - const logic = funnelLogic(insightProps) - const { excludePropertyFromProject, setFunnelCorrelationDetails } = useActions(logic) - const { isPropertyExcludedFromProject } = useValues(logic) + const { isPropertyExcludedFromProject } = useValues(funnelPropertyCorrelationLogic(insightProps)) + const { excludePropertyFromProject } = useActions(funnelPropertyCorrelationLogic(insightProps)) + const { setFunnelCorrelationDetails } = useActions(funnelCorrelationDetailsLogic(insightProps)) const propertyName = (record.event.event || '').split('::')[0] const buttons: LemonButtonProps[] = [ diff --git a/frontend/src/scenes/insights/views/Funnels/CorrelationMatrix.tsx b/frontend/src/scenes/insights/views/Funnels/CorrelationMatrix.tsx index de5d239a1e53a..e985f54dcb015 100644 --- a/frontend/src/scenes/insights/views/Funnels/CorrelationMatrix.tsx +++ b/frontend/src/scenes/insights/views/Funnels/CorrelationMatrix.tsx @@ -18,13 +18,18 @@ import { } from 'lib/lemon-ui/icons' import { AlertMessage } from 'lib/lemon-ui/AlertMessage' import clsx from 'clsx' +import { parseDisplayNameForCorrelation } from 'scenes/funnels/funnelUtils' +import { funnelCorrelationLogic } from 'scenes/funnels/funnelCorrelationLogic' +import { funnelCorrelationDetailsLogic } from 'scenes/funnels/funnelCorrelationDetailsLogic' export function CorrelationMatrix(): JSX.Element { const { insightProps } = useValues(insightLogic) - const logic = funnelLogic(insightProps) - const { correlationsLoading, funnelCorrelationDetails, parseDisplayNameForCorrelation, correlationMatrixAndScore } = - useValues(logic) - const { setFunnelCorrelationDetails, openCorrelationPersonsModal } = useActions(logic) + const { openCorrelationPersonsModal } = useActions(funnelLogic(insightProps)) + const { correlationsLoading } = useValues(funnelCorrelationLogic(insightProps)) + const { funnelCorrelationDetails, correlationMatrixAndScore } = useValues( + funnelCorrelationDetailsLogic(insightProps) + ) + const { setFunnelCorrelationDetails } = useActions(funnelCorrelationDetailsLogic(insightProps)) const actor = funnelCorrelationDetails?.result_type === FunnelCorrelationResultsType.Events ? 'event' : 'property' const action = diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx index 43f6bca8a9d82..f74a93b3d7312 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelation.tsx @@ -1,24 +1,32 @@ -import { useValues } from 'kea' -import { funnelLogic } from 'scenes/funnels/funnelLogic' -import './FunnelCorrelation.scss' -import { AvailableFeature } from '~/types' +import { useMountedLogic, useValues } from 'kea' + import { insightLogic } from 'scenes/insights/insightLogic' -import { FunnelCorrelationTable } from './FunnelCorrelationTable' -import { FunnelPropertyCorrelationTable } from './FunnelPropertyCorrelationTable' -import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' +import { funnelLogic } from 'scenes/funnels/funnelLogic' import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' +import { funnelCorrelationUsageLogic } from 'scenes/funnels/funnelCorrelationUsageLogic' + +import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { FunnelCorrelationSkewWarning, FunnelCorrelationSkewWarningDataExploration, } from './FunnelCorrelationSkewWarning' +import { FunnelCorrelationTable, FunnelCorrelationTableDataExploration } from './FunnelCorrelationTable' import { FunnelCorrelationFeedbackForm } from './FunnelCorrelationFeedbackForm' +import { + FunnelPropertyCorrelationTable, + FunnelPropertyCorrelationTableDataExploration, +} from './FunnelPropertyCorrelationTable' +import { AvailableFeature } from '~/types' + +import './FunnelCorrelation.scss' export const FunnelCorrelation = (): JSX.Element | null => { - const { insightProps, isUsingDataExploration } = useValues(insightLogic) + const { insightProps, isUsingDataExploration: dx } = useValues(insightLogic) const { steps: legacySteps } = useValues(funnelLogic(insightProps)) const { steps } = useValues(funnelDataLogic(insightProps)) + useMountedLogic(funnelCorrelationUsageLogic(insightProps)) - if (isUsingDataExploration ? steps.length <= 1 : legacySteps.length <= 1) { + if (dx ? steps.length <= 1 : legacySteps.length <= 1) { return null } @@ -27,14 +35,10 @@ export const FunnelCorrelation = (): JSX.Element | null => {

Correlation analysis

- {isUsingDataExploration ? ( - - ) : ( - - )} - {!isUsingDataExploration && } + {dx ? : } + {dx ? : } - {!isUsingDataExploration && } + {dx ? : }
diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationTable.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationTable.tsx index 5a5a4bbd3ae32..4ae2f55d44c8d 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationTable.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelCorrelationTable.tsx @@ -5,14 +5,18 @@ import { useActions, useValues } from 'kea' import { RiseOutlined, FallOutlined, InfoCircleOutlined } from '@ant-design/icons' import { IconSelectEvents, IconUnfoldLess, IconUnfoldMore } from 'lib/lemon-ui/icons' import { funnelLogic } from 'scenes/funnels/funnelLogic' -import { FunnelCorrelation, FunnelCorrelationResultsType, FunnelCorrelationType } from '~/types' +import { + FunnelCorrelation, + FunnelCorrelationResultsType, + FunnelCorrelationType, + FunnelStepWithNestedBreakdown, +} from '~/types' import Checkbox from 'antd/lib/checkbox/Checkbox' import { insightLogic } from 'scenes/insights/insightLogic' import { ValueInspectorButton } from 'scenes/funnels/ValueInspectorButton' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import './FunnelCorrelationTable.scss' import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { VisibilitySensor } from 'lib/components/VisibilitySensor/VisibilitySensor' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { CorrelationMatrix } from './CorrelationMatrix' @@ -20,43 +24,91 @@ import { capitalizeFirstLetter } from 'lib/utils' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { FunnelCorrelationTableEmptyState } from './FunnelCorrelationTableEmptyState' import { EventCorrelationActionsCell } from './CorrelationActionsCell' +import { funnelCorrelationUsageLogic } from 'scenes/funnels/funnelCorrelationUsageLogic' + +import { funnelCorrelationLogic } from 'scenes/funnels/funnelCorrelationLogic' +import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' +import { Noun } from '~/models/groupsModel' +import { parseDisplayNameForCorrelation } from 'scenes/funnels/funnelUtils' + +export function FunnelCorrelationTableDataExploration(): JSX.Element | null { + const { insightProps } = useValues(insightLogic) + const { steps, querySource, aggregationTargetLabel } = useValues(funnelDataLogic(insightProps)) + const { loadedEventCorrelationsTableOnce } = useValues(funnelCorrelationLogic(insightProps)) + const { loadEventCorrelations } = useActions(funnelCorrelationLogic(insightProps)) + + // Load correlations only if this component is mounted, and then reload if the query changes + useEffect(() => { + // We only automatically refresh results when the query changes after the user has manually asked for the first results to be loaded + if (loadedEventCorrelationsTableOnce) { + loadEventCorrelations({}) + } + }, [querySource]) + + return ( + + ) +} export function FunnelCorrelationTable(): JSX.Element | null { const { insightProps } = useValues(insightLogic) - const logic = funnelLogic(insightProps) + const { steps, filters, aggregationTargetLabel } = useValues(funnelLogic(insightProps)) + const { loadedEventCorrelationsTableOnce } = useValues(funnelCorrelationLogic(insightProps)) + const { loadEventCorrelations } = useActions(funnelCorrelationLogic(insightProps)) + + // Load correlations only if this component is mounted, and then reload if filters change + useEffect(() => { + // We only automatically refresh results when filters change after the user has manually asked for the first results to be loaded + if (loadedEventCorrelationsTableOnce) { + loadEventCorrelations({}) + } + }, [filters]) + + return ( + + ) +} + +type FunnelCorrelationTableComponentProps = { + steps: FunnelStepWithNestedBreakdown[] + aggregation_group_type_index?: number | undefined + aggregationTargetLabel: Noun +} + +export function FunnelCorrelationTableComponent({ + steps, + aggregation_group_type_index, + aggregationTargetLabel, +}: FunnelCorrelationTableComponentProps): JSX.Element | null { + const { insightProps } = useValues(insightLogic) + const { openCorrelationPersonsModal } = useActions(funnelLogic(insightProps)) const { - steps, - correlationValues, correlationTypes, - eventHasPropertyCorrelations, - eventWithPropertyCorrelationsValues, - parseDisplayNameForCorrelation, correlationsLoading, + correlationValues, + loadedEventCorrelationsTableOnce, + eventHasPropertyCorrelations, eventWithPropertyCorrelationsLoading, + eventWithPropertyCorrelationsValues, nestedTableExpandedKeys, - correlationPropKey, - filters, - aggregationTargetLabel, - loadedEventCorrelationsTableOnce, - } = useValues(logic) + } = useValues(funnelCorrelationLogic(insightProps)) const { setCorrelationTypes, + loadEventCorrelations, loadEventWithPropertyCorrelations, addNestedTableExpandedKey, removeNestedTableExpandedKey, - openCorrelationPersonsModal, - loadEventCorrelations, - } = useActions(logic) - - const { reportCorrelationInteraction } = useActions(eventUsageLogic) - - // Load correlations only if this component is mounted, and then reload if filters change - useEffect(() => { - // We only automatically refresh results when filters change after the user has manually asked for the first results to be loaded - if (loadedEventCorrelationsTableOnce) { - loadEventCorrelations({}) - } - }, [filters]) + } = useActions(funnelCorrelationLogic(insightProps)) + const { correlationPropKey } = useValues(funnelCorrelationUsageLogic(insightProps)) + const { reportCorrelationInteraction } = useActions(funnelCorrelationUsageLogic(insightProps)) const onClickCorrelationType = (correlationType: FunnelCorrelationType): void => { if (correlationTypes) { @@ -96,7 +148,7 @@ export function FunnelCorrelationTable(): JSX.Element | null {
{capitalizeFirstLetter(aggregationTargetLabel.plural)}{' '} - {filters.aggregation_group_type_index != undefined ? 'that' : 'who'} converted were{' '} + {aggregation_group_type_index != undefined ? 'that' : 'who'} converted were{' '} {get_friendly_numeric_value(record.odds_ratio)}x {is_success ? 'more' : 'less'} likely @@ -258,7 +310,7 @@ export function FunnelCorrelationTable(): JSX.Element | null { expandable={{ expandedRowRender: (record) => renderNestedTable(record.event.event), expandedRowKeys: nestedTableExpandedKeys, - rowExpandable: () => filters.aggregation_group_type_index === undefined, + rowExpandable: () => aggregation_group_type_index === undefined, expandIcon: ({ expanded, onExpand, record, expandable }) => { if (!expandable) { return null @@ -309,7 +361,7 @@ export function FunnelCorrelationTable(): JSX.Element | null { Completed @@ -329,8 +381,8 @@ export function FunnelCorrelationTable(): JSX.Element | null { title={ <> {capitalizeFirstLetter(aggregationTargetLabel.plural)}{' '} - {filters.aggregation_group_type_index != undefined ? 'that' : 'who'}{' '} - performed the event and did not complete the entire funnel. + {aggregation_group_type_index != undefined ? 'that' : 'who'} performed + the event and did not complete the entire funnel. } > diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelPropertyCorrelationTable.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelPropertyCorrelationTable.tsx index 1450fb972d186..4edb29c884b77 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelPropertyCorrelationTable.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelPropertyCorrelationTable.tsx @@ -4,7 +4,12 @@ import Column from 'antd/lib/table/Column' import { useActions, useValues } from 'kea' import { RiseOutlined, FallOutlined, InfoCircleOutlined } from '@ant-design/icons' import { funnelLogic } from 'scenes/funnels/funnelLogic' -import { FunnelCorrelation, FunnelCorrelationResultsType, FunnelCorrelationType } from '~/types' +import { + FunnelCorrelation, + FunnelCorrelationResultsType, + FunnelCorrelationType, + FunnelStepWithNestedBreakdown, +} from '~/types' import Checkbox from 'antd/lib/checkbox/Checkbox' import { insightLogic } from 'scenes/insights/insightLogic' import { ValueInspectorButton } from 'scenes/funnels/ValueInspectorButton' @@ -12,36 +17,53 @@ import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { PropertyNamesSelect } from 'lib/components/PropertyNamesSelect/PropertyNamesSelect' import { IconSelectProperties } from 'lib/lemon-ui/icons' import './FunnelCorrelationTable.scss' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { VisibilitySensor } from 'lib/components/VisibilitySensor/VisibilitySensor' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { capitalizeFirstLetter } from 'lib/utils' import { FunnelCorrelationTableEmptyState } from './FunnelCorrelationTableEmptyState' import { PropertyCorrelationActionsCell } from './CorrelationActionsCell' +import { funnelCorrelationUsageLogic } from 'scenes/funnels/funnelCorrelationUsageLogic' +import { parseDisplayNameForCorrelation } from 'scenes/funnels/funnelUtils' +import { funnelPropertyCorrelationLogic } from 'scenes/funnels/funnelPropertyCorrelationLogic' +import { Noun } from '~/models/groupsModel' +import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' -export function FunnelPropertyCorrelationTable(): JSX.Element | null { +export function FunnelPropertyCorrelationTableDataExploration(): JSX.Element | null { const { insightProps } = useValues(insightLogic) - const logic = funnelLogic(insightProps) - const { - steps, - propertyCorrelationValues, - propertyCorrelationTypes, - excludedPropertyNames, - parseDisplayNameForCorrelation, - propertyCorrelationsLoading, - inversePropertyNames, - propertyNames, - correlationPropKey, - allProperties, - filters, - aggregationTargetLabel, - loadedPropertyCorrelationsTableOnce, - } = useValues(logic) + const { steps, querySource, aggregationTargetLabel } = useValues(funnelDataLogic(insightProps)) + const { loadedPropertyCorrelationsTableOnce, propertyNames, allProperties } = useValues( + funnelPropertyCorrelationLogic(insightProps) + ) + const { loadPropertyCorrelations, setPropertyNames } = useActions(funnelPropertyCorrelationLogic(insightProps)) - const { setPropertyCorrelationTypes, setPropertyNames, openCorrelationPersonsModal, loadPropertyCorrelations } = - useActions(logic) + // Load correlations only if this component is mounted, and then reload if the query change + useEffect(() => { + // We only automatically refresh results when the query changes after the user has manually asked for the first results to be loaded + if (loadedPropertyCorrelationsTableOnce) { + if (propertyNames.length === 0) { + setPropertyNames(allProperties) + } - const { reportCorrelationInteraction } = useActions(eventUsageLogic) + loadPropertyCorrelations({}) + } + }, [querySource]) + + return ( + + ) +} + +export function FunnelPropertyCorrelationTable(): JSX.Element | null { + const { insightProps } = useValues(insightLogic) + const { steps, filters, aggregationTargetLabel } = useValues(funnelLogic(insightProps)) + const { loadedPropertyCorrelationsTableOnce, propertyNames, allProperties } = useValues( + funnelPropertyCorrelationLogic(insightProps) + ) + const { loadPropertyCorrelations, setPropertyNames } = useActions(funnelPropertyCorrelationLogic(insightProps)) // Load correlations only if this component is mounted, and then reload if filters change useEffect(() => { @@ -55,6 +77,44 @@ export function FunnelPropertyCorrelationTable(): JSX.Element | null { } }, [filters]) + return ( + + ) +} + +type FunnelPropertyCorrelationTableComponentProps = { + steps: FunnelStepWithNestedBreakdown[] + aggregation_group_type_index?: number | undefined + aggregationTargetLabel: Noun +} + +export function FunnelPropertyCorrelationTableComponent({ + steps, + aggregation_group_type_index, + aggregationTargetLabel, +}: FunnelPropertyCorrelationTableComponentProps): JSX.Element | null { + const { insightProps } = useValues(insightLogic) + const { openCorrelationPersonsModal } = useActions(funnelLogic(insightProps)) + const { + propertyCorrelationValues, + propertyCorrelationTypes, + excludedPropertyNames, + propertyCorrelationsLoading, + inversePropertyNames, + propertyNames, + allProperties, + loadedPropertyCorrelationsTableOnce, + } = useValues(funnelPropertyCorrelationLogic(insightProps)) + const { setPropertyCorrelationTypes, setPropertyNames, loadPropertyCorrelations } = useActions( + funnelPropertyCorrelationLogic(insightProps) + ) + const { correlationPropKey } = useValues(funnelCorrelationUsageLogic(insightProps)) + const { reportCorrelationInteraction } = useActions(funnelCorrelationUsageLogic(insightProps)) + const onClickCorrelationType = (correlationType: FunnelCorrelationType): void => { if (propertyCorrelationTypes) { if (propertyCorrelationTypes.includes(correlationType)) { @@ -122,7 +182,7 @@ export function FunnelPropertyCorrelationTable(): JSX.Element | null {
{capitalizeFirstLetter(aggregationTargetLabel.plural)}{' '} - {filters.aggregation_group_type_index != undefined ? 'that' : 'who'} converted were{' '} + {aggregation_group_type_index != undefined ? 'that' : 'who'} converted were{' '} {get_friendly_numeric_value(record.odds_ratio)}x {is_success ? 'more' : 'less'} likely @@ -245,7 +305,7 @@ export function FunnelPropertyCorrelationTable(): JSX.Element | null { Completed @@ -265,8 +325,8 @@ export function FunnelPropertyCorrelationTable(): JSX.Element | null { title={ <> {capitalizeFirstLetter(aggregationTargetLabel.plural)}{' '} - {filters.aggregation_group_type_index != undefined ? 'that' : 'who'}{' '} - have this property and did not complete the entire funnel. + {aggregation_group_type_index != undefined ? 'that' : 'who'} have this + property and did not complete the entire funnel. } >