diff --git a/frontend/__snapshots__/components-cards-insight-card--insight-card.png b/frontend/__snapshots__/components-cards-insight-card--insight-card.png index 5866bafa1e8c6..e76dec328d63d 100644 Binary files a/frontend/__snapshots__/components-cards-insight-card--insight-card.png and b/frontend/__snapshots__/components-cards-insight-card--insight-card.png differ diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index 16217ce2e6c7a..7a776b879fc37 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -241,7 +241,7 @@ const nodeKindToFilterProperty: Record = [NodeKind.LifecycleQuery]: 'lifecycleFilter', } -export function filterPropertyForQuery(node: InsightQueryNode): InsightFilterProperty { +export function filterKeyForQuery(node: InsightQueryNode): InsightFilterProperty { return nodeKindToFilterProperty[node.kind] } diff --git a/frontend/src/scenes/insights/InsightNav/insightNavLogic.test.ts b/frontend/src/scenes/insights/InsightNav/insightNavLogic.test.ts index 5a30e1208ebb3..ca4602559150a 100644 --- a/frontend/src/scenes/insights/InsightNav/insightNavLogic.test.ts +++ b/frontend/src/scenes/insights/InsightNav/insightNavLogic.test.ts @@ -1,11 +1,11 @@ import { insightLogic } from 'scenes/insights/insightLogic' -import { InsightLogicProps, InsightShortId, InsightType } from '~/types' +import { FunnelVizType, InsightLogicProps, InsightShortId, InsightType, StepOrderValue } from '~/types' import { insightNavLogic } from 'scenes/insights/InsightNav/insightNavLogic' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' import { useMocks } from '~/mocks/jest' -import { NodeKind } from '~/queries/schema' +import { InsightVizNode, Node, NodeKind } from '~/queries/schema' import { insightDataLogic } from '../insightDataLogic' import { nodeKindToDefaultQuery } from '~/queries/nodes/InsightQuery/defaults' @@ -62,7 +62,18 @@ describe('insightNavLogic', () => { }).toMatchValues({ query: { kind: NodeKind.InsightVizNode, - source: { ...nodeKindToDefaultQuery[NodeKind.FunnelsQuery], filterTestAccounts: true }, + source: { + ...nodeKindToDefaultQuery[NodeKind.FunnelsQuery], + filterTestAccounts: true, + series: [ + { + event: '$pageview', + kind: 'EventsNode', + math: 'total', + name: '$pageview', + }, + ], + }, }, }) }) @@ -109,5 +120,172 @@ describe('insightNavLogic', () => { }) }) }) + + describe('query cache', () => { + const trendsQuery: InsightVizNode = { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$pageview', + event: '$pageview', + }, + ], + trendsFilter: { show_values_on_series: true }, + }, + } + const funnelsQuery: InsightVizNode = { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.FunnelsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$pageview', + event: '$pageview', + }, + { + kind: NodeKind.EventsNode, + name: '$pageleave', + event: '$pageleave', + }, + ], + funnelsFilter: { + funnel_order_type: StepOrderValue.STRICT, + funnel_viz_type: FunnelVizType.Steps, + }, + }, + } + const retentionQuery: InsightVizNode = { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.RetentionQuery, + retentionFilter: { + returning_entity: { + id: 'returning', + name: 'returning', + type: 'events', + }, + target_entity: { + id: 'target', + name: 'target', + type: 'events', + }, + }, + }, + } + + it('is initialized on mount', async () => { + await expectLogic(logic).toMatchValues({ + queryPropertyCache: { + ...nodeKindToDefaultQuery[NodeKind.TrendsQuery], + commonFilter: {}, + filterTestAccounts: true, + }, + }) + }) + + it('stores query updates', async () => { + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(trendsQuery) + }).toMatchValues({ + queryPropertyCache: expect.objectContaining({ + series: [ + { + event: '$pageview', + kind: 'EventsNode', + name: '$pageview', + }, + ], + }), + }) + + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(funnelsQuery) + }).toMatchValues({ + queryPropertyCache: expect.objectContaining({ + series: [ + { + event: '$pageview', + kind: 'EventsNode', + name: '$pageview', + }, + { + event: '$pageleave', + kind: 'EventsNode', + name: '$pageleave', + }, + ], + }), + }) + }) + + it('stores insight filter in commonFilter', async () => { + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(trendsQuery) + }).toMatchValues({ + queryPropertyCache: expect.objectContaining({ + commonFilter: { show_values_on_series: true }, + }), + }) + + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(funnelsQuery) + }).toMatchValues({ + queryPropertyCache: expect.objectContaining({ + commonFilter: { + show_values_on_series: true, + funnel_order_type: 'strict', + funnel_viz_type: 'steps', + }, + }), + }) + }) + + it('stores series from retention entities', async () => { + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(retentionQuery) + }).toMatchValues({ + queryPropertyCache: expect.objectContaining({ + series: [ + { + event: 'target', + kind: 'EventsNode', + math: 'total', + name: 'target', + }, + { + event: 'returning', + kind: 'EventsNode', + math: 'total', + name: 'returning', + }, + ], + }), + }) + }) + + it('updates query when navigating', async () => { + await expectLogic(logic, () => { + builtInsightDataLogic.actions.setQuery(trendsQuery) + }) + + await expectLogic(logic, () => { + logic.actions.setActiveView(InsightType.LIFECYCLE) + }).toDispatchActions([ + logic.actionCreators.setQuery({ + kind: 'InsightVizNode', + source: { + kind: 'LifecycleQuery', + series: [{ kind: 'EventsNode', name: '$pageview', event: '$pageview' }], + filterTestAccounts: true, + lifecycleFilter: { show_values_on_series: true }, + }, + } as Node), + ]) + }) + }) }) }) diff --git a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx index 7d27d086ba192..b07540564eade 100644 --- a/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx +++ b/frontend/src/scenes/insights/InsightNav/insightNavLogic.tsx @@ -1,17 +1,44 @@ -import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' -import { InsightLogicProps, InsightType } from '~/types' +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { InsightLogicProps, InsightType, ActionFilter } from '~/types' import type { insightNavLogicType } from './insightNavLogicType' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { insightLogic } from 'scenes/insights/insightLogic' -import { NodeKind } from '~/queries/schema' +import { + InsightVizNode, + InsightQueryNode, + NodeKind, + TrendsQuery, + FunnelsQuery, + RetentionQuery, + PathsQuery, + StickinessQuery, + LifecycleQuery, + TrendsFilter, + FunnelsFilter, + RetentionFilter, + PathsFilter, + StickinessFilter, + LifecycleFilter, + EventsNode, + ActionsNode, +} from '~/queries/schema' import { insightDataLogic, queryFromKind } from 'scenes/insights/insightDataLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { insightMap } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' -import { containsHogQLQuery, isInsightVizNode } from '~/queries/utils' +import { + isInsightVizNode, + isRetentionQuery, + isInsightQueryWithBreakdown, + isInsightQueryWithSeries, + filterKeyForQuery, + containsHogQLQuery, +} from '~/queries/utils' import { examples, TotalEventsTable } from '~/queries/examples' import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' import { filterTestAccountsDefaultsLogic } from 'scenes/project/Settings/filterTestAccountDefaultsLogic' +import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { getDisplay, getShowPercentStackView, getShowValueOnSeries } from '~/queries/nodes/InsightViz/utils' export interface Tab { label: string | JSX.Element @@ -19,6 +46,24 @@ export interface Tab { dataAttr: string } +export interface CommonInsightFilter + extends Partial, + Partial, + Partial, + Partial, + Partial, + Partial {} + +export interface QueryPropertyCache + extends Omit, 'kind'>, + Omit, 'kind'>, + Omit, 'kind'>, + Omit, 'kind'>, + Omit, 'kind'>, + Omit, 'kind'> { + commonFilter: CommonInsightFilter +} + export const insightNavLogic = kea([ props({} as InsightLogicProps), key(keyForInsightLogicProps('new')), @@ -38,8 +83,18 @@ export const insightNavLogic = kea([ })), actions({ setActiveView: (view: InsightType) => ({ view }), + updateQueryPropertyCache: (cache: QueryPropertyCache) => ({ cache }), }), reducers({ + queryPropertyCache: [ + null as QueryPropertyCache | null, + { + updateQueryPropertyCache: (state, { cache }) => ({ + ...state, + ...cache, + }), + }, + ], userSelectedView: { setActiveView: (_, { view }) => view, }, @@ -153,20 +208,128 @@ export const insightNavLogic = kea([ actions.setQuery(examples.HogQLTable) } } else { + let query: InsightVizNode + if (view === InsightType.TRENDS) { - actions.setQuery(queryFromKind(NodeKind.TrendsQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.TrendsQuery, values.filterTestAccountsDefault) } else if (view === InsightType.FUNNELS) { - actions.setQuery(queryFromKind(NodeKind.FunnelsQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.FunnelsQuery, values.filterTestAccountsDefault) } else if (view === InsightType.RETENTION) { - actions.setQuery(queryFromKind(NodeKind.RetentionQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.RetentionQuery, values.filterTestAccountsDefault) } else if (view === InsightType.PATHS) { - actions.setQuery(queryFromKind(NodeKind.PathsQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.PathsQuery, values.filterTestAccountsDefault) } else if (view === InsightType.STICKINESS) { - actions.setQuery(queryFromKind(NodeKind.StickinessQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.StickinessQuery, values.filterTestAccountsDefault) } else if (view === InsightType.LIFECYCLE) { - actions.setQuery(queryFromKind(NodeKind.LifecycleQuery, values.filterTestAccountsDefault)) + query = queryFromKind(NodeKind.LifecycleQuery, values.filterTestAccountsDefault) + } else { + throw new Error('encountered unexpected type for view') } + + actions.setQuery({ + ...query, + source: values.queryPropertyCache + ? mergeCachedProperties(query.source, values.queryPropertyCache) + : query.source, + } as InsightVizNode) + } + }, + setQuery: ({ query }) => { + if (isInsightVizNode(query)) { + actions.updateQueryPropertyCache(cachePropertiesFromQuery(query.source, values.queryPropertyCache)) } }, })), + afterMount(({ values, actions }) => { + if (values.query && isInsightVizNode(values.query)) { + actions.updateQueryPropertyCache(cachePropertiesFromQuery(values.query.source, values.queryPropertyCache)) + } + }), ]) + +const cachePropertiesFromQuery = (query: InsightQueryNode, cache: QueryPropertyCache | null): QueryPropertyCache => { + const newCache = JSON.parse(JSON.stringify(query)) as QueryPropertyCache + + // set series (first two entries) from retention target and returning entity + if (isRetentionQuery(query)) { + const { target_entity, returning_entity } = query.retentionFilter || {} + const series = actionsAndEventsToSeries({ + events: [ + ...(target_entity?.type === 'events' ? [target_entity as ActionFilter] : []), + ...(returning_entity?.type === 'events' ? [returning_entity as ActionFilter] : []), + ], + actions: [ + ...(target_entity?.type === 'actions' ? [target_entity as ActionFilter] : []), + ...(returning_entity?.type === 'actions' ? [returning_entity as ActionFilter] : []), + ], + }) + if (series.length > 0) { + newCache.series = [...series, ...(cache?.series ? cache.series.slice(series.length) : [])] + } + } + + // store the insight specific filter in commonFilter + const filterKey = filterKeyForQuery(query) + newCache.commonFilter = { ...cache?.commonFilter, ...query[filterKey] } + + return newCache +} + +const mergeCachedProperties = (query: InsightQueryNode, cache: QueryPropertyCache): InsightQueryNode => { + const mergedQuery = { + ...query, + ...(cache.dateRange ? { dateRange: cache.dateRange } : {}), + ...(cache.properties ? { properties: cache.properties } : {}), + ...(cache.samplingFactor ? { samplingFactor: cache.samplingFactor } : {}), + } + + // series + if (isInsightQueryWithSeries(mergedQuery)) { + if (cache.series) { + mergedQuery.series = cache.series + } else if (cache.retentionFilter?.target_entity || cache.retentionFilter?.returning_entity) { + mergedQuery.series = [ + ...(cache.retentionFilter.target_entity + ? [cache.retentionFilter.target_entity as EventsNode | ActionsNode] + : []), + ...(cache.retentionFilter.returning_entity + ? [cache.retentionFilter.returning_entity as EventsNode | ActionsNode] + : []), + ] + } + } else if (isRetentionQuery(mergedQuery) && cache.series) { + mergedQuery.retentionFilter = { + ...mergedQuery.retentionFilter, + ...(cache.series.length > 0 ? { target_entity: cache.series[0] } : {}), + ...(cache.series.length > 1 ? { returning_entity: cache.series[1] } : {}), + } + } + + // interval + if (isInsightQueryWithSeries(mergedQuery) && cache.interval) { + mergedQuery.interval = cache.interval + } + + // breakdown + if (isInsightQueryWithBreakdown(mergedQuery) && cache.breakdown) { + mergedQuery.breakdown = cache.breakdown + } + + // insight specific filter + const filterKey = filterKeyForQuery(mergedQuery) + if (cache[filterKey] || cache.commonFilter) { + const node = { kind: mergedQuery.kind, [filterKey]: cache.commonFilter } as unknown as InsightQueryNode + mergedQuery[filterKey] = { + ...query[filterKey], + ...cache[filterKey], + // TODO: fix an issue where switching between trends and funnels with the option enabled would + // result in an error before uncommenting + // ...(getCompare(node) ? { compare: getCompare(node) } : {}), + ...(getShowValueOnSeries(node) ? { show_values_on_series: getShowValueOnSeries(node) } : {}), + ...(getShowPercentStackView(node) ? { show_percent_stack_view: getShowPercentStackView(node) } : {}), + ...(getDisplay(node) ? { display: getDisplay(node) } : {}), + } + } + + return mergedQuery +} diff --git a/frontend/src/scenes/insights/insightVizDataLogic.ts b/frontend/src/scenes/insights/insightVizDataLogic.ts index df725a1896e78..a15328138ccef 100644 --- a/frontend/src/scenes/insights/insightVizDataLogic.ts +++ b/frontend/src/scenes/insights/insightVizDataLogic.ts @@ -17,7 +17,7 @@ import { insightLogic } from './insightLogic' import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' import { filterForQuery, - filterPropertyForQuery, + filterKeyForQuery, isFunnelsQuery, isInsightQueryNode, isInsightVizNode, @@ -202,7 +202,7 @@ export const insightVizDataLogic = kea([ actions.updateQuerySource({ breakdown: { ...values.breakdown, ...breakdown } } as Partial) }, updateInsightFilter: ({ insightFilter }) => { - const filterProperty = filterPropertyForQuery(values.localQuerySource) + const filterProperty = filterKeyForQuery(values.localQuerySource) actions.updateQuerySource({ [filterProperty]: { ...values.localQuerySource[filterProperty], ...insightFilter }, }) diff --git a/frontend/src/scenes/insights/utils/compareInsightQuery.ts b/frontend/src/scenes/insights/utils/compareInsightQuery.ts index 9f206865e1a3f..95039cabecf93 100644 --- a/frontend/src/scenes/insights/utils/compareInsightQuery.ts +++ b/frontend/src/scenes/insights/utils/compareInsightQuery.ts @@ -3,7 +3,7 @@ import { InsightQueryNode } from '~/queries/schema' import { objectCleanWithEmpty, objectsEqual } from 'lib/utils' import { filterForQuery, - filterPropertyForQuery, + filterKeyForQuery, isEventsNode, isInsightQueryWithDisplay, isInsightQueryWithSeries, @@ -45,7 +45,7 @@ const cleanInsightQuery = (query: InsightQueryNode, ignoreVisualizationOnlyChang if (ignoreVisualizationOnlyChanges) { const insightFilter = filterForQuery(cleanedQuery) - const insightFilterKey = filterPropertyForQuery(cleanedQuery) + const insightFilterKey = filterKeyForQuery(cleanedQuery) cleanedQuery[insightFilterKey] = { ...insightFilter, show_legend: undefined,