From 736aa4cb8122cb3369e12daa280c3ba02c9a28c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Oberm=C3=BCller?= Date: Fri, 3 Feb 2023 14:18:32 +0100 Subject: [PATCH] feat(data-exploration): implement retention insight (fixes #14015) (#14019) --- frontend/src/lib/dayjs.ts | 4 +- .../InsightQuery/utils/filtersToQueryNode.ts | 4 +- .../InsightQuery/utils/queryNodeToFilter.ts | 9 +- .../nodes/InsightViz/EditorFilters.tsx | 14 +- .../queries/nodes/InsightViz/TrendsSeries.tsx | 12 +- frontend/src/queries/schema.json | 49 +--- frontend/src/queries/schema.ts | 8 - frontend/src/queries/utils.ts | 29 +-- .../src/scenes/funnels/funnelDataLogic.ts | 2 +- .../EditorFilters/FunnelsQuerySteps.tsx | 4 +- .../EditorFilters/RetentionSummary.tsx | 192 +++++++++------ .../scenes/insights/InsightDisplayConfig.tsx | 12 +- .../scenes/insights/RetentionDatePicker.tsx | 76 ++++-- .../insights/filters/AggregationSelect.tsx | 25 +- .../FunnelExclusionsFilter.tsx | 2 +- .../insights/filters/ReferencePicker.tsx | 52 ---- .../filters/RetentionReferencePicker.tsx | 70 ++++++ .../src/scenes/insights/insightDataLogic.ts | 71 ++++-- frontend/src/scenes/insights/insightLogic.ts | 14 +- frontend/src/scenes/insights/sharedUtils.ts | 2 +- frontend/src/scenes/insights/utils.test.ts | 52 ++++ frontend/src/scenes/insights/utils.tsx | 17 +- .../src/scenes/insights/utils/cleanFilters.ts | 3 +- .../scenes/retention/RetentionContainer.tsx | 2 + .../scenes/retention/RetentionLineGraph.tsx | 127 ++++------ .../src/scenes/retention/RetentionModal.tsx | 93 ++++--- .../src/scenes/retention/RetentionTable.tsx | 124 ++++------ .../retention/abstractRetentionLogic.ts | 115 +++++++++ frontend/src/scenes/retention/constants.ts | 36 +++ .../retention/retentionLineGraphLogic.test.ts | 103 ++++++++ .../retention/retentionLineGraphLogic.ts | 108 ++++++++ .../scenes/retention/retentionLogic.test.ts | 104 ++++++++ .../src/scenes/retention/retentionLogic.ts | 43 ++++ .../scenes/retention/retentionModalLogic.ts | 46 ++++ .../scenes/retention/retentionPeopleLogic.ts | 67 +++++ .../retention/retentionTableLogic.test.ts | 64 ----- .../scenes/retention/retentionTableLogic.ts | 232 +----------------- .../scenes/saved-insights/SavedInsights.tsx | 7 - frontend/src/types.ts | 11 +- 39 files changed, 1196 insertions(+), 809 deletions(-) delete mode 100644 frontend/src/scenes/insights/filters/ReferencePicker.tsx create mode 100644 frontend/src/scenes/insights/filters/RetentionReferencePicker.tsx create mode 100644 frontend/src/scenes/retention/abstractRetentionLogic.ts create mode 100644 frontend/src/scenes/retention/constants.ts create mode 100644 frontend/src/scenes/retention/retentionLineGraphLogic.test.ts create mode 100644 frontend/src/scenes/retention/retentionLineGraphLogic.ts create mode 100644 frontend/src/scenes/retention/retentionLogic.test.ts create mode 100644 frontend/src/scenes/retention/retentionLogic.ts create mode 100644 frontend/src/scenes/retention/retentionModalLogic.ts create mode 100644 frontend/src/scenes/retention/retentionPeopleLogic.ts diff --git a/frontend/src/lib/dayjs.ts b/frontend/src/lib/dayjs.ts index 22885b86881a83..dc99ea5b499db3 100644 --- a/frontend/src/lib/dayjs.ts +++ b/frontend/src/lib/dayjs.ts @@ -64,7 +64,7 @@ export function dayjsLocalToTimezone( // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Dayjs extends DayjsOriginal {} -export type UnitTypeShort = 'd' | 'M' | 'y' | 'h' | 'm' | 's' | 'ms' +export type UnitTypeShort = 'd' | 'D' | 'M' | 'y' | 'h' | 'm' | 's' | 'ms' export type UnitTypeLong = 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year' | 'date' @@ -82,4 +82,4 @@ export type UnitType = UnitTypeLong | UnitTypeLongPlural | UnitTypeShort export type OpUnitType = UnitType | 'week' | 'weeks' | 'w' export type QUnitType = UnitType | 'quarter' | 'quarters' | 'Q' -export type ManipulateType = Omit +export type ManipulateType = Exclude diff --git a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts index 9da4c018394e91..58af62b8b4028a 100644 --- a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts +++ b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts @@ -1,10 +1,10 @@ -import { InsightQueryNode, EventsNode, ActionsNode, NodeKind, SupportedNodeKind } from '~/queries/schema' +import { InsightQueryNode, EventsNode, ActionsNode, NodeKind, InsightNodeKind } from '~/queries/schema' import { FilterType, InsightType, ActionFilter } from '~/types' import { isLifecycleQuery, isStickinessQuery } from '~/queries/utils' import { isLifecycleFilter, isStickinessFilter } from 'scenes/insights/sharedUtils' import { objectClean } from 'lib/utils' -const reverseInsightMap: Record = { +const reverseInsightMap: Record = { [InsightType.TRENDS]: NodeKind.TrendsQuery, [InsightType.FUNNELS]: NodeKind.FunnelsQuery, [InsightType.RETENTION]: NodeKind.RetentionQuery, diff --git a/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts b/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts index 3ef8b3e62f5e39..3c03b39b3a6ace 100644 --- a/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts +++ b/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts @@ -2,10 +2,10 @@ import { InsightQueryNode, EventsNode, ActionsNode, - SupportedNodeKind, NodeKind, BreakdownFilter, NewEntityNode, + InsightNodeKind, } from '~/queries/schema' import { FilterType, InsightType, ActionFilter, EntityTypes, TrendsFilterType, StickinessFilterType } from '~/types' import { @@ -15,7 +15,6 @@ import { isRetentionQuery, isPathsQuery, isStickinessQuery, - isUnimplementedQuery, isLifecycleQuery, isActionsNode, } from '~/queries/utils' @@ -67,7 +66,7 @@ export const seriesToActionsAndEvents = ( return { actions, events, new_entity } } -const insightMap: Record = { +const insightMap: Record = { [NodeKind.TrendsQuery]: InsightType.TRENDS, [NodeKind.FunnelsQuery]: InsightType.FUNNELS, [NodeKind.RetentionQuery]: InsightType.RETENTION, @@ -76,7 +75,7 @@ const insightMap: Record = { [NodeKind.LifecycleQuery]: InsightType.LIFECYCLE, } -const filterMap: Record = { +const filterMap: Record = { [NodeKind.TrendsQuery]: 'trendsFilter', [NodeKind.FunnelsQuery]: 'funnelsFilter', [NodeKind.RetentionQuery]: 'retentionFilter', @@ -96,7 +95,7 @@ export const queryNodeToFilter = (query: InsightQueryNode): Partial entity_type: 'events', }) - if (!isRetentionQuery(query) && !isPathsQuery(query) && !isUnimplementedQuery(query)) { + if (!isRetentionQuery(query) && !isPathsQuery(query)) { const { actions, events, new_entity } = seriesToActionsAndEvents(query.series) if (actions.length > 0) { filters.actions = actions diff --git a/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx b/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx index 7486442e408a20..8068db47f4f575 100644 --- a/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx +++ b/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx @@ -12,8 +12,7 @@ import { } from '~/types' import { insightLogic } from 'scenes/insights/insightLogic' import { userLogic } from 'scenes/userLogic' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { FEATURE_FLAGS, NON_BREAKDOWN_DISPLAY_TYPES } from 'lib/constants' +import { NON_BREAKDOWN_DISPLAY_TYPES } from 'lib/constants' import { isTrendsQuery, isFunnelsQuery, @@ -45,6 +44,7 @@ import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { FunnelsQueryStepsDataExploration } from 'scenes/insights/EditorFilters/FunnelsQuerySteps' import { AttributionDataExploration } from 'scenes/insights/EditorFilters/AttributionFilter' import { FunnelsAdvancedDataExploration } from 'scenes/insights/EditorFilters/FunnelsAdvanced' +import { RetentionSummaryDataExploration } from 'scenes/insights/EditorFilters/RetentionSummary' export interface EditorFiltersProps { query: InsightQueryNode setQuery: (node: InsightQueryNode) => void @@ -56,8 +56,6 @@ export function EditorFilters({ query, setQuery }: EditorFiltersProps): JSX.Elem const { insight, insightProps, filterPropertiesCount } = useValues(insightLogic) - const { featureFlags } = useValues(featureFlagLogic) - const isTrends = isTrendsQuery(query) const isFunnels = isFunnelsQuery(query) const isRetention = isRetentionQuery(query) @@ -70,9 +68,6 @@ export function EditorFilters({ query, setQuery }: EditorFiltersProps): JSX.Elem const hasBreakdown = (isTrends && !NON_BREAKDOWN_DISPLAY_TYPES.includes(display || ChartDisplayType.ActionsLineGraph)) || - (isRetention && - featureFlags[FEATURE_FLAGS.RETENTION_BREAKDOWN] && - display !== ChartDisplayType.ActionsLineGraph) || (isFunnels && query.funnelsFilter?.funnel_viz_type === FunnelVizType.Steps) const hasPathsAdvanced = availableFeatures.includes(AvailableFeature.PATHS_ADVANCED) const hasAttribution = isFunnels && query.funnelsFilter?.funnel_viz_type === FunnelVizType.Steps @@ -83,6 +78,11 @@ export function EditorFilters({ query, setQuery }: EditorFiltersProps): JSX.Elem { title: 'General', editorFilters: filterFalsy([ + isRetention && { + key: 'retention-summary', + label: 'Retention Summary', + component: RetentionSummaryDataExploration, + }, ...(isPaths ? filterFalsy([ { diff --git a/frontend/src/queries/nodes/InsightViz/TrendsSeries.tsx b/frontend/src/queries/nodes/InsightViz/TrendsSeries.tsx index 992472643903b7..6a42ab75b62bcf 100644 --- a/frontend/src/queries/nodes/InsightViz/TrendsSeries.tsx +++ b/frontend/src/queries/nodes/InsightViz/TrendsSeries.tsx @@ -8,13 +8,7 @@ import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFil import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { SINGLE_SERIES_DISPLAY_TYPES } from 'lib/constants' import { TrendsQuery, FunnelsQuery, LifecycleQuery, StickinessQuery } from '~/queries/schema' -import { - isLifecycleQuery, - isStickinessQuery, - isTrendsQuery, - isInsightQueryWithDisplay, - isUnimplementedQuery, -} from '~/queries/utils' +import { isLifecycleQuery, isStickinessQuery, isTrendsQuery, isInsightQueryWithDisplay } from '~/queries/utils' import { queryNodeToFilter } from '../InsightQuery/utils/queryNodeToFilter' import { actionsAndEventsToSeries } from '../InsightQuery/utils/filtersToQueryNode' @@ -45,10 +39,6 @@ export function TrendsSeries({ insightProps }: TrendsSeriesProps): JSX.Element | TaxonomicFilterGroupType.HogQLExpression, ] - if (isUnimplementedQuery(querySource)) { - return null - } - const display = getDisplay(querySource) const filters = queryNodeToFilter(querySource) diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 578b4d2360ea0d..1ac0c8e20d62e2 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -773,7 +773,7 @@ "type": "array" }, "period": { - "type": "string" + "$ref": "#/definitions/RetentionPeriod" }, "properties": { "anyOf": [ @@ -1962,9 +1962,6 @@ }, { "$ref": "#/definitions/LifecycleQuery" - }, - { - "$ref": "#/definitions/UnimplementedQuery" } ] }, @@ -2452,7 +2449,7 @@ "additionalProperties": false, "properties": { "period": { - "type": "string" + "$ref": "#/definitions/RetentionPeriod" }, "retention_reference": { "enum": ["total", "previous"], @@ -2473,6 +2470,10 @@ }, "type": "object" }, + "RetentionPeriod": { + "enum": ["Hour", "Day", "Week", "Month"], + "type": "string" + }, "RetentionQuery": { "additionalProperties": false, "properties": { @@ -2775,44 +2776,6 @@ }, "required": ["kind", "series"], "type": "object" - }, - "UnimplementedQuery": { - "additionalProperties": false, - "properties": { - "aggregation_group_type_index": { - "description": "Groups aggregation", - "type": "number" - }, - "dateRange": { - "$ref": "#/definitions/DateRange", - "description": "Date range for the query" - }, - "filterTestAccounts": { - "description": "Exclude internal and test users by applying the respective filters", - "type": "boolean" - }, - "kind": { - "const": "UnimplementedQuery", - "description": "Used for insights that haven't been converted to the new query format yet", - "type": "string" - }, - "properties": { - "anyOf": [ - { - "items": { - "$ref": "#/definitions/AnyPropertyFilter" - }, - "type": "array" - }, - { - "$ref": "#/definitions/PropertyGroupFilter" - } - ], - "description": "Property filters for all series" - } - }, - "required": ["kind"], - "type": "object" } } } diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index a78f328db82f32..5bfebc91f3c6c8 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -60,9 +60,6 @@ export enum NodeKind { /** Performance */ RecentPerformancePageViewNode = 'RecentPerformancePageViewNode', - - /** Used for insights that haven't been converted to the new query format yet */ - UnimplementedQuery = 'UnimplementedQuery', } export type AnyDataNode = EventsNode | EventsQuery | ActionsNode | PersonsNode @@ -322,9 +319,6 @@ export interface LifecycleQuery extends InsightsQueryBase { /** Properties specific to the lifecycle insight */ lifecycleFilter?: LifecycleFilter } -export interface UnimplementedQuery extends InsightsQueryBase { - kind: NodeKind.UnimplementedQuery -} export type InsightQueryNode = | TrendsQuery @@ -333,7 +327,6 @@ export type InsightQueryNode = | PathsQuery | StickinessQuery | LifecycleQuery - | UnimplementedQuery export type InsightNodeKind = InsightQueryNode['kind'] export type InsightFilterProperty = | 'trendsFilter' @@ -349,7 +342,6 @@ export type InsightFilter = | PathsFilter | StickinessFilter | LifecycleFilter -export type SupportedNodeKind = Exclude export const dateRangeForFilter = (source: FilterType | undefined): DateRange | undefined => { if (!source) { diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index 36abb7f33f1544..a2153148d8df0f 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -4,25 +4,24 @@ import { DateRange, EventsNode, EventsQuery, + TrendsQuery, FunnelsQuery, + RetentionQuery, + PathsQuery, + StickinessQuery, + LifecycleQuery, InsightFilter, InsightFilterProperty, InsightQueryNode, InsightVizNode, LegacyQuery, - LifecycleQuery, Node, NodeKind, - PathsQuery, PersonsNode, RecentPerformancePageViewNode, - RetentionQuery, - StickinessQuery, - SupportedNodeKind, TimeToSeeDataQuery, TimeToSeeDataSessionsQuery, - TrendsQuery, - UnimplementedQuery, + InsightNodeKind, } from '~/queries/schema' import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types' @@ -94,10 +93,6 @@ export function isInsightQueryWithBreakdown(node?: Node): node is TrendsQuery | return isTrendsQuery(node) || isFunnelsQuery(node) } -export function isUnimplementedQuery(node?: Node): node is UnimplementedQuery { - return node?.kind === NodeKind.UnimplementedQuery -} - export function isInsightQueryNode(node?: Node): node is InsightQueryNode { return ( isTrendsQuery(node) || @@ -105,8 +100,7 @@ export function isInsightQueryNode(node?: Node): node is InsightQueryNode { isRetentionQuery(node) || isPathsQuery(node) || isStickinessQuery(node) || - isLifecycleQuery(node) || - isUnimplementedQuery(node) + isLifecycleQuery(node) ) } @@ -154,7 +148,7 @@ export function dateRangeFor(node?: Node): DateRange | undefined { return undefined } -const nodeKindToFilterProperty: Record = { +const nodeKindToFilterProperty: Record = { [NodeKind.TrendsQuery]: 'trendsFilter', [NodeKind.FunnelsQuery]: 'funnelsFilter', [NodeKind.RetentionQuery]: 'retentionFilter', @@ -163,15 +157,12 @@ const nodeKindToFilterProperty: Record [NodeKind.LifecycleQuery]: 'lifecycleFilter', } -export function filterPropertyForQuery(node: Exclude): InsightFilterProperty { +export function filterPropertyForQuery(node: InsightQueryNode): InsightFilterProperty { return nodeKindToFilterProperty[node.kind] } export function filterForQuery(node: InsightQueryNode): InsightFilter | undefined { - if (node.kind === NodeKind.UnimplementedQuery) { - return undefined - } - const filterProperty = filterPropertyForQuery(node) + const filterProperty = nodeKindToFilterProperty[node.kind] return node[filterProperty] } diff --git a/frontend/src/scenes/funnels/funnelDataLogic.ts b/frontend/src/scenes/funnels/funnelDataLogic.ts index 681ac4878fc141..52573136201029 100644 --- a/frontend/src/scenes/funnels/funnelDataLogic.ts +++ b/frontend/src/scenes/funnels/funnelDataLogic.ts @@ -116,7 +116,7 @@ export const funnelDataLogic = kea({ (s) => [s.querySource], (querySource: FunnelsQuery): Omit => ({ funnel_from_step: 0, - funnel_to_step: querySource.series.length > 1 ? querySource.series.length - 1 : 1, + funnel_to_step: (querySource.series || []).length > 1 ? querySource.series.length - 1 : 1, }), ], exclusionFilters: [ diff --git a/frontend/src/scenes/insights/EditorFilters/FunnelsQuerySteps.tsx b/frontend/src/scenes/insights/EditorFilters/FunnelsQuerySteps.tsx index b0847bced118f0..a8994adb1ece61 100644 --- a/frontend/src/scenes/insights/EditorFilters/FunnelsQuerySteps.tsx +++ b/frontend/src/scenes/insights/EditorFilters/FunnelsQuerySteps.tsx @@ -37,8 +37,8 @@ export function FunnelsQueryStepsDataExploration({ insightProps }: QueryEditorFi 0} + filterSteps={(querySource as FunnelsQuery).series || []} + showSeriesIndicator={((querySource as FunnelsQuery).series || []).length > 0} isDataExploration insightProps={insightProps} /> diff --git a/frontend/src/scenes/insights/EditorFilters/RetentionSummary.tsx b/frontend/src/scenes/insights/EditorFilters/RetentionSummary.tsx index fd9a908897940c..a4de1e6e630f78 100644 --- a/frontend/src/scenes/insights/EditorFilters/RetentionSummary.tsx +++ b/frontend/src/scenes/insights/EditorFilters/RetentionSummary.tsx @@ -1,50 +1,85 @@ import { useActions, useValues } from 'kea' import { InfoCircleOutlined } from '@ant-design/icons' +import { retentionLogic } from 'scenes/retention/retentionLogic' import { dateOptionPlurals, dateOptions, retentionOptionDescriptions, retentionOptions, - retentionTableLogic, -} from 'scenes/retention/retentionTableLogic' -import { Input, Select } from 'antd' -import { EditorFilterProps, FilterType, RetentionType } from '~/types' +} from 'scenes/retention/constants' +import { EditorFilterProps, FilterType, InsightLogicProps, QueryEditorFilterProps, RetentionType } from '~/types' import { IconOpenInNew } from 'lib/lemon-ui/icons' import { ActionFilter } from '../filters/ActionFilter/ActionFilter' import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { AggregationSelectComponent } from 'scenes/insights/filters/AggregationSelect' +import { AggregationSelect, AggregationSelectDataExploration } from 'scenes/insights/filters/AggregationSelect' import { groupsModel } from '~/models/groupsModel' import { MathAvailability } from '../filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { Link } from 'lib/lemon-ui/Link' +import { LemonInput, LemonSelect } from '@posthog/lemon-ui' +import { RetentionFilter } from '~/queries/schema' +import { insightDataLogic } from '../insightDataLogic' + +export function RetentionSummaryDataExploration({ insightProps }: QueryEditorFilterProps): JSX.Element { + const { retentionFilter } = useValues(insightDataLogic(insightProps)) + const { updateInsightFilter } = useActions(insightDataLogic(insightProps)) + return ( + + ) +} export function RetentionSummary({ insightProps }: EditorFilterProps): JSX.Element { + const { filters } = useValues(retentionLogic(insightProps)) + const { setFilters } = useActions(retentionLogic(insightProps)) + return +} + +type RetentionSummaryComponentProps = { + setFilters: (filters: Partial) => void + isDataExploration?: boolean + insightProps: InsightLogicProps +} & RetentionFilter + +export function RetentionSummaryComponent({ + target_entity, + returning_entity, + retention_type, + total_intervals, + period, + setFilters, + isDataExploration, + insightProps, +}: RetentionSummaryComponentProps): JSX.Element { const { showGroupsOptions } = useValues(groupsModel) - const { filters, actionFilterTargetEntity, actionFilterReturningEntity } = useValues( - retentionTableLogic(insightProps) - ) - const { setFilters } = useActions(retentionTableLogic(insightProps)) return (
-
- Show{' '} +
+ Show {showGroupsOptions ? ( - setFilters({ aggregation_group_type_index: groupTypeIndex })} - /> + isDataExploration ? ( + + ) : ( + + ) ) : ( - Unique users - )}{' '} - who performed event or action + Unique users + )} + who performed
+ event or action { if (newFilters.events && newFilters.events.length > 0) { setFilters({ target_entity: newFilters.events[0] }) @@ -56,78 +91,77 @@ export function RetentionSummary({ insightProps }: EditorFilterProps): JSX.Eleme }} typeKey="retention-table" /> - + />
-
- in the last{' '} - setFilters({ total_intervals: parseInt(e.target.value || '0') + 1 })} - />{' '} - + /> + and then came back to perform
-
- and then came back to perform event or action{' '} -
- { - if (newFilters.events && newFilters.events.length > 0) { - setFilters({ returning_entity: newFilters.events[0] }) - } else if (newFilters.actions && newFilters.actions.length > 0) { - setFilters({ returning_entity: newFilters.actions[0] }) - } else { - setFilters({ returning_entity: undefined }) - } - }} - typeKey="retention-table-returning" - /> -
- on any of the next {dateOptionPlurals[filters.period ?? 'Day']} +
+ event or action + { + if (newFilters.events && newFilters.events.length > 0) { + setFilters({ returning_entity: newFilters.events[0] }) + } else if (newFilters.actions && newFilters.actions.length > 0) { + setFilters({ returning_entity: newFilters.actions[0] }) + } else { + setFilters({ returning_entity: undefined }) + } + }} + typeKey="retention-table-returning" + /> + on any of the next {dateOptionPlurals[period ?? 'Day']}.
-

