Skip to content

Commit

Permalink
feat(insights): Compare to arbitrary prior periods of time (#22397)
Browse files Browse the repository at this point in the history
  • Loading branch information
aspicer authored Jun 13, 2024
1 parent dcc3d78 commit 5e0521e
Show file tree
Hide file tree
Showing 80 changed files with 1,030 additions and 176 deletions.
6 changes: 3 additions & 3 deletions cypress/e2e/insights-date-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ describe('insights date picker', () => {

it('Can set a custom rolling date range', () => {
cy.get('[data-attr=date-filter]').click()
cy.get('[data-attr=rolling-date-range-input]').type('{selectall}5{enter}')
cy.get('[data-attr=rolling-date-range-date-options-selector]').click()
cy.get('.Popover [data-attr=rolling-date-range-input]').type('{selectall}5{enter}')
cy.get('.Popover [data-attr=rolling-date-range-date-options-selector]').click()
cy.get('.RollingDateRangeFilter__popover > div').contains('days').should('exist').click()
cy.get('.RollingDateRangeFilter__label').should('contain', 'In the last').click()
cy.get('.Popover .RollingDateRangeFilter__label').should('contain', 'In the last').click()

// Test that the button shows the correct formatted range
cy.get('[data-attr=date-filter]').get('.LemonButton__content').contains('Last 5 days').should('exist')
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-app-insights--stickiness--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-app-insights--stickiness--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface ActivityLogPaginatedResponse<T> extends PaginatedResponse<T> {
export interface ApiMethodOptions {
signal?: AbortSignal
headers?: Record<string, any>
async?: boolean
}

export class ApiError extends Error {
Expand Down
83 changes: 74 additions & 9 deletions frontend/src/lib/components/CompareFilter/CompareFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,94 @@
import { LemonCheckbox } from '@posthog/lemon-ui'
import { LemonSelect } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { RollingDateRangeFilter } from 'lib/components/DateFilter/RollingDateRangeFilter'
import { dateFromToText } from 'lib/utils'
import { useEffect, useState } from 'react'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'

export function CompareFilter(): JSX.Element | null {
const { insightProps, canEditInsight } = useValues(insightLogic)

const { compare, supportsCompare } = useValues(insightVizDataLogic(insightProps))
const { updateInsightFilter } = useActions(insightVizDataLogic(insightProps))
const { compareFilter, supportsCompare } = useValues(insightVizDataLogic(insightProps))
const { updateCompareFilter } = useActions(insightVizDataLogic(insightProps))

// This keeps the state of the rolling date range filter, even when different drop down options are selected
// The default value for this is one month
const [tentativeCompareTo, setTentativeCompareTo] = useState<string>(compareFilter?.compare_to || '-1m')

const disabled: boolean = !canEditInsight || !supportsCompare

useEffect(() => {
const newCompareTo = compareFilter?.compare_to
if (!!newCompareTo && tentativeCompareTo != newCompareTo) {
setTentativeCompareTo(newCompareTo)
}
}, [compareFilter?.compare_to])

// Hide compare filter control when disabled to avoid states where control is "disabled but checked"
if (disabled) {
return null
}

const options = [
{
value: 'none',
label: 'No comparison between periods',
},
{
value: 'previous',
label: 'Compare to previous period',
},
{
value: 'compareTo',
label: (
<RollingDateRangeFilter
isButton={false}
dateRangeFilterLabel="Compare to "
dateRangeFilterSuffixLabel=" earlier"
dateFrom={tentativeCompareTo}
selected={!!compareFilter?.compare && !!compareFilter?.compare_to}
inUse={true}
onChange={(compare_to) => {
updateCompareFilter({ compare: true, compare_to })
}}
/>
),
},
]

let value = 'none'
if (compareFilter?.compare) {
if (compareFilter?.compare_to) {
value = 'compareTo'
} else {
value = 'previous'
}
}

return (
<LemonCheckbox
onChange={(compare: boolean) => {
updateInsightFilter({ compare })
<LemonSelect
onSelect={(newValue) => {
if (newValue == 'compareTo') {
updateCompareFilter({ compare: true, compare_to: tentativeCompareTo })
}
}}
renderButtonContent={(leaf) =>
(leaf?.value == 'compareTo'
? `Compare to ${dateFromToText(tentativeCompareTo)} earlier`
: leaf?.label) || 'Compare to'
}
value={value}
dropdownMatchSelectWidth={false}
onChange={(value) => {
if (value == 'none') {
updateCompareFilter({ compare: false, compare_to: undefined })
} else if (value == 'previous') {
updateCompareFilter({ compare: true, compare_to: undefined })
}
}}
checked={!!compare}
label={<span className="font-normal">Compare to previous period</span>}
bordered
data-attr="compare-filter"
options={options}
size="small"
/>
)
Expand Down
124 changes: 76 additions & 48 deletions frontend/src/lib/components/DateFilter/RollingDateRangeFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ const dateOptions: LemonSelectOptionLeaf<DateOption>[] = [
]

type RollingDateRangeFilterProps = {
isButton?: boolean
pageKey?: string
/** specifies if the filter is selected in the dropdown (to darken) */
selected?: boolean
/** specifies if the filter is in use (causes it to read props) */
inUse?: boolean
dateFrom?: string | null | dayjs.Dayjs
max?: number | null
onChange?: (fromDate: string) => void
Expand All @@ -27,80 +31,104 @@ type RollingDateRangeFilterProps = {
ref?: React.MutableRefObject<HTMLDivElement | null>
}
dateRangeFilterLabel?: string
dateRangeFilterSuffixLabel?: string
allowedDateOptions?: DateOption[]
fullWidth?: LemonButtonProps['fullWidth']
}

export function RollingDateRangeFilter({
isButton = true,
onChange,
makeLabel,
popover,
dateFrom,
selected,
inUse,
max,
dateRangeFilterLabel = 'In the last',
dateRangeFilterSuffixLabel,
pageKey,
allowedDateOptions = ['days', 'weeks', 'months', 'years'],
fullWidth,
}: RollingDateRangeFilterProps): JSX.Element {
const logicProps = { onChange, dateFrom, selected, max, pageKey }
const logicProps = { onChange, dateFrom, inUse: selected || inUse, max, pageKey }
const { increaseCounter, decreaseCounter, setCounter, setDateOption, toggleDateOptionsSelector, select } =
useActions(rollingDateRangeFilterLogic(logicProps))
const { counter, dateOption, formattedDate, startOfDateRange } = useValues(rollingDateRangeFilterLogic(logicProps))

return (
<Tooltip title={makeLabel ? makeLabel(formattedDate, startOfDateRange) : undefined}>
let contents = (
<div className="flex items-center">
<p className="RollingDateRangeFilter__label">{dateRangeFilterLabel}</p>
<div className="RollingDateRangeFilter__counter" onClick={(e): void => e.stopPropagation()}>
<span
className="RollingDateRangeFilter__counter__step cursor-pointer"
// eslint-disable-next-line react/forbid-dom-props
style={{ background: 'none' }}
onClick={decreaseCounter}
title="Decrease rolling date range"
>
-
</span>
<LemonInput
data-attr="rolling-date-range-input"
className="[&>input::-webkit-inner-spin-button]:appearance-none"
type="number"
value={counter ?? 0}
min={0}
placeholder="0"
onChange={(value) => setCounter(value)}
/>
<span
className="RollingDateRangeFilter__counter__step cursor-pointer"
// eslint-disable-next-line react/forbid-dom-props
style={{ background: 'none' }}
onClick={increaseCounter}
title="Increase rolling date range"
>
+
</span>
</div>
<LemonSelect
className="RollingDateRangeFilter__select"
data-attr="rolling-date-range-date-options-selector"
id="rolling-date-range-date-options-selector"
value={dateOption}
onChange={(newValue): void => setDateOption(newValue)}
onClick={(e): void => {
e.stopPropagation()
toggleDateOptionsSelector()
}}
dropdownMatchSelectWidth={false}
options={dateOptions.filter((option) => allowedDateOptions.includes(option.value))}
menu={{
...popover,
className: 'RollingDateRangeFilter__popover',
}}
size="xsmall"
/>
{dateRangeFilterSuffixLabel ? (
<p className="RollingDateRangeFilter__label ml-1"> {dateRangeFilterSuffixLabel}</p>
) : null}
</div>
)

if (isButton) {
contents = (
<LemonButton
className="RollingDateRangeFilter"
data-attr="rolling-date-range-filter"
onClick={select}
active={selected}
fullWidth={fullWidth}
>
<p className="RollingDateRangeFilter__label">{dateRangeFilterLabel}</p>
<div className="RollingDateRangeFilter__counter" onClick={(e): void => e.stopPropagation()}>
<span
className="RollingDateRangeFilter__counter__step"
onClick={decreaseCounter}
title="Decrease rolling date range"
>
-
</span>
<LemonInput
data-attr="rolling-date-range-input"
type="number"
value={counter ?? 0}
min={0}
placeholder="0"
onChange={(value) => setCounter(value)}
/>
<span
className="RollingDateRangeFilter__counter__step"
onClick={increaseCounter}
title="Increase rolling date range"
>
+
</span>
</div>
<LemonSelect
className="RollingDateRangeFilter__select"
data-attr="rolling-date-range-date-options-selector"
id="rolling-date-range-date-options-selector"
value={dateOption}
onChange={(newValue): void => setDateOption(newValue)}
onClick={(e): void => {
e.stopPropagation()
toggleDateOptionsSelector()
}}
dropdownMatchSelectWidth={false}
options={dateOptions.filter((option) => allowedDateOptions.includes(option.value))}
menu={{
...popover,
className: 'RollingDateRangeFilter__popover',
}}
size="xsmall"
/>
{contents}
</LemonButton>
</Tooltip>
)
)
}

if (makeLabel) {
contents = <Tooltip title={makeLabel(formattedDate, startOfDateRange)}>{contents}</Tooltip>
}

return contents
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ const dateOptionsMap = {
export type DateOption = (typeof dateOptionsMap)[keyof typeof dateOptionsMap]

export type RollingDateFilterLogicPropsType = {
selected?: boolean
inUse?: boolean
onChange?: (fromDate: string) => void
dateFrom?: Dayjs | string | null
max?: number | null
pageKey?: string
}

const counterDefault = (selected: boolean | undefined, dateFrom: Dayjs | string | null | undefined): number => {
if (selected && dateFrom && typeof dateFrom === 'string') {
const counterDefault = (inUse: boolean | undefined, dateFrom: Dayjs | string | null | undefined): number => {
if (inUse && dateFrom && typeof dateFrom === 'string') {
const counter = parseInt(dateFrom.slice(1, -1))
if (counter) {
return counter
Expand All @@ -35,8 +35,8 @@ const counterDefault = (selected: boolean | undefined, dateFrom: Dayjs | string
return 3
}

const dateOptionDefault = (selected: boolean | undefined, dateFrom: Dayjs | string | null | undefined): DateOption => {
if (selected && dateFrom && typeof dateFrom === 'string') {
const dateOptionDefault = (inUse: boolean | undefined, dateFrom: Dayjs | string | null | undefined): DateOption => {
if (inUse && dateFrom && typeof dateFrom === 'string') {
const dateOption = dateOptionsMap[dateFrom.slice(-1)]
if (dateOption) {
return dateOption
Expand All @@ -59,7 +59,7 @@ export const rollingDateRangeFilterLogic = kea<rollingDateRangeFilterLogicType>(
}),
reducers(({ props }) => ({
counter: [
counterDefault(props.selected, props.dateFrom) as number | null,
counterDefault(props.inUse, props.dateFrom) as number | null,
{
increaseCounter: (state) => (state ? (!props.max || state < props.max ? state + 1 : state) : 1),
decreaseCounter: (state) => {
Expand All @@ -73,7 +73,7 @@ export const rollingDateRangeFilterLogic = kea<rollingDateRangeFilterLogicType>(
},
],
dateOption: [
dateOptionDefault(props.selected, props.dateFrom),
dateOptionDefault(props.inUse, props.dateFrom),
{
setDateOption: (_, { option }) => option,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export function InsightLegendRow({ rowIndex, item }: InsightLegendRowProps): JSX

const { insightProps, hiddenLegendKeys, highlightedSeries } = useValues(insightLogic)
const { toggleVisibility } = useActions(insightLogic)
const { compare, display, trendsFilter, breakdownFilter, isSingleSeries } = useValues(trendsDataLogic(insightProps))
const { display, trendsFilter, compareFilter, breakdownFilter, isSingleSeries } = useValues(
trendsDataLogic(insightProps)
)
const compare = compareFilter && !!compareFilter.compare

const highlighted = shouldHighlightThisRow(hiddenLegendKeys, rowIndex, highlightedSeries)
const highlightStyle: Record<string, any> = highlighted
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,16 @@ export function dateFilterToText(
return defaultValue
}

// Converts a dateFrom string ("-2w") into english: "2 weeks"
export function dateFromToText(dateFrom: string): string | undefined {
const dateOption: (typeof dateOptionsMap)[keyof typeof dateOptionsMap] = dateOptionsMap[dateFrom.slice(-1)]
const counter = parseInt(dateFrom.slice(1, -1))
if (dateOption && counter) {
return `${counter} ${dateOption}${counter > 1 ? 's' : ''}`
}
return undefined
}

export function dateStringToComponents(date: string | null): {
amount: number
unit: (typeof dateOptionsMap)[keyof typeof dateOptionsMap]
Expand Down
Loading

0 comments on commit 5e0521e

Please sign in to comment.