Want to learn more about retention?{' '} - Go to docs - - + +

diff --git a/frontend/src/scenes/insights/InsightDisplayConfig.tsx b/frontend/src/scenes/insights/InsightDisplayConfig.tsx index 3e166be3baa42f..b50aa93a56c023 100644 --- a/frontend/src/scenes/insights/InsightDisplayConfig.tsx +++ b/frontend/src/scenes/insights/InsightDisplayConfig.tsx @@ -7,13 +7,13 @@ import { FEATURE_FLAGS, NON_TIME_SERIES_DISPLAY_TYPES } from 'lib/constants' import { ChartDisplayType, FilterType, FunnelVizType, InsightType, ItemMode } from '~/types' import { CalendarOutlined, InfoCircleOutlined } from '@ant-design/icons' import { InsightDateFilter } from './filters/InsightDateFilter' -import { RetentionDatePicker } from './RetentionDatePicker' import { FunnelDisplayLayoutPicker, FunnelDisplayLayoutPickerDataExploration, } from './views/Funnels/FunnelDisplayLayoutPicker' import { PathStepPicker, PathStepPickerDataExploration } from './views/Paths/PathStepPicker' -import { ReferencePicker as RetentionReferencePicker } from './filters/ReferencePicker' +import { RetentionDatePicker, RetentionDatePickerDataExploration } from './RetentionDatePicker' +import { RetentionReferencePicker, RetentionReferencePickerDataExploration } from './filters/RetentionReferencePicker' import { Tooltip } from 'antd' import { FunnelBinsPicker } from './views/Funnels/FunnelBinsPicker' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -136,8 +136,12 @@ export function InsightDisplayConfig({ filters, disableTable }: InsightDisplayCo {isRetentionFilter(filters) && ( - - + {isUsingDataExploration ? : } + {isUsingDataExploration ? ( + + ) : ( + + )} )} diff --git a/frontend/src/scenes/insights/RetentionDatePicker.tsx b/frontend/src/scenes/insights/RetentionDatePicker.tsx index 74cce90e0554b1..8964c36c52cb62 100644 --- a/frontend/src/scenes/insights/RetentionDatePicker.tsx +++ b/frontend/src/scenes/insights/RetentionDatePicker.tsx @@ -1,34 +1,66 @@ import { useActions, useValues } from 'kea' -import { retentionTableLogic } from 'scenes/retention/retentionTableLogic' +import { retentionLogic } from 'scenes/retention/retentionLogic' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { insightLogic } from 'scenes/insights/insightLogic' import { dayjs } from 'lib/dayjs' import { DatePicker } from 'lib/components/DatePicker' +import { DateRange } from '~/queries/schema' +import { insightDataLogic } from './insightDataLogic' -export function RetentionDatePicker({ disabled }: { disabled?: boolean }): JSX.Element { +export function RetentionDatePickerDataExploration(): JSX.Element { const { insightProps } = useValues(insightLogic) - const { filters } = useValues(retentionTableLogic(insightProps)) - const { setFilters } = useActions(retentionTableLogic(insightProps)) + const { dateRange, retentionFilter } = useValues(insightDataLogic(insightProps)) + const { updateDateRange } = useActions(insightDataLogic(insightProps)) - const yearSuffix = filters.date_to && dayjs(filters.date_to).year() !== dayjs().year() ? ', YYYY' : '' + return ( + + ) +} + +export function RetentionDatePicker(): JSX.Element { + const { insightProps } = useValues(insightLogic) + const { filters } = useValues(retentionLogic(insightProps)) + const { setFilters } = useActions(retentionLogic(insightProps)) + + return ( + + ) +} + +type RetentionDatePickerComponentProps = { + period?: string + date_to?: string | null + updateDateRange: (filters: Partial) => void +} + +function RetentionDatePickerComponent({ + date_to, + period, + updateDateRange, +}: RetentionDatePickerComponentProps): JSX.Element { + const yearSuffix = date_to && dayjs(date_to).year() !== dayjs().year() ? ', YYYY' : '' return ( - <> - - - setFilters({ date_to: date_to && dayjs(date_to).toISOString() })} - allowClear - placeholder="Today" - className="retention-date-picker" - disabled={disabled} - /> - - - + + {/* eslint-disable-next-line react/forbid-dom-props */} + + { + updateDateRange({ date_to: date_to && dayjs(date_to).toISOString() }) + }} + allowClear + placeholder="Today" + className="retention-date-picker" + /> + + ) } diff --git a/frontend/src/scenes/insights/filters/AggregationSelect.tsx b/frontend/src/scenes/insights/filters/AggregationSelect.tsx index 2b46e2f3bb6476..08ce65a02a1a6d 100644 --- a/frontend/src/scenes/insights/filters/AggregationSelect.tsx +++ b/frontend/src/scenes/insights/filters/AggregationSelect.tsx @@ -5,43 +5,51 @@ import { groupsAccessLogic } from 'lib/introductions/groupsAccessLogic' import { GroupIntroductionFooter } from 'scenes/groups/GroupsIntroduction' import { funnelLogic } from 'scenes/funnels/funnelLogic' import { InsightLogicProps } from '~/types' -import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' +import { insightDataLogic } from '../insightDataLogic' +import { insightLogic } from '../insightLogic' type AggregationSelectProps = { insightProps: InsightLogicProps + className?: string } -export function AggregationSelectDataExploration({ insightProps }: AggregationSelectProps): JSX.Element { - const { querySource } = useValues(funnelDataLogic(insightProps)) - const { updateQuerySource } = useActions(funnelDataLogic(insightProps)) +export function AggregationSelectDataExploration({ insightProps, className }: AggregationSelectProps): JSX.Element { + const { querySource } = useValues(insightDataLogic(insightProps)) + const { updateQuerySource } = useActions(insightDataLogic(insightProps)) return ( updateQuerySource({ aggregation_group_type_index })} /> ) } -export function AggregationSelect({ insightProps }: AggregationSelectProps): JSX.Element { - const { filters } = useValues(funnelLogic(insightProps)) +export function AggregationSelect({ insightProps, className }: AggregationSelectProps): JSX.Element { + const { filters } = useValues(insightLogic(insightProps)) const { setFilters } = useActions(funnelLogic(insightProps)) return ( setFilters({ aggregation_group_type_index })} + onChange={(aggregation_group_type_index) => { + setFilters({ ...filters, aggregation_group_type_index }) + }} /> ) } const UNIQUE_USERS = -1 interface AggregationSelectComponentProps { + className?: string aggregationGroupTypeIndex: number | undefined onChange: (aggregation_group_type_index: number | undefined) => void } -export function AggregationSelectComponent({ +function AggregationSelectComponent({ + className, aggregationGroupTypeIndex: aggregation_group_type_index, onChange, }: AggregationSelectComponentProps): JSX.Element { @@ -73,6 +81,7 @@ export function AggregationSelectComponent({ return ( { if (value !== null) { diff --git a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx index 57fce1b21948fa..87c020eb50bf66 100644 --- a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx +++ b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx @@ -21,7 +21,7 @@ export function FunnelExclusionsFilterDataExploration(): JSX.Element { 1} + areFiltersValid={((querySource as FunnelsQuery).series || []).length > 1} setFilters={(filters) => { const exclusions = (filters.events as FunnelStepRangeEntityFilter[]).map((e) => ({ ...e, diff --git a/frontend/src/scenes/insights/filters/ReferencePicker.tsx b/frontend/src/scenes/insights/filters/ReferencePicker.tsx deleted file mode 100644 index d7949f8e61f9a2..00000000000000 --- a/frontend/src/scenes/insights/filters/ReferencePicker.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Select } from 'antd' -import { PercentageOutlined } from '@ant-design/icons' -import { insightLogic } from 'scenes/insights/insightLogic' -import { useActions, useValues } from 'kea' -import { retentionTableLogic } from 'scenes/retention/retentionTableLogic' - -export function ReferencePicker({ disabled }: { disabled?: boolean }): JSX.Element { - /* - Reference picker specifies how retention values should be displayed, - options and description found in `enum Reference` - */ - const { insightProps } = useValues(insightLogic) - const { retentionReference } = useValues(retentionTableLogic(insightProps)) - const { setRetentionReference } = useActions(retentionTableLogic(insightProps)) - - return ( - - ) -} diff --git a/frontend/src/scenes/insights/filters/RetentionReferencePicker.tsx b/frontend/src/scenes/insights/filters/RetentionReferencePicker.tsx new file mode 100644 index 00000000000000..c0a46119796ba0 --- /dev/null +++ b/frontend/src/scenes/insights/filters/RetentionReferencePicker.tsx @@ -0,0 +1,70 @@ +import { Select } from 'antd' +import { PercentageOutlined } from '@ant-design/icons' +import { insightLogic } from 'scenes/insights/insightLogic' +import { useActions, useValues } from 'kea' +import { retentionLogic } from 'scenes/retention/retentionLogic' +import { RetentionFilter } from '~/queries/schema' +import { insightDataLogic } from '../insightDataLogic' + +export function RetentionReferencePickerDataExploration(): JSX.Element { + const { insightProps } = useValues(insightLogic) + const { retentionFilter } = useValues(insightDataLogic(insightProps)) + const { updateInsightFilter } = useActions(insightDataLogic(insightProps)) + + return +} + +export function RetentionReferencePicker(): JSX.Element { + const { insightProps } = useValues(insightLogic) + const { filters } = useValues(retentionLogic(insightProps)) + const { setFilters } = useActions(retentionLogic(insightProps)) + + return +} + +type RetentionReferencePickerComponentProps = { + setFilters: (filters: Partial) => void +} & RetentionFilter + +export function RetentionReferencePickerComponent({ + retention_reference, + setFilters, +}: RetentionReferencePickerComponentProps): JSX.Element { + return ( + + ) +} diff --git a/frontend/src/scenes/insights/insightDataLogic.ts b/frontend/src/scenes/insights/insightDataLogic.ts index cb89fe57e72579..8a45e385094ffc 100644 --- a/frontend/src/scenes/insights/insightDataLogic.ts +++ b/frontend/src/scenes/insights/insightDataLogic.ts @@ -1,7 +1,16 @@ import { kea, props, key, path, actions, reducers, selectors, connect, listeners } from 'kea' -import { FilterType, FunnelVizType, InsightLogicProps, InsightType, PathType } from '~/types' +import { FilterType, FunnelVizType, InsightLogicProps, InsightType, PathType, RetentionPeriod } from '~/types' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' -import { BreakdownFilter, InsightFilter, InsightQueryNode, InsightVizNode, Node, NodeKind } from '~/queries/schema' +import { + BreakdownFilter, + DateRange, + InsightFilter, + InsightNodeKind, + InsightQueryNode, + InsightVizNode, + Node, + NodeKind, +} from '~/queries/schema' import { BaseMathType } from '~/types' import { ShownAsValue } from 'lib/constants' @@ -18,7 +27,7 @@ import { isPathsQuery, isStickinessQuery, isLifecycleQuery, - isUnimplementedQuery, + isInsightQueryWithBreakdown, } from '~/queries/utils' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { FEATURE_FLAGS } from 'lib/constants' @@ -27,15 +36,7 @@ import { trendsLogic } from 'scenes/trends/trendsLogic' // TODO: should take the existing values.query and set params from previous view similar to // cleanFilters({ ...values.filters, insight: type as InsightType }, values.filters) -const getCleanedQuery = ( - kind: - | NodeKind.TrendsQuery - | NodeKind.FunnelsQuery - | NodeKind.PathsQuery - | NodeKind.StickinessQuery - | NodeKind.LifecycleQuery - | NodeKind.UnimplementedQuery -): InsightVizNode => { +const getCleanedQuery = (kind: InsightNodeKind): InsightVizNode => { if (kind === NodeKind.TrendsQuery) { return { kind: NodeKind.InsightVizNode, @@ -69,6 +70,28 @@ const getCleanedQuery = ( }, }, } + } else if (kind === NodeKind.RetentionQuery) { + return { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.RetentionQuery, + retentionFilter: { + period: RetentionPeriod.Day, + total_intervals: 11, + target_entity: { + id: '$pageview', + name: '$pageview', + type: 'events', + }, + returning_entity: { + id: '$pageview', + name: '$pageview', + type: 'events', + }, + retention_type: 'retention_first_time', + }, + }, + } } else if (kind === NodeKind.PathsQuery) { return { kind: NodeKind.InsightVizNode, @@ -112,12 +135,7 @@ const getCleanedQuery = ( }, } } else { - return { - kind: NodeKind.InsightVizNode, - source: { - kind: NodeKind.UnimplementedQuery, - }, - } + throw new Error('should not reach here') } } @@ -154,6 +172,7 @@ export const insightDataLogic = kea([ setQuery: (query: Node) => ({ query }), updateQuerySource: (query: Omit, 'kind'>) => ({ query }), updateInsightFilter: (insightFilter: InsightFilter) => ({ insightFilter }), + updateDateRange: (dateRange: DateRange) => ({ dateRange }), updateBreakdown: (breakdown: BreakdownFilter) => ({ breakdown }), }), @@ -168,6 +187,10 @@ export const insightDataLogic = kea([ selectors({ querySource: [(s) => [s.query], (query) => (query as InsightVizNode).source], + + dateRange: [(s) => [s.querySource], (q) => q.dateRange], + breakdown: [(s) => [s.querySource], (q) => (isInsightQueryWithBreakdown(q) ? q.breakdown : null)], + insightFilter: [(s) => [s.querySource], (q) => filterForQuery(q)], trendsFilter: [(s) => [s.querySource], (q) => (isTrendsQuery(q) ? q.trendsFilter : null)], funnelsFilter: [(s) => [s.querySource], (q) => (isFunnelsQuery(q) ? q.funnelsFilter : null)], @@ -178,15 +201,15 @@ export const insightDataLogic = kea([ }), listeners(({ actions, values }) => ({ + updateDateRange: ({ dateRange }) => { + const newQuerySource = { ...values.querySource, dateRange } + actions.updateQuerySource(newQuerySource) + }, updateBreakdown: ({ breakdown }) => { const newQuerySource = { ...values.querySource, breakdown } actions.updateQuerySource(newQuerySource) }, updateInsightFilter: ({ insightFilter }) => { - if (isUnimplementedQuery(values.querySource)) { - return - } - const filterProperty = filterPropertyForQuery(values.querySource) const newQuerySource = { ...values.querySource } newQuerySource[filterProperty] = { @@ -226,14 +249,14 @@ export const insightDataLogic = kea([ actions.setQuery(getCleanedQuery(NodeKind.TrendsQuery)) } else if (type === InsightType.FUNNELS) { actions.setQuery(getCleanedQuery(NodeKind.FunnelsQuery)) + } else if (type === InsightType.RETENTION) { + actions.setQuery(getCleanedQuery(NodeKind.RetentionQuery)) } else if (type === InsightType.PATHS) { actions.setQuery(getCleanedQuery(NodeKind.PathsQuery)) } else if (type === InsightType.STICKINESS) { actions.setQuery(getCleanedQuery(NodeKind.StickinessQuery)) } else if (type === InsightType.LIFECYCLE) { actions.setQuery(getCleanedQuery(NodeKind.LifecycleQuery)) - } else { - actions.setQuery(getCleanedQuery(NodeKind.UnimplementedQuery)) } }, setInsight: ({ insight: { filters }, options: { overrideFilter } }) => { diff --git a/frontend/src/scenes/insights/insightLogic.ts b/frontend/src/scenes/insights/insightLogic.ts index 7eebc8b551b2f2..e12e0fe4ddb936 100644 --- a/frontend/src/scenes/insights/insightLogic.ts +++ b/frontend/src/scenes/insights/insightLogic.ts @@ -757,17 +757,9 @@ export const insightLogic = kea([ }, ], isUsingDataExploration: [ - (s) => [s.featureFlags, s.filters], - (featureFlags: FeatureFlagsSet, filters: Partial): boolean => { - const featureDataExploration = featureFlags[FEATURE_FLAGS.DATA_EXPLORATION_INSIGHTS] - return ( - !!featureDataExploration && - (isTrendsFilter(filters) || - isFunnelsFilter(filters) || - isPathsFilter(filters) || - isStickinessFilter(filters) || - isLifecycleFilter(filters)) - ) + (s) => [s.featureFlags], + (featureFlags: FeatureFlagsSet): boolean => { + return !!featureFlags[FEATURE_FLAGS.DATA_EXPLORATION_INSIGHTS] }, ], }), diff --git a/frontend/src/scenes/insights/sharedUtils.ts b/frontend/src/scenes/insights/sharedUtils.ts index 9b026816668ebb..109a77e398aed1 100644 --- a/frontend/src/scenes/insights/sharedUtils.ts +++ b/frontend/src/scenes/insights/sharedUtils.ts @@ -1,4 +1,4 @@ -// This is separate from utils.ts because here we don't include `funnelLogic`, `retentionTableLogic`, etc +// This is separate from utils.ts because here we don't include `funnelLogic`, `retentionLogic`, etc import { FilterType, diff --git a/frontend/src/scenes/insights/utils.test.ts b/frontend/src/scenes/insights/utils.test.ts index 9ef4d710ad2df1..542ad429318673 100644 --- a/frontend/src/scenes/insights/utils.test.ts +++ b/frontend/src/scenes/insights/utils.test.ts @@ -39,6 +39,7 @@ import { TrendsQuery, PathsQuery, FunnelsQuery, + RetentionQuery, } from '~/queries/schema' import { isEventsNode } from '~/queries/utils' @@ -850,6 +851,57 @@ describe('summarizeInsightQuery()', () => { expect(result).toEqual("Pageview → random_event organization conversion rate by person's some_prop") }) + it('summarizes a user first-time Retention insight with the same event for cohortizing and returning', () => { + const query: RetentionQuery = { + kind: NodeKind.RetentionQuery, + retentionFilter: { + target_entity: { + id: '$autocapture', + name: '$autocapture', + type: 'event', + }, + returning_entity: { + id: '$autocapture', + name: '$autocapture', + type: 'event', + }, + retention_type: RETENTION_FIRST_TIME, + }, + } + + const result = summarizeInsightQuery(query, aggregationLabel, cohortIdsMapped, mathDefinitions) + + expect(result).toEqual( + 'Retention of users based on doing Autocapture for the first time and returning with the same event' + ) + }) + + it('summarizes an organization recurring Retention insight with the different events for cohortizing and returning', () => { + const query: RetentionQuery = { + kind: NodeKind.RetentionQuery, + retentionFilter: { + target_entity: { + id: 'purchase', + name: 'purchase', + type: 'event', + }, + returning_entity: { + id: '$pageview', + name: '$pageview', + type: 'event', + }, + retention_type: RETENTION_RECURRING, + }, + aggregation_group_type_index: 0, + } + + const result = summarizeInsightQuery(query, aggregationLabel, cohortIdsMapped, mathDefinitions) + + expect(result).toEqual( + 'Retention of organizations based on doing purchase recurringly and returning with Pageview' + ) + }) + it('summarizes a Paths insight based on all events', () => { const query: PathsQuery = { kind: NodeKind.PathsQuery, diff --git a/frontend/src/scenes/insights/utils.tsx b/frontend/src/scenes/insights/utils.tsx index 33f22a601a5d54..e89fbd91c0ce17 100644 --- a/frontend/src/scenes/insights/utils.tsx +++ b/frontend/src/scenes/insights/utils.tsx @@ -23,7 +23,7 @@ import { getCurrentTeamId } from 'lib/utils/logics' import { groupsModelType } from '~/models/groupsModelType' import { toLocalFilters } from './filters/ActionFilter/entityFilterLogic' import { RETENTION_FIRST_TIME } from 'lib/constants' -import { retentionOptions } from 'scenes/retention/retentionTableLogic' +import { retentionOptions } from 'scenes/retention/constants' import { cohortsModelType } from '~/models/cohortsModelType' import { mathsLogicType } from 'scenes/trends/mathsLogicType' import { apiValueToMathType, MathCategory, MathDefinition } from 'scenes/trends/mathsLogic' @@ -45,6 +45,7 @@ import { isFunnelsQuery, isLifecycleQuery, isPathsQuery, + isRetentionQuery, isStickinessQuery, isTrendsQuery, } from '~/queries/utils' @@ -409,6 +410,20 @@ export function summarizeInsightQuery( summary += ` by ${summarizeBreakdown(query.breakdown, aggregationLabel, cohortsById)}` } return summary + } else if (isRetentionQuery(query)) { + const areTargetAndReturningIdentical = + query.retentionFilter?.returning_entity?.id === query.retentionFilter?.target_entity?.id && + query.retentionFilter?.returning_entity?.type === query.retentionFilter?.target_entity?.type + return ( + `Retention of ${aggregationLabel(query.aggregation_group_type_index, true).plural}` + + ` based on doing ${getDisplayNameFromEntityFilter( + (query.retentionFilter?.target_entity || {}) as EntityFilter + )}` + + ` ${retentionOptions[query.retentionFilter?.retention_type || RETENTION_FIRST_TIME]} and returning with ` + + (areTargetAndReturningIdentical + ? 'the same event' + : getDisplayNameFromEntityFilter((query.retentionFilter?.returning_entity || {}) as EntityFilter)) + ) } else if (isPathsQuery(query)) { // Sync format with PathsSummary in InsightDetails let summary = `User paths based on ${humanizePathsEventTypes(query.pathsFilter?.include_event_types).join( diff --git a/frontend/src/scenes/insights/utils/cleanFilters.ts b/frontend/src/scenes/insights/utils/cleanFilters.ts index 01e6cb4c873bac..e5689ea85e3d32 100644 --- a/frontend/src/scenes/insights/utils/cleanFilters.ts +++ b/frontend/src/scenes/insights/utils/cleanFilters.ts @@ -10,6 +10,7 @@ import { PathsFilterType, PathType, RetentionFilterType, + RetentionPeriod, TrendsFilterType, } from '~/types' import { deepCleanFunnelExclusionEvents, getClampedStepRangeFilter, isStepsUndefined } from 'scenes/funnels/funnelUtils' @@ -150,7 +151,7 @@ export function cleanFilters( }, returning_entity: filters.returning_entity || { id: '$pageview', type: 'events', name: '$pageview' }, date_to: filters.date_to, - period: filters.period || 'Day', + period: filters.period || RetentionPeriod.Day, retention_type: filters.retention_type || (filters as any)['retentionType'] || RETENTION_FIRST_TIME, breakdowns: filters.breakdowns, breakdown_type: filters.breakdown_type, diff --git a/frontend/src/scenes/retention/RetentionContainer.tsx b/frontend/src/scenes/retention/RetentionContainer.tsx index 1fdc39bf19d037..e12deb74dbf104 100644 --- a/frontend/src/scenes/retention/RetentionContainer.tsx +++ b/frontend/src/scenes/retention/RetentionContainer.tsx @@ -2,6 +2,7 @@ import { RetentionLineGraph } from './RetentionLineGraph' import { RetentionTable } from './RetentionTable' import './RetentionContainer.scss' import { LemonDivider } from '@posthog/lemon-ui' +import { RetentionModal } from './RetentionModal' export function RetentionContainer({ inCardView, @@ -21,6 +22,7 @@ export function RetentionContainer({
+ )}
diff --git a/frontend/src/scenes/retention/RetentionLineGraph.tsx b/frontend/src/scenes/retention/RetentionLineGraph.tsx index fb1ae40e648441..96ee0dacec8071 100644 --- a/frontend/src/scenes/retention/RetentionLineGraph.tsx +++ b/frontend/src/scenes/retention/RetentionLineGraph.tsx @@ -1,13 +1,14 @@ -import { useState } from 'react' -import { retentionTableLogic } from './retentionTableLogic' -import { LineGraph } from '../insights/views/LineGraph/LineGraph' import { useActions, useValues } from 'kea' -import { InsightEmptyState } from '../insights/EmptyStates' -import { GraphType, GraphDataset } from '~/types' -import { RetentionTablePayload, RetentionTablePeoplePayload } from 'scenes/retention/types' + import { insightLogic } from 'scenes/insights/insightLogic' -import { RetentionModal } from './RetentionModal' +import { retentionLogic } from './retentionLogic' +import { retentionLineGraphLogic } from './retentionLineGraphLogic' +import { retentionModalLogic } from './retentionModalLogic' + +import { GraphType, GraphDataset } from '~/types' import { roundToDecimal } from 'lib/utils' +import { LineGraph } from '../insights/views/LineGraph/LineGraph' +import { InsightEmptyState } from '../insights/EmptyStates' interface RetentionLineGraphProps { inSharedMode?: boolean @@ -15,81 +16,53 @@ interface RetentionLineGraphProps { export function RetentionLineGraph({ inSharedMode = false }: RetentionLineGraphProps): JSX.Element | null { const { insightProps } = useValues(insightLogic) - const logic = retentionTableLogic(insightProps) - const { - results: _results, - filters, - trendSeries, - people: _people, - peopleLoading, - loadingMore, - aggregationTargetLabel, - incompletenessOffsetFromEnd, - } = useValues(logic) - const results = _results as RetentionTablePayload[] - const people = _people as RetentionTablePeoplePayload - - const { loadPeople, loadMorePeople } = useActions(logic) - const [modalVisible, setModalVisible] = useState(false) - const [selectedRow, selectRow] = useState(0) + const { filters } = useValues(retentionLogic(insightProps)) + const { trendSeries, incompletenessOffsetFromEnd } = useValues(retentionLineGraphLogic(insightProps)) + const { openModal } = useActions(retentionModalLogic(insightProps)) if (trendSeries.length === 0) { return null } + return trendSeries ? ( - <> - - {value} - Cohort - - ) - }, - showHeader: false, - renderCount: (count) => { - return `${roundToDecimal(count)}%` - }, - }} - onClick={(payload) => { - const { points } = payload - const datasetIndex = points.clickedPointNotLine - ? points.pointsIntersectingClick[0].dataset.index - : points.pointsIntersectingLine[0].dataset.index - if (datasetIndex) { - loadPeople(datasetIndex) // start from 0 - selectRow(datasetIndex) - } - setModalVisible(true) - }} - incompletenessOffsetFromEnd={incompletenessOffsetFromEnd} - /> - {results && ( - setModalVisible(false)} - actorsLoading={peopleLoading} - loadMore={() => loadMorePeople()} - loadingMore={loadingMore} - aggregationTargetLabel={aggregationTargetLabel} - /> - )} - + + {value} + Cohort + + ) + }, + showHeader: false, + renderCount: (count) => { + return `${roundToDecimal(count)}%` + }, + }} + onClick={(payload) => { + const { points } = payload + const rowIndex = points.clickedPointNotLine + ? points.pointsIntersectingClick[0].dataset.index + : points.pointsIntersectingLine[0].dataset.index + + // we should always have a rowIndex, but adding a guard nonetheless + if (rowIndex !== undefined) { + openModal(rowIndex) + } + }} + incompletenessOffsetFromEnd={incompletenessOffsetFromEnd} + /> ) : ( ) diff --git a/frontend/src/scenes/retention/RetentionModal.tsx b/frontend/src/scenes/retention/RetentionModal.tsx index 173887f85da84c..7c77616b4f3ebd 100644 --- a/frontend/src/scenes/retention/RetentionModal.tsx +++ b/frontend/src/scenes/retention/RetentionModal.tsx @@ -1,9 +1,5 @@ import { capitalizeFirstLetter, isGroupType, percentage } from 'lib/utils' -import { - RetentionTablePayload, - RetentionTablePeoplePayload, - RetentionTableAppearanceType, -} from 'scenes/retention/types' +import { RetentionTableAppearanceType } from 'scenes/retention/types' import { dayjs } from 'lib/dayjs' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' import './RetentionTable.scss' @@ -15,36 +11,33 @@ import { triggerExport } from 'lib/components/ExportButton/exporter' import { ExporterFormat } from '~/types' import clsx from 'clsx' import { MissingPersonsAlert } from 'scenes/trends/persons-modal/PersonsModal' -import { Noun } from '~/models/groupsModel' +import { useActions, useValues } from 'kea' +import { insightLogic } from 'scenes/insights/insightLogic' +import { retentionLogic } from './retentionLogic' +import { retentionPeopleLogic } from './retentionPeopleLogic' +import { retentionModalLogic } from './retentionModalLogic' -export function RetentionModal({ - results, - visible, - selectedRow, - dismissModal, - actorsLoading, - actors, - loadMore, - loadingMore, - aggregationTargetLabel, -}: { - results: RetentionTablePayload[] - visible: boolean - selectedRow: number - dismissModal: () => void - loadMore: () => void - actorsLoading: boolean - loadingMore: boolean - actors: RetentionTablePeoplePayload - aggregationTargetLabel: Noun -}): JSX.Element | null { +export function RetentionModal(): JSX.Element | null { + const { insightProps } = useValues(insightLogic) + const { results } = useValues(retentionLogic(insightProps)) + const { people, peopleLoading, peopleLoadingMore } = useValues(retentionPeopleLogic(insightProps)) + const { loadMorePeople } = useActions(retentionPeopleLogic(insightProps)) + const { aggregationTargetLabel, selectedRow } = useValues(retentionModalLogic(insightProps)) + const { closeModal } = useActions(retentionModalLogic(insightProps)) + + if (!results || selectedRow === null) { + return null + } + + const row = results[selectedRow] + const isEmpty = row.values[0]?.count === 0 return ( - + Close } - width={results[selectedRow]?.values[0]?.count === 0 ? undefined : '90%'} - title={results[selectedRow] ? dayjs(results[selectedRow].date).format('MMMM D, YYYY') : ''} + width={isEmpty ? undefined : '90%'} + title={`${dayjs(row.date).format('MMMM D, YYYY')} Cohort`} > - {actors && !!actors.missing_persons && ( - + {people && !!people.missing_persons && ( + )}
- {actorsLoading ? ( + {peopleLoading ? ( - ) : results[selectedRow]?.values[0]?.count === 0 ? ( + ) : isEmpty ? ( No {aggregationTargetLabel.plural} during this period. ) : ( <> @@ -80,7 +73,7 @@ export function RetentionModal({ {capitalizeFirstLetter(aggregationTargetLabel.singular)} - {results?.[selectedRow]?.values?.map((data: any, index: number) => ( + {row.values?.map((data: any, index: number) => (
{results[index].label}
@@ -88,19 +81,15 @@ export function RetentionModal({   {data.count > 0 && ( - ( - {percentage( - data.count / results[selectedRow]?.values[0]['count'] - )} - ) + ({percentage(data.count / row?.values[0]['count'])}) )}
))} - {actors.result && - actors.result.map((personAppearances: RetentionTableAppearanceType) => ( + {people.result && + people.result.map((personAppearances: RetentionTableAppearanceType) => ( {/* eslint-disable-next-line react/forbid-dom-props */} @@ -129,14 +118,14 @@ export function RetentionModal({ )} {personAppearances.appearances.map((appearance: number, index: number) => { + const hasAppearance = !!appearance return (
) @@ -146,8 +135,8 @@ export function RetentionModal({
- {actors.next ? ( - + {people.next ? ( + Load more {aggregationTargetLabel.plural} ) : null} diff --git a/frontend/src/scenes/retention/RetentionTable.tsx b/frontend/src/scenes/retention/RetentionTable.tsx index a1fffceac58cd5..f0a25195e03987 100644 --- a/frontend/src/scenes/retention/RetentionTable.tsx +++ b/frontend/src/scenes/retention/RetentionTable.tsx @@ -1,100 +1,68 @@ -import { useState, useEffect } from 'react' import { useValues, useActions } from 'kea' -import { retentionTableLogic } from './retentionTableLogic' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { RetentionTablePayload, RetentionTablePeoplePayload } from 'scenes/retention/types' import clsx from 'clsx' -import { insightLogic } from 'scenes/insights/insightLogic' import { dayjs } from 'lib/dayjs' -import './RetentionTable.scss' -import { RetentionModal } from './RetentionModal' +import { insightLogic } from 'scenes/insights/insightLogic' +import { retentionLogic } from './retentionLogic' +import { retentionTableLogic } from './retentionTableLogic' +import { retentionModalLogic } from './retentionModalLogic' + +import { Tooltip } from 'lib/lemon-ui/Tooltip' +import './RetentionTable.scss' export function RetentionTable({ inCardView = false }: { inCardView?: boolean }): JSX.Element | null { const { insightProps } = useValues(insightLogic) - const logic = retentionTableLogic(insightProps) const { - results: _results, + results, resultsLoading, - peopleLoading, - people: _people, - loadingMore, filters: { period, date_to }, - aggregationTargetLabel, - tableHeaders, - tableRows, - } = useValues(logic) - const results = _results as RetentionTablePayload[] - const people = _people as RetentionTablePeoplePayload - - const { loadPeople, loadMorePeople } = useActions(logic) - const [modalVisible, setModalVisible] = useState(false) - const [selectedRow, selectRow] = useState(0) - const [isLatestPeriod, setIsLatestPeriod] = useState(false) + } = useValues(retentionLogic(insightProps)) + const { tableHeaders, tableRows } = useValues(retentionTableLogic(insightProps)) + const { openModal } = useActions(retentionModalLogic(insightProps)) - useEffect(() => { - setIsLatestPeriod(periodIsLatest(date_to || null, period || null)) - }, [date_to, period]) + const isLatestPeriod = periodIsLatest(date_to || null, period || null) if (resultsLoading || !results?.length) { return null } return ( - <> - - - - {tableHeaders.map((heading) => ( - - ))} - - - {tableRows.map((row, rowIndex) => ( - { - if (!inCardView && rowIndex !== undefined) { - loadPeople(rowIndex) - setModalVisible(true) - selectRow(rowIndex) - } - }} - > - {row.map((column, columnIndex) => ( - - ))} - +
{heading}
- {columnIndex <= 1 ? ( - - {column} - - ) : ( - renderPercentage( - column.percentage, - isLatestPeriod && columnIndex === row.length - 1, - columnIndex === 2 // First result column renders differently - ) - )} -
+ + + {tableHeaders.map((heading) => ( + ))} - -
{heading}
+ - {results && ( - setModalVisible(false)} - actorsLoading={peopleLoading} - loadMore={loadMorePeople} - loadingMore={loadingMore} - aggregationTargetLabel={aggregationTargetLabel} - /> - )} - + {tableRows.map((row, rowIndex) => ( + { + if (!inCardView) { + openModal(rowIndex) + } + }} + > + {row.map((column, columnIndex) => ( + + {columnIndex <= 1 ? ( + + {column} + + ) : ( + renderPercentage( + column.percentage, + isLatestPeriod && columnIndex === row.length - 1, + columnIndex === 2 // First result column renders differently + ) + )} + + ))} + + ))} + + ) } diff --git a/frontend/src/scenes/retention/abstractRetentionLogic.ts b/frontend/src/scenes/retention/abstractRetentionLogic.ts new file mode 100644 index 00000000000000..dfa29dae83af41 --- /dev/null +++ b/frontend/src/scenes/retention/abstractRetentionLogic.ts @@ -0,0 +1,115 @@ +import { kea } from 'kea' +import { insightLogic } from 'scenes/insights/insightLogic' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { RetentionTablePayload } from 'scenes/retention/types' +import { InsightLogicProps } from '~/types' +import { insightDataLogic } from 'scenes/insights/insightDataLogic' + +import type { abstractRetentionLogicType } from './abstractRetentionLogicType' +import { retentionLogic } from './retentionLogic' +import { DateRange, BreakdownFilter, RetentionFilter } from '~/queries/schema' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' + +const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' + +// this logic disambiguates between data exploration "queries" and +// regular "filters" for the purposes of feature flag `data-exploration-insights` +export const abstractRetentionLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), + path: (key) => ['scenes', 'retention', 'abstractRetentionLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [ + insightLogic(props), + ['isUsingDataExploration'], + retentionLogic(props), + ['filters', 'results as retentionResults', 'resultsLoading as retentionResultsLoading'], + insightDataLogic(props), + [ + 'querySource as dataExplorationQuerySource', + 'retentionFilter as dataExplorationRetentionFilter', + 'breakdown as dataExplorationBreakdown', + 'dateRange as dataExplorationDateRange', + ], + ], + }), + selectors: { + apiFilters: [ + (s) => [s.isUsingDataExploration, s.filters, s.dataExplorationQuerySource], + (isUsingDataExploration, filters, dataExplorationQuerySource) => { + if (isUsingDataExploration) { + return queryNodeToFilter(dataExplorationQuerySource) + } + + return filters + }, + ], + retentionFilter: [ + (s) => [s.isUsingDataExploration, s.filters, s.dataExplorationRetentionFilter], + (isUsingDataExploration, filters, dataExplorationRetentionFilter): RetentionFilter => { + if (isUsingDataExploration) { + return dataExplorationRetentionFilter || {} + } + + return { + retention_type: filters.retention_type, + retention_reference: filters.retention_reference, + total_intervals: filters.total_intervals, + target_entity: filters.target_entity, + returning_entity: filters.returning_entity, + period: filters.period, + } + }, + ], + dateRange: [ + (s) => [s.isUsingDataExploration, s.filters, s.dataExplorationDateRange], + (isUsingDataExploration, filters, dataExplorationDateRange): DateRange => { + if (isUsingDataExploration) { + return dataExplorationDateRange || {} + } + + return { + date_to: filters.date_to, + date_from: filters.date_from, + } + }, + ], + breakdown: [ + (s) => [s.isUsingDataExploration, s.filters, s.dataExplorationBreakdown], + (isUsingDataExploration, filters, dataExplorationBreakdown): BreakdownFilter => { + if (isUsingDataExploration) { + return dataExplorationBreakdown || {} + } + + return { + breakdown_type: filters.breakdown_type, + breakdown: filters.breakdown, + breakdown_normalize_url: filters.breakdown_normalize_url, + breakdowns: filters.breakdowns, + breakdown_value: filters.breakdown_value, + breakdown_group_type_index: filters.breakdown_group_type_index, + } + }, + ], + aggregation: [ + (s) => [s.isUsingDataExploration, s.filters, s.dataExplorationQuerySource], + ( + isUsingDataExploration, + filters, + dataExplorationQuerySource + ): { aggregation_group_type_index?: number } => { + if (isUsingDataExploration) { + return { + aggregation_group_type_index: dataExplorationQuerySource.aggregation_group_type_index, + } + } + + return { + aggregation_group_type_index: filters.aggregation_group_type_index, + } + }, + ], + results: [(s) => [s.retentionResults], (retentionResults): RetentionTablePayload[] => retentionResults], + resultsLoading: [(s) => [s.retentionResultsLoading], (retentionResultsLoading) => retentionResultsLoading], + }, +}) diff --git a/frontend/src/scenes/retention/constants.ts b/frontend/src/scenes/retention/constants.ts new file mode 100644 index 00000000000000..8137c07d8447b7 --- /dev/null +++ b/frontend/src/scenes/retention/constants.ts @@ -0,0 +1,36 @@ +import { OpUnitType } from 'lib/dayjs' +import { RETENTION_FIRST_TIME, RETENTION_RECURRING } from 'lib/constants' +import { RetentionPeriod } from '~/types' + +export const dateOptions: RetentionPeriod[] = [ + RetentionPeriod.Hour, + RetentionPeriod.Day, + RetentionPeriod.Week, + RetentionPeriod.Month, +] + +// https://day.js.org/docs/en/durations/creating#list-of-all-available-units +export const dateOptionToTimeIntervalMap: Record = { + Hour: 'h', + Day: 'd', + Week: 'w', + Month: 'M', +} + +export const dateOptionPlurals: Record = { + Hour: 'hours', + Day: 'days', + Week: 'weeks', + Month: 'months', +} + +export const retentionOptions = { + [RETENTION_FIRST_TIME]: 'for the first time', + [RETENTION_RECURRING]: 'recurringly', +} + +export const retentionOptionDescriptions = { + [`${RETENTION_RECURRING}`]: 'A user will belong to any cohort where they have performed the event in its Period 0.', + [`${RETENTION_FIRST_TIME}`]: + 'A user will only belong to the cohort for which they performed the event for the first time.', +} diff --git a/frontend/src/scenes/retention/retentionLineGraphLogic.test.ts b/frontend/src/scenes/retention/retentionLineGraphLogic.test.ts new file mode 100644 index 00000000000000..3f8d3a0bcbd73e --- /dev/null +++ b/frontend/src/scenes/retention/retentionLineGraphLogic.test.ts @@ -0,0 +1,103 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { retentionLineGraphLogic } from 'scenes/retention/retentionLineGraphLogic' +import { insightLogic } from 'scenes/insights/insightLogic' +import { InsightShortId, InsightType, RetentionFilterType } from '~/types' +import { useMocks } from '~/mocks/jest' + +const Insight123 = '123' as InsightShortId +const result = [ + { + values: [ + { count: 400, people: [] }, + { count: 100, people: [] }, + { count: 75, people: [] }, + { count: 20, people: [] }, + ], + label: 'Week 0', + date: '2022-07-24T00:00:00Z', + }, + { + values: [ + { count: 200, people: [] }, + { count: 50, people: [] }, + { count: 20, people: [] }, + ], + label: 'Week 1', + date: '2022-07-31T00:00:00Z', + }, + { + values: [ + { count: 10, people: [] }, + { count: 0, people: [] }, + ], + label: 'Week 2', + date: '2022-08-07T00:00:00Z', + }, + { + values: [{ count: 0, people: [] }], + label: 'Week 3', + date: '2022-08-14T00:00:00Z', + }, +] + +describe('retentionLineGraphLogic', () => { + let logic: ReturnType + + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/insights/': { results: [result] }, + '/api/projects/:team/insights/retention/': { result }, + }, + }) + }) + + describe('syncs with insightLogic', () => { + const props = { dashboardItemId: Insight123 } + + beforeEach(() => { + initKeaTests() + logic = retentionLineGraphLogic(props) + logic.mount() + }) + + it('returns cohort percentage when retention_reference is total', async () => { + await expectLogic(logic, () => { + insightLogic(props).actions.setFilters({ + insight: InsightType.RETENTION, + period: 'Week', + retention_reference: 'total', + } as RetentionFilterType) + }) + .toFinishAllListeners() + .toMatchValues(logic, { + trendSeries: expect.arrayContaining([ + expect.objectContaining({ data: [100, 25, 18.75, 5] }), + expect.objectContaining({ data: [100, 25, 10] }), + expect.objectContaining({ data: [100, 0] }), + expect.objectContaining({ data: [0] }), + ]), + }) + }) + + it('handles cohort percentage when retention_reference is previous', async () => { + await expectLogic(logic, () => { + insightLogic(props).actions.setFilters({ + insight: InsightType.RETENTION, + period: 'Week', + retention_reference: 'previous', + } as RetentionFilterType) + }) + .toFinishAllListeners() + .toMatchValues(logic, { + trendSeries: expect.arrayContaining([ + expect.objectContaining({ data: [100, 25, 75, 26.666666666666668] }), + expect.objectContaining({ data: [100, 25, 40] }), + expect.objectContaining({ data: [100, 0] }), + expect.objectContaining({ data: [0] }), + ]), + }) + }) + }) +}) diff --git a/frontend/src/scenes/retention/retentionLineGraphLogic.ts b/frontend/src/scenes/retention/retentionLineGraphLogic.ts new file mode 100644 index 00000000000000..eabd3c6de6e8fa --- /dev/null +++ b/frontend/src/scenes/retention/retentionLineGraphLogic.ts @@ -0,0 +1,108 @@ +import { dayjs, QUnitType } from 'lib/dayjs' +import { kea } from 'kea' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { RetentionTrendPayload } from 'scenes/retention/types' +import { InsightLogicProps, RetentionPeriod } from '~/types' +import { dateOptionToTimeIntervalMap } from './constants' + +import { abstractRetentionLogic } from './abstractRetentionLogic' + +import type { retentionLineGraphLogicType } from './retentionLineGraphLogicType' + +const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' + +export const retentionLineGraphLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), + path: (key) => ['scenes', 'retention', 'retentionLineGraphLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [abstractRetentionLogic(props), ['retentionFilter', 'dateRange', 'results']], + }), + + selectors: { + trendSeries: [ + (s) => [s.results, s.retentionFilter], + (results, { period, retention_reference }): RetentionTrendPayload[] => { + // If the retention reference option is specified as previous, + // then translate retention rates to relative to previous, + // otherwise, just use what the result was originally. + // + // Our input results might looks something like + // + // Cohort 1 | 1000 | 120 | 190 | 170 | 140 + // Cohort 2 | 6003 | 300 | 100 | 120 | 50 + // + // If `retentionFilter.retention_reference` is not "previous" + // we want to calculate the percentages of the sizes compared + // to the first value. If we have "previous" we want to go + // further and translate these numbers into percentage of the + // previous value so we get some idea for the rate of + // convergence. + return results.map((cohortRetention, datasetIndex) => { + const retentionPercentages = cohortRetention.values + .map((value) => value.count / cohortRetention.values[0].count) + // Make them display in the right scale + .map((value) => (isNaN(value) ? 0 : 100 * value)) + + // To calculate relative percentages, we take for instance Cohort 1 as percentages + // of the cohort size and create another series that has a 100 at prepended so we have + // + // Cohort 1' | 100 | 12 | 19 | 17 | 14 + // Cohort 1'' | 100 | 100 | 12 | 19 | 17 | 14 + // + // And from here construct a third, relative percentage series by dividing the + // top numbers by the bottom numbers to get + // + // Cohort 1''' | 1 | 0.12 | ... + const paddedValues = [100].concat(retentionPercentages) + + return { + id: datasetIndex, + days: retentionPercentages.map((_, index) => `${period} ${index}`), + labels: retentionPercentages.map((_, index) => `${period} ${index}`), + count: 0, + label: cohortRetention.date + ? period === 'Hour' + ? dayjs(cohortRetention.date).format('MMM D, h A') + : dayjs.utc(cohortRetention.date).format('MMM D') + : cohortRetention.label, + data: + retention_reference === 'previous' + ? retentionPercentages + // Zip together the current a previous values, filling + // in with 100 for the first index + .map((value, index) => [value, paddedValues[index]]) + // map values to percentage of previous + .map(([value, previous]) => (100 * value) / previous) + : retentionPercentages, + index: datasetIndex, + } + }) + }, + ], + + incompletenessOffsetFromEnd: [ + (s) => [s.dateRange, s.retentionFilter, s.trendSeries], + ({ date_to }, { period }, trendSeries) => { + // Returns negative number of points to paint over starting from end of array + if (!trendSeries?.[0]?.days) { + return 0 + } else if (!date_to) { + return -1 + } + const numUnits = trendSeries[0].days.length + const interval = dateOptionToTimeIntervalMap?.[period ?? RetentionPeriod.Day] + const startDate = dayjs().startOf(interval) + const startIndex = trendSeries[0].days.findIndex( + (_, i) => dayjs(date_to).add(i - numUnits, interval as QUnitType) >= startDate + ) + + if (startIndex !== undefined && startIndex !== -1) { + return startIndex - trendSeries[0].days.length + } else { + return 0 + } + }, + ], + }, +}) diff --git a/frontend/src/scenes/retention/retentionLogic.test.ts b/frontend/src/scenes/retention/retentionLogic.test.ts new file mode 100644 index 00000000000000..be23b0387334b4 --- /dev/null +++ b/frontend/src/scenes/retention/retentionLogic.test.ts @@ -0,0 +1,104 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { retentionLogic } from 'scenes/retention/retentionLogic' +import { insightLogic } from 'scenes/insights/insightLogic' +import { InsightShortId, InsightType, RetentionFilterType, RetentionPeriod } from '~/types' +import { useMocks } from '~/mocks/jest' + +const Insight123 = '123' as InsightShortId +const result = [ + { + values: [ + { count: 400, people: [] }, + { count: 100, people: [] }, + { count: 75, people: [] }, + { count: 20, people: [] }, + ], + label: 'Week 0', + date: '2022-07-24T00:00:00Z', + }, + { + values: [ + { count: 200, people: [] }, + { count: 50, people: [] }, + { count: 20, people: [] }, + ], + label: 'Week 1', + date: '2022-07-31T00:00:00Z', + }, + { + values: [ + { count: 10, people: [] }, + { count: 0, people: [] }, + ], + label: 'Week 2', + date: '2022-08-07T00:00:00Z', + }, + { + values: [{ count: 0, people: [] }], + label: 'Week 3', + date: '2022-08-14T00:00:00Z', + }, +] + +describe('retentionLogic', () => { + let logic: ReturnType + + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/insights/retention/': { result }, + }, + }) + }) + + describe('syncs with insightLogic', () => { + const props = { dashboardItemId: Insight123 } + + beforeEach(() => { + initKeaTests() + logic = retentionLogic(props) + logic.mount() + }) + + it('setFilters calls insightLogic.setFilters', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ insight: InsightType.RETENTION, period: RetentionPeriod.Week }) + }) + .toDispatchActions([ + (action) => + action.type === insightLogic(props).actionTypes.setFilters && + action.payload.filters?.period === 'Week', + ]) + .toMatchValues(logic, { + filters: expect.objectContaining({ + period: 'Week', + }), + }) + .toMatchValues(insightLogic(props), { + filters: expect.objectContaining({ + period: 'Week', + }), + }) + }) + + it('insightLogic.setFilters updates filters', async () => { + await expectLogic(logic, () => { + insightLogic(props).actions.setFilters({ + insight: InsightType.RETENTION, + period: RetentionPeriod.Week, + } as RetentionFilterType) + }) + .toMatchValues(logic, { + filters: expect.objectContaining({ + period: 'Week', + }), + }) + .toMatchValues(insightLogic(props), { + filters: expect.objectContaining({ + period: 'Week', + }), + }) + }) + }) +}) diff --git a/frontend/src/scenes/retention/retentionLogic.ts b/frontend/src/scenes/retention/retentionLogic.ts new file mode 100644 index 00000000000000..1528dffbcbb438 --- /dev/null +++ b/frontend/src/scenes/retention/retentionLogic.ts @@ -0,0 +1,43 @@ +import { kea } from 'kea' +import { insightLogic } from 'scenes/insights/insightLogic' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { cleanFilters } from 'scenes/insights/utils/cleanFilters' +import { isRetentionFilter } from 'scenes/insights/sharedUtils' +import { RetentionTablePayload } from 'scenes/retention/types' +import { InsightLogicProps, InsightType, RetentionFilterType } from '~/types' + +import type { retentionLogicType } from './retentionLogicType' + +const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' + +export const retentionLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), + path: (key) => ['scenes', 'retention', 'retentionLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [insightLogic(props), ['filters as inflightFilters', 'insight', 'insightLoading']], + }), + actions: () => ({ + setFilters: (filters: Partial) => ({ filters }), + }), + selectors: { + filters: [ + (s) => [s.inflightFilters], + (inflightFilters): Partial => + inflightFilters && isRetentionFilter(inflightFilters) ? inflightFilters : {}, + ], + results: [ + // Take the insight result, and cast it to `RetentionTablePayload[]` + (s) => [s.insight], + ({ filters, result }): RetentionTablePayload[] => { + return filters?.insight === InsightType.RETENTION ? result ?? [] : [] + }, + ], + resultsLoading: [(s) => [s.insightLoading], (insightLoading) => insightLoading], + }, + listeners: ({ values, props }) => ({ + setFilters: ({ filters }) => { + insightLogic(props).actions.setFilters(cleanFilters({ ...values.filters, ...filters }, values.filters)) + }, + }), +}) diff --git a/frontend/src/scenes/retention/retentionModalLogic.ts b/frontend/src/scenes/retention/retentionModalLogic.ts new file mode 100644 index 00000000000000..7a5448f5682b24 --- /dev/null +++ b/frontend/src/scenes/retention/retentionModalLogic.ts @@ -0,0 +1,46 @@ +import { kea } from 'kea' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { Noun, groupsModel } from '~/models/groupsModel' +import { InsightLogicProps } from '~/types' +import { retentionPeopleLogic } from './retentionPeopleLogic' +import { abstractRetentionLogic } from './abstractRetentionLogic' + +import type { retentionModalLogicType } from './retentionModalLogicType' + +const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' + +export const retentionModalLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), + path: (key) => ['scenes', 'retention', 'retentionModalLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [abstractRetentionLogic(props), ['aggregation'], groupsModel, ['aggregationLabel']], + actions: [retentionPeopleLogic(props), ['loadPeople']], + }), + actions: () => ({ + openModal: (rowIndex: number) => ({ rowIndex }), + closeModal: true, + }), + reducers: { + selectedRow: [ + null as number | null, + { + openModal: (_, { rowIndex }) => rowIndex, + closeModal: () => null, + }, + ], + }, + selectors: { + aggregationTargetLabel: [ + (s) => [s.aggregation, s.aggregationLabel], + ({ aggregation_group_type_index }, aggregationLabel): Noun => { + return aggregationLabel(aggregation_group_type_index) + }, + ], + }, + listeners: ({ actions }) => ({ + openModal: ({ rowIndex }) => { + actions.loadPeople(rowIndex) + }, + }), +}) diff --git a/frontend/src/scenes/retention/retentionPeopleLogic.ts b/frontend/src/scenes/retention/retentionPeopleLogic.ts new file mode 100644 index 00000000000000..b57ae161c9224f --- /dev/null +++ b/frontend/src/scenes/retention/retentionPeopleLogic.ts @@ -0,0 +1,67 @@ +import { kea } from 'kea' +import api from 'lib/api' +import { toParams } from 'lib/utils' +import { insightLogic } from 'scenes/insights/insightLogic' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { RetentionTablePeoplePayload } from 'scenes/retention/types' +import { InsightLogicProps } from '~/types' +import { abstractRetentionLogic } from './abstractRetentionLogic' + +import type { retentionPeopleLogicType } from './retentionPeopleLogicType' + +const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' + +export const retentionPeopleLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), + path: (key) => ['scenes', 'retention', 'retentionPeopleLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [abstractRetentionLogic(props), ['apiFilters']], + actions: [insightLogic(props), ['loadResultsSuccess']], + }), + actions: () => ({ + clearPeople: true, + loadMorePeople: true, + loadMorePeopleSuccess: (payload: RetentionTablePeoplePayload) => ({ payload }), + }), + loaders: ({ values }) => ({ + people: { + __default: {} as RetentionTablePeoplePayload, + loadPeople: async (rowIndex: number) => { + const urlParams = toParams({ ...values.apiFilters, selected_interval: rowIndex }) + return (await api.get(`api/person/retention/?${urlParams}`)) as RetentionTablePeoplePayload + }, + }, + }), + reducers: { + people: { + clearPeople: () => ({}), + loadPeople: () => ({}), + loadMorePeopleSuccess: (_, { payload }) => payload, + }, + peopleLoadingMore: [ + false, + { + loadMorePeople: () => true, + loadMorePeopleSuccess: () => false, + }, + ], + }, + listeners: ({ actions, values }) => ({ + loadResultsSuccess: async () => { + // clear people when changing the insight filters + actions.clearPeople() + }, + loadMorePeople: async () => { + if (values.people.next) { + const peopleResult: RetentionTablePeoplePayload = await api.get(values.people.next) + const newPayload: RetentionTablePeoplePayload = { + result: [...(values.people.result || []), ...(peopleResult.result || [])], + next: peopleResult.next, + missing_persons: (peopleResult.missing_persons || 0) + (values.people.missing_persons || 0), + } + actions.loadMorePeopleSuccess(newPayload) + } + }, + }), +}) diff --git a/frontend/src/scenes/retention/retentionTableLogic.test.ts b/frontend/src/scenes/retention/retentionTableLogic.test.ts index 204552da9e6617..30eb804dcffffb 100644 --- a/frontend/src/scenes/retention/retentionTableLogic.test.ts +++ b/frontend/src/scenes/retention/retentionTableLogic.test.ts @@ -50,11 +50,6 @@ describe('retentionTableLogic', () => { '/api/projects/:team/insights/': { results: [result] }, '/api/projects/:team/insights/retention/': { result }, }, - post: { - '/api/projects/:team/insights/': { results: [result] }, - '/api/projects/:team/insights/:id/viewed': [201], - '/api/projects/:team/insights/cancel': [200], - }, }) }) @@ -67,65 +62,6 @@ describe('retentionTableLogic', () => { logic.mount() }) - it('setFilters calls insightLogic.setFilters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ insight: InsightType.RETENTION, period: 'Week' }) - }) - .toDispatchActions([ - (action) => - action.type === insightLogic(props).actionTypes.setFilters && - action.payload.filters?.period === 'Week', - ]) - .toMatchValues(logic, { - filters: expect.objectContaining({ - period: 'Week', - }), - }) - .toMatchValues(insightLogic(props), { - filters: expect.objectContaining({ - period: 'Week', - }), - }) - }) - - it('insightLogic.setFilters updates filters', async () => { - await expectLogic(logic, () => { - insightLogic(props).actions.setFilters({ - insight: InsightType.RETENTION, - period: 'Week', - } as RetentionFilterType) - }) - .toMatchValues(logic, { - filters: expect.objectContaining({ - period: 'Week', - }), - }) - .toMatchValues(insightLogic(props), { - filters: expect.objectContaining({ - period: 'Week', - }), - }) - }) - - it('handles conversion from cohort percentage to derivative of percentages when retentionReference is previous', async () => { - await expectLogic(logic, () => { - insightLogic(props).actions.setFilters({ - insight: InsightType.RETENTION, - period: 'Week', - } as RetentionFilterType) - logic.actions.setRetentionReference('previous') - }) - .toFinishAllListeners() - .toMatchValues(logic, { - trendSeries: expect.arrayContaining([ - expect.objectContaining({ data: [100, 25, 75, 26.666666666666668] }), - expect.objectContaining({ data: [100, 25, 40] }), - expect.objectContaining({ data: [100, 0] }), - expect.objectContaining({ data: [0] }), - ]), - }) - }) - it('calculates max number of intervals in the results', async () => { await expectLogic(logic, () => { insightLogic(props).actions.setFilters({ diff --git a/frontend/src/scenes/retention/retentionTableLogic.ts b/frontend/src/scenes/retention/retentionTableLogic.ts index 2f60caa9a2636e..ea22a2288569f6 100644 --- a/frontend/src/scenes/retention/retentionTableLogic.ts +++ b/frontend/src/scenes/retention/retentionTableLogic.ts @@ -1,45 +1,12 @@ import { dayjs } from 'lib/dayjs' import { kea } from 'kea' -import api from 'lib/api' -import { RETENTION_FIRST_TIME, RETENTION_RECURRING } from 'lib/constants' -import { range, toParams } from 'lib/utils' -import { insightLogic } from 'scenes/insights/insightLogic' +import { range } from 'lib/utils' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' -import { cleanFilters } from 'scenes/insights/utils/cleanFilters' -import { isRetentionFilter } from 'scenes/insights/sharedUtils' -import { RetentionTablePayload, RetentionTablePeoplePayload, RetentionTrendPayload } from 'scenes/retention/types' -import { actionsModel } from '~/models/actionsModel' -import { Noun, groupsModel } from '~/models/groupsModel' -import { ActionType, InsightLogicProps, InsightType, RetentionFilterType } from '~/types' -import type { retentionTableLogicType } from './retentionTableLogicType' - -export const dateOptions = ['Hour', 'Day', 'Week', 'Month'] - -// https://day.js.org/docs/en/durations/creating#list-of-all-available-units -const dateOptionToTimeIntervalMap = { - Hour: 'h', - Day: 'd', - Week: 'w', - Month: 'M', -} - -export const dateOptionPlurals = { - Hour: 'hours', - Day: 'days', - Week: 'weeks', - Month: 'months', -} +import { InsightLogicProps } from '~/types' -export const retentionOptions = { - [RETENTION_FIRST_TIME]: 'for the first time', - [RETENTION_RECURRING]: 'recurringly', -} +import { abstractRetentionLogic } from './abstractRetentionLogic' -export const retentionOptionDescriptions = { - [`${RETENTION_RECURRING}`]: 'A user will belong to any cohort where they have performed the event in its Period 0.', - [`${RETENTION_FIRST_TIME}`]: - 'A user will only belong to the cohort for which they performed the event for the first time.', -} +import type { retentionTableLogicType } from './retentionTableLogicType' const DEFAULT_RETENTION_LOGIC_KEY = 'default_retention_key' @@ -48,164 +15,9 @@ export const retentionTableLogic = kea({ key: keyForInsightLogicProps(DEFAULT_RETENTION_LOGIC_KEY), path: (key) => ['scenes', 'retention', 'retentionTableLogic', key], connect: (props: InsightLogicProps) => ({ - values: [ - insightLogic(props), - ['filters as inflightFilters', 'insight', 'insightLoading'], - actionsModel, - ['actions'], - groupsModel, - ['aggregationLabel'], - ], - actions: [insightLogic(props), ['loadResultsSuccess']], + values: [abstractRetentionLogic(props), ['retentionFilter', 'breakdown', 'results']], }), - actions: () => ({ - setFilters: (filters: Partial) => ({ filters }), - setRetentionReference: (retentionReference: RetentionFilterType['retention_reference']) => ({ - retentionReference, - }), - loadMorePeople: true, - updatePeople: (people: RetentionTablePeoplePayload) => ({ people }), - clearPeople: true, - }), - loaders: ({ values }) => ({ - people: { - __default: {} as RetentionTablePeoplePayload, - loadPeople: async (rowIndex: number) => { - const urlParams = toParams({ ...values.filters, selected_interval: rowIndex }) - return (await api.get(`api/person/retention/?${urlParams}`)) as RetentionTablePeoplePayload - }, - }, - }), - reducers: { - people: { - clearPeople: () => ({}), - updatePeople: (_, { people }) => people, - }, - loadingMore: [ - false, - { - loadMorePeople: () => true, - updatePeople: () => false, - }, - ], - }, selectors: { - filters: [ - (s) => [s.inflightFilters], - (inflightFilters): Partial => - inflightFilters && isRetentionFilter(inflightFilters) ? inflightFilters : {}, - ], - loadedFilters: [ - (s) => [s.insight], - ({ filters }): Partial => (filters && isRetentionFilter(filters) ? filters : {}), - ], - results: [ - // Take the insight result, and cast it to `RetentionTablePayload[]` - (s) => [s.insight], - ({ filters, result }): RetentionTablePayload[] => { - return filters?.insight === InsightType.RETENTION ? result ?? [] : [] - }, - ], - trendSeries: [ - (s) => [s.results, s.filters, s.retentionReference], - (results, filters, retentionReference): RetentionTrendPayload[] => { - // If the retention reference option is specified as previous, - // then translate retention rates to relative to previous, - // otherwise, just use what the result was originally. - // - // Our input results might looks something like - // - // Cohort 1 | 1000 | 120 | 190 | 170 | 140 - // Cohort 2 | 6003 | 300 | 100 | 120 | 50 - // - // If `retentionReference` is not "previous" we want to calculate the percentages - // of the sizes compared to the first value. If we have "previous" we want to - // go further and translate thhese numbers into percentage of the previous value - // so we get some idea for the rate of convergence. - - return results.map((cohortRetention, datasetIndex) => { - const retentionPercentages = cohortRetention.values - .map((value) => value.count / cohortRetention.values[0].count) - // Make them display in the right scale - .map((value) => (isNaN(value) ? 0 : 100 * value)) - - // To calculate relative percentages, we take for instance Cohort 1 as percentages - // of the cohort size and create another series that has a 100 at prepended so we have - // - // Cohort 1' | 100 | 12 | 19 | 17 | 14 - // Cohort 1'' | 100 | 100 | 12 | 19 | 17 | 14 - // - // And from here construct a third, relative percentage series by dividing the - // top numbers by the bottom numbers to get - // - // Cohort 1''' | 1 | 0.12 | ... - const paddedValues = [100].concat(retentionPercentages) - - return { - id: datasetIndex, - days: retentionPercentages.map((_, index) => `${filters.period} ${index}`), - labels: retentionPercentages.map((_, index) => `${filters.period} ${index}`), - count: 0, - label: cohortRetention.date - ? filters.period === 'Hour' - ? dayjs(cohortRetention.date).format('MMM D, h A') - : dayjs.utc(cohortRetention.date).format('MMM D') - : cohortRetention.label, - data: - retentionReference === 'previous' - ? retentionPercentages - // Zip together the current a previous values, filling - // in with 100 for the first index - .map((value, index) => [value, paddedValues[index]]) - // map values to percentage of previous - .map(([value, previous]) => (100 * value) / previous) - : retentionPercentages, - index: datasetIndex, - } - }) - }, - ], - resultsLoading: [(s) => [s.insightLoading], (insightLoading) => insightLoading], - actionsLookup: [ - (s) => [s.actions], - (actions: ActionType[]) => Object.assign({}, ...actions.map((action) => ({ [action.id]: action.name }))), - ], - actionFilterTargetEntity: [(s) => [s.filters], (filters) => ({ events: [filters.target_entity] })], - actionFilterReturningEntity: [(s) => [s.filters], (filters) => ({ events: [filters.returning_entity] })], - retentionReference: [ - (selectors) => [selectors.filters], - ({ retention_reference }) => retention_reference ?? 'total', - ], - aggregationTargetLabel: [ - (s) => [s.filters, s.aggregationLabel], - (filters, aggregationLabel): Noun => { - return aggregationLabel(filters.aggregation_group_type_index) - }, - ], - incompletenessOffsetFromEnd: [ - (s) => [s.filters, s.trendSeries], - (filters, trendSeries) => { - // Returns negative number of points to paint over starting from end of array - if (!trendSeries?.[0]?.days) { - return 0 - } else if (!filters?.date_to) { - return -1 - } - const numUnits = trendSeries[0].days.length - const interval = dateOptionToTimeIntervalMap?.[filters.period ?? 'Day'] - const startDate = dayjs().startOf(interval) - const startIndex = trendSeries[0].days.findIndex( - (_, i) => dayjs(filters?.date_to).add(i - numUnits, interval) >= startDate - ) - - if (startIndex !== undefined && startIndex !== -1) { - return startIndex - trendSeries[0].days.length - } else { - return 0 - } - }, - ], - maxIntervalsCount: [ (s) => [s.results], (results) => { @@ -221,8 +33,8 @@ export const retentionTableLogic = kea({ ], tableRows: [ - (s) => [s.results, s.maxIntervalsCount, s.filters], - (results, maxIntervalsCount, { period, breakdowns }) => { + (s) => [s.results, s.maxIntervalsCount, s.retentionFilter, s.breakdown], + (results, maxIntervalsCount, { period }, { breakdowns }) => { return range(maxIntervalsCount).map((rowIndex: number) => [ // First column is the cohort label breakdowns?.length @@ -248,34 +60,4 @@ export const retentionTableLogic = kea({ }, ], }, - listeners: ({ actions, values, props }) => ({ - setProperties: ({ properties }) => { - insightLogic(props).actions.setFilters(cleanFilters({ ...values.filters, properties }, values.filters)) - }, - setFilters: ({ filters }) => { - insightLogic(props).actions.setFilters(cleanFilters({ ...values.filters, ...filters }, values.filters)) - }, - setRetentionReference: ({ retentionReference }) => { - actions.setFilters({ - ...values.filters, - // NOTE: we use lower case here to accommodate the expected - // casing of the server - retention_reference: retentionReference, - }) - }, - loadResultsSuccess: async () => { - actions.clearPeople() - }, - loadMorePeople: async () => { - if (values.people.next) { - const peopleResult: RetentionTablePeoplePayload = await api.get(values.people.next) - const newPeople: RetentionTablePeoplePayload = { - result: [...(values.people.result || []), ...(peopleResult.result || [])], - next: peopleResult.next, - missing_persons: (peopleResult.missing_persons || 0) + (values.people.missing_persons || 0), - } - actions.updatePeople(newPeople) - } - }, - }), }) diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 52ce6437e8802d..86f9ee4a67390b 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -15,7 +15,6 @@ import { IconBarChart, IconCoffee, IconEvent, - IconInternetExplorer, IconPerson, IconQuestionAnswer, IconSelectEvents, @@ -207,12 +206,6 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconCoffee, inMenu: true, }, - [NodeKind.UnimplementedQuery]: { - name: 'An unimplemented query', - description: 'A query that has not yet been implemented', - icon: IconInternetExplorer, - inMenu: true, - }, } export const INSIGHT_TYPE_OPTIONS: LemonSelectOptions = [ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index abb18551d21ec3..ccdd7da7cb6bcf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1431,6 +1431,13 @@ export enum FunnelVizType { export type RetentionType = typeof RETENTION_RECURRING | typeof RETENTION_FIRST_TIME +export enum RetentionPeriod { + Hour = 'Hour', + Day = 'Day', + Week = 'Week', + Month = 'Month', +} + export type BreakdownKeyType = string | number | (string | number)[] | null export interface Breakdown { @@ -1555,7 +1562,7 @@ export interface RetentionFilterType extends FilterType { total_intervals?: number // retention total intervals returning_entity?: Record target_entity?: Record - period?: string + period?: RetentionPeriod } export interface LifecycleFilterType extends FilterType { shown_as?: ShownAsValue @@ -1846,7 +1853,7 @@ export interface ChartParams { showPersonsModal?: boolean } -// Shared between insightLogic, dashboardItemLogic, trendsLogic, funnelLogic, pathsLogic, retentionTableLogic +// Shared between insightLogic, dashboardItemLogic, trendsLogic, funnelLogic, pathsLogic, retentionLogic export interface InsightLogicProps { /** currently persisted insight */ dashboardItemId?: InsightShortId | 'new' | `new-${string}` | null