feat: date filter experiment (#10462)

Co-authored-by: Ben White <ben@benjackwhite.co.uk>
This commit is contained in:
Emanuele Capparelli
2022-07-04 12:46:12 +01:00
committed by GitHub
parent 8ee6c02af8
commit dfc04afa08
39 changed files with 1236 additions and 165 deletions

View File

@@ -137,4 +137,25 @@ describe('Insights', () => {
cy.get('.insight-description').should('not.exist')
cy.get('[data-attr=insight-tags]').should('not.exist')
})
describe('insights date picker', () => {
it('Can set the date filter and show the right grouping interval', () => {
cy.get('[data-attr=date-filter]').click()
cy.get('div').contains('Yesterday').should('exist').click()
cy.get('[data-attr=interval-filter]').should('contain', 'Hour')
})
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('.RollingDateRangeFilter__popup > div').contains('days').should('exist').click()
cy.get('[data-attr=rolling-date-range-filter] > .RollingDateRangeFilter__label')
.should('contain', 'In the last')
.click()
// Test that the button shows the correct formatted range
cy.get('[data-attr=date-filter]').get('span').contains('Last 5 days').should('exist')
})
})
})

View File

@@ -130,9 +130,7 @@ describe('Trends', () => {
it('Apply date filter', () => {
cy.get('[data-attr=date-filter]').click()
cy.contains('Last 30 days').click()
cy.get('.ant-select-item').contains('Last 30 days')
cy.get('div').contains('Yesterday').should('exist').click()
cy.get('[data-attr=trend-line-graph]', { timeout: 10000 }).should('exist')
})

View File

@@ -19,6 +19,7 @@ beforeEach(() => {
req.reply(
decideResponse({
'toolbar-launch-side-action': true,
'date-filter-experiment': 'test',
})
)
)

View File

@@ -0,0 +1,3 @@
.DateFilter {
padding: 0.5rem;
}

View File

@@ -1,31 +1,42 @@
import React, { useMemo, useState } from 'react'
import React, { useRef, useMemo, useState } from 'react'
import { Select } from 'antd'
import { SelectProps } from 'antd/lib/select'
import { dateMapping, isDate, dateFilterToText } from 'lib/utils'
import { dateMapping, dateMappingExperiment, isDate, dateFilterToText, uuid } from 'lib/utils'
import { DateFilterRange } from 'lib/components/DateFilter/DateFilterRange'
import { dayjs } from 'lib/dayjs'
import { DateFilterRangeExperiment } from 'lib/components/DateFilter/DateFilterRangeExperiment'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { dateMappingOption } from '~/types'
import { dayjs } from 'lib/dayjs'
import './DateFilter.scss'
import { Tooltip } from 'lib/components/Tooltip'
import { dateFilterLogic } from './dateFilterLogic'
import { RollingDateRangeFilter } from './RollingDateRangeFilter'
import { useActions, useValues } from 'kea'
import { LemonButtonWithPopup, LemonDivider, LemonButton } from '@posthog/lemon-ui'
import { CalendarOutlined } from '@ant-design/icons'
import { FEATURE_FLAGS } from 'lib/constants'
export interface DateFilterProps {
defaultValue: string
showCustom?: boolean
bordered?: boolean
bordered?: boolean // remove if experiment is successful
showRollingRangePicker?: boolean // experimental
makeLabel?: (key: React.ReactNode) => React.ReactNode
className?: string
style?: React.CSSProperties
onChange?: (fromDate: string, toDate: string) => void
disabled?: boolean
getPopupContainer?: (props: any) => HTMLElement
dateOptions?: Record<string, dateMappingOption>
getPopupContainer?: () => HTMLElement
dateOptions?: dateMappingOption[]
isDateFormatted?: boolean
selectProps?: SelectProps<any>
selectProps?: SelectProps<any> // remove if experiment is successful
}
interface RawDateFilterProps extends DateFilterProps {
dateFrom?: string | null | dayjs.Dayjs
dateTo?: string | null | dayjs.Dayjs
}
export function DateFilter({
function _DateFilter({
bordered,
defaultValue,
showCustom,
@@ -63,7 +74,10 @@ export function DateFilter({
setDateRangeOpen(true)
}
} else {
setDate(dateOptions[v].values[0], dateOptions[v].values[1])
const option = dateOptions.find((option) => !option.inactive && option.key === v)
if (option) {
setDate(option.values[0], option.values[1])
}
}
}
@@ -104,7 +118,7 @@ export function DateFilter({
bordered={bordered}
id="daterange_selector"
value={
isDateFormatted && !(currKey in dateOptions)
isDateFormatted && !dateOptions.find((option) => option.key === currKey)
? dateFilterToText(dateFrom, dateTo, defaultValue, dateOptions, true)
: currKey
}
@@ -140,7 +154,7 @@ export function DateFilter({
{...selectProps}
>
{[
...Object.entries(dateOptions).map(([key, { values, inactive }]) => {
...dateOptions.map(({ key, values, inactive }) => {
if (key === 'Custom' && !showCustom) {
return null
}
@@ -165,3 +179,139 @@ export function DateFilter({
</Select>
)
}
function DateFilterExperiment({
defaultValue,
showCustom,
showRollingRangePicker = true,
style,
className,
disabled,
makeLabel,
onChange,
getPopupContainer,
dateFrom,
dateTo,
dateOptions = dateMappingExperiment,
isDateFormatted = true,
}: RawDateFilterProps): JSX.Element {
const key = useRef(uuid()).current
const logicProps = { key, dateFrom, dateTo, onChange, defaultValue, dateOptions, isDateFormatted }
const { open, openDateRange, close, setRangeDateFrom, setRangeDateTo, setDate } = useActions(
dateFilterLogic(logicProps)
)
const { isOpen, isDateRangeOpen, rangeDateFrom, rangeDateTo, value, isFixedDateRange, isRollingDateRange } =
useValues(dateFilterLogic(logicProps))
const optionsRef = useRef<HTMLDivElement | null>(null)
const rollingDateRangeRef = useRef<HTMLDivElement | null>(null)
function dropdownOnClick(e: React.MouseEvent): void {
e.preventDefault()
open()
document.getElementById('daterange_selector')?.focus()
}
function onApplyClick(): void {
close()
const formattedRangeDateFrom = dayjs(rangeDateFrom).format('YYYY-MM-DD')
const formattedRangeDateTo = dayjs(rangeDateTo).format('YYYY-MM-DD')
setDate(formattedRangeDateFrom, formattedRangeDateTo)
}
const popupOverlay = isDateRangeOpen ? (
<DateFilterRangeExperiment
getPopupContainer={getPopupContainer}
onClick={dropdownOnClick}
onDateFromChange={(date) => setRangeDateFrom(date)}
onDateToChange={(date) => setRangeDateTo(date)}
onApplyClick={onApplyClick}
onClickOutside={close}
rangeDateFrom={rangeDateFrom}
rangeDateTo={rangeDateTo}
disableBeforeYear={2015}
/>
) : (
<div ref={optionsRef} className="DateFilter" onClick={(e) => e.stopPropagation()}>
{dateOptions.map(({ key, values, inactive }) => {
if (key === 'Custom' && !showCustom) {
return null
}
if (inactive && value !== key) {
return null
}
const isHighlighted = dateFrom === values[0] && dateTo === values[1]
const dateValue = dateFilterToText(values[0], values[1], defaultValue, dateOptions, isDateFormatted)
return (
<Tooltip key={key} title={makeLabel ? makeLabel(dateValue) : undefined}>
<LemonButton
key={key}
onClick={() => {
setDate(values[0], values[1])
close()
}}
type={isHighlighted ? 'highlighted' : 'stealth'}
fullWidth
>
{key}
</LemonButton>
</Tooltip>
)
})}
{showRollingRangePicker && (
<RollingDateRangeFilter
dateFrom={dateFrom}
selected={isRollingDateRange}
onChange={(fromDate) => {
setDate(fromDate, '')
close()
}}
makeLabel={makeLabel}
popup={{
ref: rollingDateRangeRef,
}}
/>
)}
<LemonDivider />
<LemonButton onClick={openDateRange} type={isFixedDateRange ? 'highlighted' : 'stealth'} fullWidth>
{'Custom fixed time period'}
</LemonButton>
</div>
)
return (
<LemonButtonWithPopup
data-attr="date-filter"
id="daterange_selector"
onClick={isOpen ? close : open}
value={value}
disabled={disabled}
className={className}
style={{ ...style, border: '1px solid var(--border)' }} //TODO this is a css hack, so that this button aligns with others on the page which are still on antd
size={'small'}
type={'stealth'}
popup={{
onClickOutside: close,
visible: isOpen || isDateRangeOpen,
overlay: popupOverlay,
placement: 'bottom-start',
actionable: true,
closeOnClickInside: false,
additionalRefs: [rollingDateRangeRef, '.datefilter-datepicker'],
getPopupContainer,
}}
icon={<CalendarOutlined />}
>
{value}
</LemonButtonWithPopup>
)
}
export function DateFilter(props: RawDateFilterProps): JSX.Element {
const { featureFlags } = useValues(featureFlagLogic)
const experimentEnabled = featureFlags[FEATURE_FLAGS.DATE_FILTER_EXPERIMENT] === 'test'
return experimentEnabled ? <DateFilterExperiment {...props} /> : <_DateFilter {...props} />
}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { Button } from 'antd'
import { dayjs } from 'lib/dayjs'
import { DatePicker } from '../DatePicker'

View File

@@ -0,0 +1,11 @@
.DateFilterRange__calendartoday {
display: inline-block;
position: relative;
}
.DateFilterRange__calendartoday::before {
content: '' !important;
top: 0.5rem !important;
border: 0 !important;
font-size: 0.625em;
}

View File

@@ -0,0 +1,110 @@
import React, { useState, useRef } from 'react'
import { Button } from 'antd'
import './DateFilterRangeExperiment.scss'
import { dayjs } from 'lib/dayjs'
import { DatePicker } from '../DatePicker'
import clsx from 'clsx'
import { useOutsideClickHandler } from 'lib/hooks/useOutsideClickHandler'
export function DateFilterRangeExperiment(props: {
onClickOutside: () => void
onClick: (e: React.MouseEvent) => void
onDateFromChange: (date?: dayjs.Dayjs) => void
onDateToChange: (date?: dayjs.Dayjs) => void
onApplyClick: () => void
rangeDateFrom?: string | dayjs.Dayjs | null
rangeDateTo?: string | dayjs.Dayjs | null
getPopupContainer?: (props: any) => HTMLElement
disableBeforeYear?: number
}): JSX.Element {
const dropdownRef = useRef<HTMLDivElement | null>(null)
const [calendarOpen, setCalendarOpen] = useState(true)
useOutsideClickHandler(
'.datefilter-datepicker',
() => {
if (calendarOpen) {
setCalendarOpen(false)
}
},
[calendarOpen],
['INPUT']
)
return (
<div ref={dropdownRef}>
<a
style={{
margin: '0 1rem',
color: 'rgba(0, 0, 0, 0.2)',
fontWeight: 700,
}}
href="#"
onClick={props.onClick}
>
&lt;
</a>
<hr style={{ margin: '0.5rem 0' }} />
<div style={{ padding: '0 1rem' }}>
<label className="secondary">From date</label>
<br />
<DatePicker.RangePicker
dropdownClassName="datefilter-datepicker"
getPopupContainer={props.getPopupContainer}
defaultValue={[
props.rangeDateFrom
? dayjs.isDayjs(props.rangeDateFrom)
? props.rangeDateFrom
: dayjs(props.rangeDateFrom)
: null,
props.rangeDateTo
? dayjs.isDayjs(props.rangeDateTo)
? props.rangeDateTo
: dayjs(props.rangeDateTo)
: null,
]}
open={calendarOpen}
onOpenChange={(open) => {
if (open) {
setCalendarOpen(open)
}
}}
onChange={(dates) => {
if (dates && dates.length === 2) {
props.onDateFromChange(dates[0] || undefined)
props.onDateToChange(dates[1] || undefined)
setCalendarOpen(false)
}
}}
popupStyle={{ zIndex: 999999 }}
disabledDate={(date) =>
(!!props.disableBeforeYear && date.year() < props.disableBeforeYear) || date.isAfter(dayjs())
}
dateRender={(current, today) => {
return (
<div
className={clsx('ant-picker-cell-inner', {
['DateFilterRange__calendartoday']:
current.date() === today.date() &&
current.month() === today.month() &&
current.year() === today.year(),
})}
>
{current.date()}
</div>
)
}}
/>
<br />
<Button
type="default"
disabled={!props.rangeDateTo || !props.rangeDateFrom}
style={{ marginTop: '1rem', marginBottom: '1rem' }}
onClick={props.onApplyClick}
>
Apply filter
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
.RollingDateRangeFilter {
display: flex;
min-height: 2rem;
height: 2rem;
padding: 1.25rem 0.5rem;
align-items: center;
color: var(--text-default);
font-weight: normal;
font-size: 0.875rem;
line-height: 1.375em;
cursor: pointer;
transition: background 0.3s ease;
&:hover {
background-color: var(--bg-mid);
}
&.RollingDateRangeFilter--selected {
background: var(--primary-bg-hover);
color: var(--text-default);
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
}
.RollingDateRangeFilter__label {
flex-shrink: 0;
margin: 0 auto;
}
.RollingDateRangeFilter__counter {
display: flex;
margin: 0;
height: 2rem;
border: 1px solid var(--border-light);
border-radius: 0.25rem;
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
margin-right: 0.25rem;
margin-left: 0.25rem;
line-height: 1.25rem;
input {
width: 3rem;
text-align: center;
}
}
.RollingDateRangeFilter__select {
width: 6rem;
}
.RollingDateRangeFilter__popup {
z-index: 9999;
}

View File

@@ -0,0 +1,116 @@
import React from 'react'
import { Input } from 'antd'
import { rollingDateRangeFilterLogic } from './rollingDateRangeFilterLogic'
import { useActions, useValues } from 'kea'
import { LemonButton, LemonSelect, LemonSelectOptions } from '@posthog/lemon-ui'
import { Tooltip } from 'lib/components/Tooltip'
import { dayjs } from 'lib/dayjs'
import clsx from 'clsx'
import './RollingDateRangeFilter.scss'
const dateOptions: LemonSelectOptions = {
days: {
label: 'days',
},
weeks: {
label: 'weeks',
},
months: {
label: 'months',
},
quarter: {
label: 'quarters',
},
}
type RollingDateRangeFilterProps = {
selected?: boolean
dateFrom?: string | null | dayjs.Dayjs
onChange?: (fromDate: string) => void
makeLabel?: (key: React.ReactNode) => React.ReactNode
popup?: {
ref?: React.MutableRefObject<HTMLDivElement | null>
}
}
export function RollingDateRangeFilter({
onChange,
makeLabel,
popup,
dateFrom,
selected,
}: RollingDateRangeFilterProps): JSX.Element {
const logicProps = { onChange, dateFrom, selected }
const { increaseCounter, decreaseCounter, setCounter, setDateOption, toggleDateOptionsSelector, select } =
useActions(rollingDateRangeFilterLogic(logicProps))
const { counter, dateOption, isDateOptionsSelectorOpen, formattedDate } = useValues(
rollingDateRangeFilterLogic(logicProps)
)
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const newValue = event.target.value ? parseFloat(event.target.value) : undefined
setCounter(newValue)
}
return (
<Tooltip title={makeLabel ? makeLabel(formattedDate) : undefined}>
<div
className={clsx('RollingDateRangeFilter', {
'RollingDateRangeFilter--selected': selected,
})}
data-attr="rolling-date-range-filter"
onClick={select}
>
<p className="RollingDateRangeFilter__label">In the last</p>
<div className="RollingDateRangeFilter__counter" onClick={(e): void => e.stopPropagation()}>
<LemonButton
onClick={decreaseCounter}
title={`Decrease rolling date range`}
type={'stealth'}
size="small"
>
-
</LemonButton>
<Input
data-attr="rolling-date-range-input"
type="number"
value={counter ?? ''}
min="0"
placeholder="0"
onChange={onInputChange}
bordered={false}
/>
<LemonButton
onClick={increaseCounter}
title={`Increase rolling date range`}
type={'stealth'}
size="small"
>
+
</LemonButton>
</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 as string)}
open={isDateOptionsSelectorOpen}
onClick={(e): void => {
e.stopPropagation()
toggleDateOptionsSelector()
}}
dropdownMatchSelectWidth={false}
options={dateOptions}
type="stealth"
popup={{
...popup,
className: 'RollingDateRangeFilter__popup',
}}
outlined
size="small"
/>
</div>
</Tooltip>
)
}

View File

@@ -0,0 +1,61 @@
import { expectLogic } from 'kea-test-utils'
import { dayjs } from 'lib/dayjs'
import { dateMappingExperiment } from 'lib/utils'
import { dateFilterLogic, DateFilterLogicPropsType } from './dateFilterLogic'
describe('dateFilterLogic', () => {
let props: DateFilterLogicPropsType
const onChange = jest.fn()
let logic: ReturnType<typeof dateFilterLogic.build>
beforeEach(async () => {
props = {
key: 'test',
defaultValue: '-7d',
onChange,
dateFrom: null,
dateTo: null,
dateOptions: dateMappingExperiment,
isDateFormatted: false,
}
logic = dateFilterLogic(props)
logic.mount()
})
it('should only open one type of date filter', async () => {
await expectLogic(logic).toMount().toMatchValues({
isOpen: false,
isDateRangeOpen: false,
})
logic.actions.open()
await expectLogic(logic).toMatchValues({
isOpen: true,
isDateRangeOpen: false,
})
logic.actions.openDateRange()
await expectLogic(logic).toMatchValues({
isOpen: false,
isDateRangeOpen: true,
})
})
it('should set a rolling date range', async () => {
await expect(logic.values).toMatchObject({
rangeDateFrom: null,
rangeDateTo: dayjs().format('YYYY-MM-DD'),
isFixedDateRange: false,
isRollingDateRange: true,
})
const threeDaysAgo = dayjs().subtract(3, 'd').format('YYYY-MM-DD')
logic.actions.setRangeDateFrom(threeDaysAgo)
await expect(logic.values).toMatchObject({
rangeDateFrom: threeDaysAgo,
rangeDateTo: dayjs().format('YYYY-MM-DD'),
isFixedDateRange: false,
isRollingDateRange: true,
})
})
})

View File

@@ -0,0 +1,120 @@
import { actions, props, events, kea, listeners, path, reducers, selectors, key } from 'kea'
import { dayjs, Dayjs } from 'lib/dayjs'
import type { dateFilterLogicType } from './dateFilterLogicType'
import { isDate, dateFilterToText } from 'lib/utils'
import { dateMappingOption } from '~/types'
export type DateFilterLogicPropsType = {
key: string
defaultValue: string
onChange?: (fromDate: string, toDate: string) => void
dateFrom?: Dayjs | string | null
dateTo?: Dayjs | string | null
dateOptions?: dateMappingOption[]
isDateFormatted?: boolean
}
export const dateFilterLogic = kea<dateFilterLogicType>([
path(['lib', 'components', 'DateFilter', 'DateFilterLogic']),
props({ defaultValue: 'Custom' } as DateFilterLogicPropsType),
key(({ key }) => key),
actions({
open: true,
close: true,
openDateRange: true,
setDate: (dateFrom: string, dateTo: string) => ({ dateFrom, dateTo }),
setRangeDateFrom: (range: Dayjs | string | undefined | null) => ({ range }),
setRangeDateTo: (range: Dayjs | string | undefined | null) => ({ range }),
setValue: (value: string) => ({ value }),
}),
reducers(({ props }) => ({
isOpen: [
false,
{
open: () => true,
close: () => false,
openDateRange: () => false,
},
],
isDateRangeOpen: [
false,
{
open: () => false,
openDateRange: () => true,
close: () => false,
},
],
rangeDateFrom: [
(props.dateFrom && isDate.test(props.dateFrom as string) ? dayjs(props.dateFrom) : undefined) as
| Dayjs
| string
| undefined
| null,
{
setRangeDateFrom: (_, { range }) => range,
openDateRange: () => null,
},
],
rangeDateTo: [
(props.dateTo && isDate.test(props.dateTo as string)
? dayjs(props.dateTo)
: dayjs().format('YYYY-MM-DD')) as Dayjs | string | undefined | null,
{
setRangeDateTo: (_, { range }) => range,
openDateRange: () => dayjs().format('YYYY-MM-DD'),
},
],
value: [
dateFilterToText(props.dateFrom, props.dateTo, props.defaultValue, props.dateOptions, false),
{
setValue: (_, { value }) => value,
},
],
})),
selectors(() => ({
isFixedDateRange: [
() => [(_, props) => props.dateFrom, (_, props) => props.dateTo],
(dateFrom: Dayjs | string | null, dateTo: Dayjs | string | null) =>
!!(dateFrom && dateTo && dayjs(dateFrom).isValid() && dayjs(dateTo).isValid()),
],
isRollingDateRange: [
(s) => [
s.isFixedDateRange,
(_, props) => props.dateOptions,
(_, props) => props.dateFrom,
(_, props) => props.dateTo,
],
(
isFixedDateRange: boolean,
dateOptions: dateMappingOption[],
dateFrom: Dayjs | string | undefined | null,
dateTo: Dayjs | string | undefined | null
): boolean =>
!isFixedDateRange &&
!(
dateOptions &&
dateOptions.find((option) => option.values[0] === dateFrom && option.values[1] === dateTo)
),
],
})),
listeners(({ props }) => ({
setDate: ({ dateFrom, dateTo }) => {
props.onChange?.(dateFrom, dateTo)
},
})),
events(({ actions, values }) => ({
propsChanged: (props) => {
// when props change, automatically reset the Select key to reflect the change
const { dateFrom, dateTo, defaultValue, dateOptions } = props
let newValue = null
if (values.isFixedDateRange) {
newValue = `${dateFrom} - ${dateTo}`
} else {
newValue = dateFilterToText(dateFrom, dateTo, defaultValue, dateOptions, false)
}
if (newValue && values.value !== newValue) {
actions.setValue(newValue)
}
},
})),
])

View File

@@ -0,0 +1,42 @@
import { expectLogic } from 'kea-test-utils'
import { initKeaTests } from '~/test/init'
import { rollingDateRangeFilterLogic } from './rollingDateRangeFilterLogic'
describe('rollingDateRangeFilterLogic', () => {
let logic: ReturnType<typeof rollingDateRangeFilterLogic.build>
beforeEach(() => {
initKeaTests()
})
it('has -3d as default value', () => {
logic = rollingDateRangeFilterLogic({})
logic.mount()
expectLogic(logic, () => {}).toMatchValues({
value: '-3d',
})
})
it('correctly sets the value', () => {
logic = rollingDateRangeFilterLogic({})
logic.mount()
expectLogic(logic, () => {
logic.actions.increaseCounter()
logic.actions.setDateOption('months')
}).toMatchValues({
value: '-4m',
})
expectLogic(logic, () => {
logic.actions.decreaseCounter()
logic.actions.setDateOption('quarters')
}).toMatchValues({
value: '-3q',
})
expectLogic(logic, () => {
logic.actions.setCounter(6)
logic.actions.setDateOption('days')
}).toMatchValues({
value: '-6d',
})
})
})

View File

@@ -0,0 +1,109 @@
import { actions, props, kea, listeners, path, reducers, selectors } from 'kea'
import type { rollingDateRangeFilterLogicType } from './rollingDateRangeFilterLogicType'
import { Dayjs } from 'lib/dayjs'
import './RollingDateRangeFilter.scss'
import { dateFilterToText } from 'lib/utils'
const dateOptionsMap = {
q: 'quarters',
m: 'months',
w: 'weeks',
d: 'days',
}
export type RollingDateFilterLogicPropsType = {
selected?: boolean
onChange?: (fromDate: string) => void
dateFrom?: Dayjs | string | null
}
const counterDefault = (selected: boolean | undefined, dateFrom: Dayjs | string | null | undefined): number => {
if (selected && dateFrom && typeof dateFrom === 'string') {
const counter = parseInt(dateFrom.slice(1, -1))
if (counter) {
return counter
}
}
return 3
}
const dateOptionDefault = (selected: boolean | undefined, dateFrom: Dayjs | string | null | undefined): string => {
if (selected && dateFrom && typeof dateFrom === 'string') {
const dateOption = dateOptionsMap[dateFrom.slice(-1)]
if (dateOption) {
return dateOption
}
}
return 'days'
}
export const rollingDateRangeFilterLogic = kea<rollingDateRangeFilterLogicType>([
path(['lib', 'components', 'DateFilter', 'RollingDateRangeFilterLogic']),
actions({
increaseCounter: true,
decreaseCounter: true,
setCounter: (counter: number | null | undefined) => ({ counter }),
setDateOption: (option: string) => ({ option }),
toggleDateOptionsSelector: true,
select: true,
}),
props({} as RollingDateFilterLogicPropsType),
reducers(({ props }) => ({
counter: [
counterDefault(props.selected, props.dateFrom) as number | null,
{
increaseCounter: (state) => (state ? state + 1 : 1),
decreaseCounter: (state) => {
if (state) {
return state > 0 ? state - 1 : 0
}
return 0
},
setCounter: (_, { counter }) => counter ?? null,
},
],
dateOption: [
dateOptionDefault(props.selected, props.dateFrom),
{
setDateOption: (_, { option }) => option,
},
],
isDateOptionsSelectorOpen: [
false,
{
toggleDateOptionsSelector: (state) => !state,
},
],
})),
selectors(() => ({
value: [
(s) => [s.counter, s.dateOption],
(counter: number | null, dateOption: string) => {
if (!counter) {
return ''
}
switch (dateOption) {
case 'quarters':
return `-${counter}q`
case 'months':
return `-${counter}m`
case 'weeks':
return `-${counter}w`
default:
return `-${counter}d`
}
},
],
formattedDate: [
(s) => [s.value],
(value: string) => {
return dateFilterToText(value, undefined, 'Custom rolling range', [], true)
},
],
})),
listeners(({ props, values }) => ({
select: () => {
props.onChange?.(values.value)
},
})),
])

View File

@@ -1,10 +1,13 @@
import { kea } from 'kea'
import { objectsEqual } from 'lib/utils'
import { objectsEqual, dateMappingExperiment as dateMapping } from 'lib/utils'
import type { intervalFilterLogicType } from './intervalFilterLogicType'
import { IntervalKeyType } from 'lib/components/IntervalFilter/intervals'
import { insightLogic } from 'scenes/insights/insightLogic'
import { InsightLogicProps } from '~/types'
import { InsightLogicProps, IntervalType } from '~/types'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
import { dayjs } from 'lib/dayjs'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
export const intervalFilterLogic = kea<intervalFilterLogicType>({
props: {} as InsightLogicProps,
@@ -12,17 +15,56 @@ export const intervalFilterLogic = kea<intervalFilterLogicType>({
path: (key) => ['lib', 'components', 'IntervalFilter', 'intervalFilterLogic', key],
connect: (props: InsightLogicProps) => ({
actions: [insightLogic(props), ['setFilters']],
values: [insightLogic(props), ['filters']],
values: [insightLogic(props), ['filters'], featureFlagLogic, ['featureFlags']],
}),
actions: () => ({
setInterval: (interval: IntervalKeyType) => ({ interval }),
}),
listeners: ({ values, actions }) => ({
listeners: ({ values, actions, selectors }) => ({
setInterval: ({ interval }) => {
if (!objectsEqual(interval, values.filters.interval)) {
actions.setFilters({ ...values.filters, interval })
}
},
setFilters: ({ filters }, _, __, previousState) => {
if (values.featureFlags[FEATURE_FLAGS.DATE_FILTER_EXPERIMENT] === 'test') {
const { date_from, date_to } = filters
const previousFilters = selectors.filters(previousState)
if (
!date_from ||
(objectsEqual(date_from, previousFilters.date_from) &&
objectsEqual(date_to, previousFilters.date_to))
) {
return
}
// automatically set an interval for fixed date ranges
if (date_from && date_to && dayjs(filters.date_from).isValid() && dayjs(filters.date_to).isValid()) {
if (dayjs(date_to).diff(dayjs(date_from), 'day') <= 3) {
actions.setInterval('hour')
} else if (dayjs(date_to).diff(dayjs(date_from), 'month') <= 3) {
actions.setInterval('day')
} else {
actions.setInterval('month')
}
return
}
// get a defaultInterval for dateOptions that have a default value
let interval: IntervalType = 'day'
for (const { key, values, defaultInterval } of dateMapping) {
if (
values[0] === date_from &&
values[1] === (date_to || undefined) &&
key !== 'Custom' &&
defaultInterval
) {
interval = defaultInterval
break
}
}
actions.setInterval(interval)
}
},
}),
selectors: {
interval: [(s) => [s.filters], (filters) => filters?.interval],

View File

@@ -20,6 +20,7 @@ export interface LemonButtonPropsBase extends Omit<LemonRowPropsBase<'button'>,
active?: boolean
/** URL to link to. */
to?: string
className?: string
}
export interface LemonButtonProps extends LemonButtonPropsBase {
@@ -137,7 +138,7 @@ export interface LemonButtonWithPopupProps extends LemonButtonPropsBase {
* The difference vs. plain `LemonButton` is popup visibility being controlled internally, which is more convenient.
*/
export function LemonButtonWithPopup({
popup: { onClickOutside, onClickInside, closeOnClickInside = true, ...popupProps },
popup: { onClickOutside, onClickInside, closeOnClickInside = true, className: popupClassName, ...popupProps },
onClick,
...buttonProps
}: LemonButtonWithPopupProps): JSX.Element {
@@ -158,11 +159,13 @@ export function LemonButtonWithPopup({
return (
<Popup
className={popupClassName}
onClickOutside={(e) => {
setPopupVisible(false)
onClickOutside?.(e)
}}
onClickInside={(e) => {
e.stopPropagation()
closeOnClickInside && setPopupVisible(false)
onClickInside?.(e)
}}

View File

@@ -30,6 +30,11 @@ export interface LemonSelectProps<O extends LemonSelectOptions>
dropdownMaxContentWidth?: boolean
dropdownPlacement?: PopupProps['placement']
allowClear?: boolean
className?: string
popup?: {
className?: string
ref?: React.MutableRefObject<HTMLDivElement | null>
}
}
export function LemonSelect<O extends LemonSelectOptions>({
@@ -41,6 +46,8 @@ export function LemonSelect<O extends LemonSelectOptions>({
dropdownMaxContentWidth = false,
dropdownPlacement,
allowClear = false,
className,
popup,
...buttonProps
}: LemonSelectProps<O>): JSX.Element {
const [localValue, setLocalValue] = useState(value)
@@ -82,9 +89,11 @@ export function LemonSelect<O extends LemonSelectOptions>({
onMouseLeave={() => setHover(false)}
>
<LemonButtonWithPopup
className={className}
popup={{
ref: popup?.ref,
overlay: sections.map((section, i) => (
<>
<React.Fragment key={i}>
{section.label ? (
typeof section.label === 'string' ? (
<h5>{section.label}</h5>
@@ -116,11 +125,12 @@ export function LemonSelect<O extends LemonSelectOptions>({
</LemonButton>
))}
{i < sections.length - 1 ? <LemonDivider /> : null}
</>
</React.Fragment>
)),
sameWidth: dropdownMatchSelectWidth,
placement: dropdownPlacement,
actionable: true,
className: popup?.className,
maxContentWidth: dropdownMaxContentWidth,
}}
icon={localValue && allOptions[localValue]?.icon}

View File

@@ -16,6 +16,7 @@ import {
} from '@floating-ui/react-dom-interactions'
export interface PopupProps {
ref?: React.MutableRefObject<HTMLDivElement | null>
visible?: boolean
onClickOutside?: (event: Event) => void
onClickInside?: MouseEventHandler<HTMLDivElement>
@@ -34,6 +35,12 @@ export interface PopupProps {
maxContentWidth?: boolean
className?: string
middleware?: Middleware[]
/** Any other refs that needs to be taken into account for handling outside clicks e.g. other nested popups.
* Works also with strings, matching classnames or ids, for antd legacy components that don't support refs
* **/
additionalRefs?: (React.MutableRefObject<HTMLDivElement | null> | string)[]
style?: React.CSSProperties
getPopupContainer?: () => HTMLElement
}
/** 0 means no parent. */
@@ -45,88 +52,102 @@ let uniqueMemoizedIndex = 1
*
* Often used with buttons for various menu. If this is your intention, use `LemonButtonWithPopup`.
*/
export function Popup({
children,
overlay,
visible,
onClickOutside,
onClickInside,
placement = 'bottom-start',
fallbackPlacements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'],
className,
actionable = false,
middleware,
sameWidth = false,
maxContentWidth = false,
}: PopupProps): JSX.Element {
const popupId = useMemo(() => uniqueMemoizedIndex++, [])
const {
x,
y,
refs: { reference: referenceRef, floating: floatingRef },
strategy,
placement: floatingPlacement,
update,
} = useFloating<HTMLElement>({
placement,
strategy: 'fixed',
middleware: [
offset(4),
...(fallbackPlacements ? [flip({ fallbackPlacements })] : []),
shift(),
size({
padding: 5,
apply({ rects, elements: { floating } }) {
if (sameWidth) {
Object.assign(floating.style, {
width: `${rects.reference.width}px`,
})
}
},
}),
...(middleware ?? []),
],
})
export const Popup = React.forwardRef<HTMLDivElement, PopupProps>(
(
{
children,
overlay,
visible,
onClickOutside,
onClickInside,
placement = 'bottom-start',
fallbackPlacements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'],
className,
actionable = false,
middleware,
sameWidth = false,
maxContentWidth = false,
additionalRefs = [],
style,
getPopupContainer,
},
ref
): JSX.Element => {
const popupId = useMemo(() => uniqueMemoizedIndex++, [])
const {
x,
y,
refs: { reference: referenceRef, floating: floatingRef },
strategy,
placement: floatingPlacement,
update,
} = useFloating<HTMLElement>({
placement,
strategy: 'fixed',
middleware: [
offset(4),
...(fallbackPlacements ? [flip({ fallbackPlacements })] : []),
shift(),
size({
padding: 5,
apply({ rects, elements: { floating } }) {
if (sameWidth) {
Object.assign(floating.style, {
width: `${rects.reference.width}px`,
})
}
},
}),
...(middleware ?? []),
],
})
useOutsideClickHandler([floatingRef, referenceRef], (event) => visible && onClickOutside?.(event), [visible])
useOutsideClickHandler(
[floatingRef, referenceRef, ...additionalRefs],
(event) => visible && onClickOutside?.(event),
[visible]
)
useEffect(() => {
if (visible && referenceRef?.current && floatingRef?.current) {
return autoUpdate(referenceRef.current, floatingRef.current, update)
}
}, [visible, referenceRef?.current, floatingRef?.current])
useEffect(() => {
if (visible && referenceRef?.current && floatingRef?.current) {
return autoUpdate(referenceRef.current, floatingRef.current, update)
}
}, [visible, referenceRef?.current, floatingRef?.current, ...additionalRefs])
const clonedChildren =
typeof children === 'function'
? children({ ref: referenceRef })
: React.Children.toArray(children).map((child) =>
React.cloneElement(child as ReactElement, { ref: referenceRef })
)
const clonedChildren =
typeof children === 'function'
? children({ ref: referenceRef })
: React.Children.toArray(children).map((child) =>
React.cloneElement(child as ReactElement, { ref: referenceRef })
)
return (
<>
{clonedChildren}
{ReactDOM.createPortal(
<CSSTransition in={visible} timeout={100} classNames="Popup-" mountOnEnter unmountOnExit>
<PopupContext.Provider value={popupId}>
<div
className={clsx(
'Popup',
actionable && 'Popup--actionable',
maxContentWidth && 'Popup--max-content-width',
className
)}
data-floating-placement={floatingPlacement}
ref={floatingRef as MutableRefObject<HTMLDivElement>}
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
onClick={onClickInside}
>
<div className="Popup__box">{overlay}</div>
</div>
</PopupContext.Provider>
</CSSTransition>,
document.body
)}
</>
)
}
return (
<>
{clonedChildren}
{ReactDOM.createPortal(
<CSSTransition in={visible} timeout={100} classNames="Popup-" mountOnEnter unmountOnExit>
<PopupContext.Provider value={popupId}>
<div
className={clsx(
'Popup',
actionable && 'Popup--actionable',
maxContentWidth && 'Popup--max-content-width',
className
)}
data-floating-placement={floatingPlacement}
ref={floatingRef as MutableRefObject<HTMLDivElement>}
style={{ position: strategy, top: y ?? 0, left: x ?? 0, ...style }}
onClick={onClickInside}
>
<div ref={ref} className="Popup__box">
{overlay}
</div>
</div>
</PopupContext.Provider>
</CSSTransition>,
getPopupContainer ? getPopupContainer() : document.body
)}
</>
)
}
)

View File

@@ -123,6 +123,7 @@ export const FEATURE_FLAGS = {
PLUGINS_HISTORY: 'plugins-history', // owner: @yakkomajuri,
EMBED_INSIGHTS: 'embed-insights', // owner: @mariusandra
ASYNC_EXPORT_CSV_FOR_LIVE_EVENTS: 'ASYNC_EXPORT_CSV_FOR_LIVE_EVENTS', // owner: @pauldambra
DATE_FILTER_EXPERIMENT: 'date-filter-experiment', // owner: @kappa90
}
/** Which self-hosted plan's features are available with Cloud's "Standard" plan (aka card attached). */

View File

@@ -3,9 +3,10 @@ import { useEffect } from 'react'
const exceptions = ['.ant-select-dropdown *', '.click-outside-block', '.click-outside-block *']
export function useOutsideClickHandler(
refOrRefs: React.MutableRefObject<any> | React.MutableRefObject<any>[],
refOrRefs: string | React.MutableRefObject<any> | (React.MutableRefObject<any> | string)[],
handleClickOutside?: (event: Event) => void,
extraDeps: any[] = []
extraDeps: any[] = [],
exceptTagNames?: string[] // list of tag names that don't trigger the callback even if outside
): void {
const allRefs = Array.isArray(refOrRefs) ? refOrRefs : [refOrRefs]
@@ -16,12 +17,20 @@ export function useOutsideClickHandler(
}
if (
allRefs.some((maybeRef) => {
const ref = maybeRef.current
return ref && `contains` in ref && ref.contains(event.target as Element)
if (typeof maybeRef === 'string') {
return event.composedPath?.()?.find((e) => (e as HTMLElement)?.matches?.(maybeRef))
} else {
const ref = maybeRef.current
return event.target && ref && `contains` in ref && ref.contains(event.target as Element)
}
})
) {
return
}
const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement
if (exceptTagNames && exceptTagNames.includes(target.tagName)) {
return
}
handleClickOutside?.(event)
}

View File

@@ -260,11 +260,9 @@ describe('dateFilterToText()', () => {
})
it('can have overridden date options', () => {
expect(
dateFilterToText('-21d', null, 'default', {
'Last 3 weeks': { values: ['-21d'] },
})
).toEqual('Last 3 weeks')
expect(dateFilterToText('-21d', null, 'default', [{ key: 'Last 3 weeks', values: ['-21d'] }])).toEqual(
'Last 3 weeks'
)
})
})
@@ -301,9 +299,7 @@ describe('dateFilterToText()', () => {
'-21d',
null,
'default',
{
'Last 3 weeks': { values: ['-21d'], getFormattedDate: () => 'custom formatted date' },
},
[{ key: 'Last 3 weeks', values: ['-21d'], getFormattedDate: () => 'custom formatted date' }],
true
)
).toEqual('custom formatted date')

View File

@@ -710,76 +710,183 @@ export function determineDifferenceType(
const DATE_FORMAT = 'D MMM YYYY'
export const dateMapping: Record<string, dateMappingOption> = {
Custom: { values: [] },
Today: {
export const dateMapping: dateMappingOption[] = [
{ key: 'Custom', values: [] },
{
key: 'Today',
values: ['dStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string => date.startOf('d').format(format),
},
Yesterday: {
{
key: 'Yesterday',
values: ['-1d', '-1d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string => date.subtract(1, 'd').format(format),
},
'Last 24 hours': {
{
key: 'Last 24 hours',
values: ['-24h'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(24, 'h').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 48 hours': {
{
key: 'Last 48 hours',
values: ['-48h'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(48, 'h').format(format)} - ${date.endOf('d').format(format)}`,
inactive: true,
},
'Last 3 days': {
{
key: 'Last 3 days',
values: ['-3d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(3, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 7 days': {
{
key: 'Last 7 days',
values: ['-7d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(7, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 14 days': {
{
key: 'Last 14 days',
values: ['-14d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(14, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 30 days': {
{
key: 'Last 30 days',
values: ['-30d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(30, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 90 days': {
{
key: 'Last 90 days',
values: ['-90d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(90, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'Last 180 days': {
{
key: 'Last 180 days',
values: ['-180d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(180, 'd').format(format)} - ${date.endOf('d').format(format)}`,
},
'This month': {
{
key: 'This month',
values: ['mStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(1, 'm').format(format)} - ${date.endOf('d').format(format)}`,
inactive: true,
},
'Previous month': {
{
key: 'Previous month',
values: ['-1mStart', '-1mEnd'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(1, 'm').startOf('M').format(format)} - ${date.subtract(1, 'm').endOf('M').format(format)}`,
inactive: true,
},
'Year to date': {
{
key: 'Year to date',
values: ['yStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.startOf('y').format(format)} - ${date.endOf('d').format(format)}`,
},
'All time': { values: ['all'] },
}
{ key: 'All time', values: ['all'] },
]
export const dateMappingExperiment: dateMappingOption[] = [
{ key: 'Custom', values: [] },
{
key: 'Today',
values: ['dStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string => date.startOf('d').format(format),
defaultInterval: 'hour',
},
{
key: 'Yesterday',
values: ['-1d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string => date.subtract(1, 'd').format(format),
defaultInterval: 'hour',
},
{
key: 'Last 24 hours',
values: ['-24h'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(24, 'h').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'hour',
},
{
key: 'Last 48 hours',
values: ['-48h'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(48, 'h').format(format)} - ${date.endOf('d').format(format)}`,
inactive: true,
defaultInterval: 'hour',
},
{
key: 'Last 7 days',
values: ['-7d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(7, 'd').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'day',
},
{
key: 'Last 14 days',
values: ['-14d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(14, 'd').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'day',
},
{
key: 'Last 30 days',
values: ['-30d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(30, 'd').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'day',
},
{
key: 'Last 90 days',
values: ['-90d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(90, 'd').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'day',
},
{
key: 'Last 180 days',
values: ['-180d'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(180, 'd').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'month',
},
{
key: 'This month',
values: ['mStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(1, 'm').format(format)} - ${date.endOf('d').format(format)}`,
inactive: true,
defaultInterval: 'day',
},
{
key: 'Previous month',
values: ['-1mStart', '-1mEnd'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.subtract(1, 'm').startOf('M').format(format)} - ${date.subtract(1, 'm').endOf('M').format(format)}`,
inactive: true,
defaultInterval: 'day',
},
{
key: 'Year to date',
values: ['yStart'],
getFormattedDate: (date: dayjs.Dayjs, format: string): string =>
`${date.startOf('y').format(format)} - ${date.endOf('d').format(format)}`,
defaultInterval: 'month',
},
{
key: 'All time',
values: ['all'],
defaultInterval: 'month',
},
]
export const isDate = /([0-9]{4}-[0-9]{2}-[0-9]{2})/
@@ -787,11 +894,18 @@ export function getFormattedLastWeekDate(lastDay: dayjs.Dayjs = dayjs()): string
return `${lastDay.subtract(7, 'week').format(DATE_FORMAT)} - ${lastDay.endOf('d').format(DATE_FORMAT)}`
}
const dateOptionsMap = {
q: 'quarter',
m: 'month',
w: 'week',
d: 'day',
}
export function dateFilterToText(
dateFrom: string | dayjs.Dayjs | null | undefined,
dateTo: string | dayjs.Dayjs | null | undefined,
defaultValue: string,
dateOptions: Record<string, dateMappingOption> = dateMapping,
dateOptions: dateMappingOption[] = dateMapping,
isDateFormatted: boolean = false,
dateFormat: string = DATE_FORMAT
): string {
@@ -823,13 +937,40 @@ export function dateFilterToText(
}
}
let name = defaultValue
Object.entries(dateOptions).map(([key, { values, getFormattedDate }]) => {
for (const { key, values, getFormattedDate } of dateOptions) {
if (values[0] === dateFrom && values[1] === dateTo && key !== 'Custom') {
name = isDateFormatted && getFormattedDate ? getFormattedDate(dayjs(), dateFormat) : key
return isDateFormatted && getFormattedDate ? getFormattedDate(dayjs(), dateFormat) : key
}
})[0]
return name
}
if (dateFrom) {
const dateOption = dateOptionsMap[dateFrom.slice(-1)]
const counter = parseInt(dateFrom.slice(1, -1))
if (dateOption && counter) {
let date = null
switch (dateOption) {
case 'quarter':
date = dayjs().subtract(counter * 3, 'M')
break
case 'months':
date = dayjs().subtract(counter, 'M')
break
case 'weeks':
date = dayjs().subtract(counter * 7, 'd')
break
default:
date = dayjs().subtract(counter, 'd')
break
}
if (isDateFormatted) {
return `${date.format('YYYY-MM-DD')} - ${dayjs().endOf('d').format('YYYY-MM-DD')}`
} else {
return `Last ${counter} ${dateOption}${counter > 1 ? 's' : ''}`
}
}
}
return defaultValue
}
export function copyToClipboard(value: string, description: string = 'text'): boolean {

View File

@@ -137,6 +137,8 @@
span.filter {
font-size: 14px;
display: flex;
align-items: center;
&:not(:last-child) {
margin-right: 0.5rem;

View File

@@ -90,7 +90,7 @@ export function InsightDisplayConfig({ filters, activeView, disableTable }: Insi
<span className="filter">
<span className="head-title-item">Date range</span>
<InsightDateFilter
defaultValue="Last 7 days"
defaultValue={'Last 7 days'}
disabled={showFunnelBarOptions && isFunnelEmpty(filters)}
bordered
makeLabel={(key) => (

View File

@@ -33,7 +33,7 @@ export const insightCommandLogic = kea<insightCommandLogicType>({
compareFilterLogic(props).actions.toggleCompare()
},
},
...Object.entries(dateMapping).map(([key, { values }]) => ({
...dateMapping.map(({ key, values }) => ({
icon: RiseOutlined,
display: `Set Time Range to ${key}`,
executor: () => {

View File

@@ -247,6 +247,7 @@ export const insightLogic = kea<insightLogicType>({
cache.abortController = new AbortController()
const { filters } = values
const insight = (filters.insight as InsightType | undefined) || InsightType.TRENDS
const params = { ...filters, ...(refresh ? { refresh: true } : {}) }

View File

@@ -42,6 +42,7 @@ import { FEATURE_FLAGS } from 'lib/constants'
import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity'
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
import { insightActivityDescriber } from 'scenes/saved-insights/activityDescriptions'
import { CalendarOutlined } from '@ant-design/icons'
const { TabPane } = Tabs
@@ -395,10 +396,9 @@ export function SavedInsights(): JSX.Element {
))}
</Select>
</Col>
<Col>
Last modified:
<div className="flex-center gap-05">
<span>Last modified:</span>
<DateFilter
style={{ paddingLeft: 8 }}
defaultValue="All time"
disabled={false}
bordered={true}
@@ -407,8 +407,14 @@ export function SavedInsights(): JSX.Element {
onChange={(fromDate, toDate) =>
setSavedInsightsFilters({ dateFrom: fromDate, dateTo: toDate })
}
makeLabel={(key) => (
<>
<CalendarOutlined />
<span className="hide-when-small"> {key}</span>
</>
)}
/>
</Col>
</div>
{tab !== SavedInsightsTabs.Yours ? (
<Col>
Created by:

View File

@@ -186,12 +186,12 @@ export function SessionRecordingsTable({ personUUID, isPersonPage = false }: Ses
reportRecordingsListFilterAdded(SessionRecordingFilterType.DateRange)
setDateRange(changedDateFrom, changedDateTo)
}}
dateOptions={{
Custom: { values: [] },
'Last 24 hours': { values: ['-24h'] },
'Last 7 days': { values: ['-7d'] },
'Last 21 days': { values: ['-21d'] },
}}
dateOptions={[
{ key: 'Custom', values: [] },
{ key: 'Last 24 hours', values: ['-24h'] },
{ key: 'Last 7 days', values: ['-7d'] },
{ key: 'Last 21 days', values: ['-21d'] },
]}
/>
</Row>
<Row className="time-filter">

View File

@@ -11,6 +11,7 @@
box-shadow: 0 0.3px 0.7px rgba(0, 0, 0, 0.25), 0 0.8px 1.7px rgba(0, 0, 0, 0.18),
0 1.5px 3.1px rgba(0, 0, 0, 0.149), 0 2.7px 5.4px rgba(0, 0, 0, 0.125), 0 5px 10px rgba(0, 0, 0, 0.101),
0 12px 24px rgba(0, 0, 0, 0.07);
&:hover {
box-shadow: 0 1.3px 2.7px rgba(0, 0, 0, 0.25), 0 3.2px 6.4px rgba(0, 0, 0, 0.18),
0 6px 12px rgba(0, 0, 0, 0.149), 0 10.7px 21.4px rgba(0, 0, 0, 0.125),
@@ -68,11 +69,13 @@
padding-left: 8px;
}
}
.close-button {
cursor: pointer;
padding: 8px;
color: rgba(0, 0, 0, 0.6);
transition: color 0.1s ease-in-out;
&:hover {
color: rgb(0, 0, 0);
}

View File

@@ -32,6 +32,7 @@ export function HeatmapStats(): JSX.Element {
onChange={(date_from, date_to) => setHeatmapFilter({ date_from, date_to })}
getPopupContainer={getShadowRootPopupContainer}
/>
{heatmapLoading ? <Spinner size="sm" style={{ marginLeft: 8 }} /> : null}
</div>
<div style={{ marginTop: 20, marginBottom: 10 }}>

View File

@@ -10,6 +10,7 @@
background: white;
color: black;
font-size: 14px;
&.no-padding {
padding: 0;
}
@@ -19,13 +20,16 @@
.ant-modal-wrap {
z-index: 2147483030 !important;
}
.ant-select-dropdown,
.ant-picker-dropdown {
z-index: 2147483031 !important;
}
.ant-tooltip {
z-index: 2147483032 !important;
}
.ant-list-item {
&:hover {
background: #f0f0f0;
@@ -61,7 +65,12 @@
right: 0;
bottom: 0;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.Popup {
z-index: 2147483020;
}

View File

@@ -1733,9 +1733,11 @@ export interface VersionType {
}
export interface dateMappingOption {
key: string
inactive?: boolean // Options removed due to low usage (see relevant PR); will not show up for new insights but will be kept for existing
values: string[]
getFormattedDate?: (date: dayjs.Dayjs, format: string) => string
defaultInterval?: IntervalType
}
export interface Breadcrumb {

View File

@@ -61,7 +61,7 @@ from posthog.queries.trends.trends import Trends
from posthog.queries.util import get_earliest_timestamp
from posthog.settings import SITE_URL
from posthog.tasks.update_cache import update_insight_cache
from posthog.utils import get_safe_cache, relative_date_parse, should_refresh, str_to_bool
from posthog.utils import DEFAULT_DATE_FROM_DAYS, get_safe_cache, relative_date_parse, should_refresh, str_to_bool
logger = structlog.get_logger(__name__)
@@ -127,7 +127,13 @@ class InsightBasicSerializer(TaggedItemSerializerMixin, serializers.ModelSeriali
def to_representation(self, instance):
representation = super().to_representation(instance)
representation["filters"] = instance.dashboard_filters()
filters = instance.dashboard_filters()
if not filters.get("date_from"):
filters.update(
{"date_from": f"-{DEFAULT_DATE_FROM_DAYS}d",}
)
representation["filters"] = filters
return representation

View File

@@ -609,7 +609,9 @@ class TestDashboard(APIBaseTest, QueryMatchingTest):
item = Insight.objects.create(filters={"events": [{"id": "$pageview"}]}, team=self.team, last_refresh=now(),)
DashboardTile.objects.create(insight=item, dashboard=dashboard)
response = self.client.get(f"/api/projects/{self.team.id}/dashboards/{dashboard.pk}").json()
self.assertEqual(response["items"][0]["filters"], {"events": [{"id": "$pageview"}], "insight": "TRENDS"})
self.assertEqual(
response["items"][0]["filters"], {"events": [{"id": "$pageview"}], "insight": "TRENDS", "date_from": "-7d"}
)
def test_retrieve_dashboard_different_team(self):
team2 = Team.objects.create(organization=Organization.objects.create(name="a"))

View File

@@ -1445,7 +1445,7 @@ class TestInsight(ClickhouseTestMixin, LicensedTestMixin, APIBaseTest, QueryMatc
before_save = DashboardTile.objects.get(dashboard__id=dashboard_id, insight__id=insight_id).filters_hash
response = self.client.patch(
f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", {"filters": {"date_from": "-7d"},},
f"/api/projects/{self.team.id}/dashboards/{dashboard_id}", {"filters": {"date_from": "-14d"},},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -47,7 +47,7 @@ from posthog.models.entity import MATH_TYPE, Entity, ExclusionEntity
from posthog.models.filters.mixins.base import BaseParamMixin, BreakdownType
from posthog.models.filters.mixins.utils import cached_property, include_dict, process_bool
from posthog.models.filters.utils import GroupTypeIndex, validate_group_type_index
from posthog.utils import relative_date_parse
from posthog.utils import DEFAULT_DATE_FROM_DAYS, relative_date_parse
# When updating this regex, remember to update the regex with the same name in TrendsFormula.tsx
ALLOWED_FORMULA_CHARACTERS = r"([a-zA-Z \-\*\^0-9\+\/\(\)\.]+)"
@@ -324,7 +324,9 @@ class DateMixin(BaseParamMixin):
return relative_date_parse(self._date_from)
else:
return self._date_from
return timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - relativedelta(days=7)
return timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - relativedelta(
days=DEFAULT_DATE_FROM_DAYS
)
@cached_property
def date_to(self) -> datetime.datetime:
@@ -357,6 +359,8 @@ class DateMixin(BaseParamMixin):
else self._date_from
}
)
else:
result_dict.update({"date_from": f"-{DEFAULT_DATE_FROM_DAYS}d"})
if self._date_to:
result_dict.update(

View File

@@ -17,6 +17,7 @@ class TestFilter(BaseTest):
filter.to_dict(),
{
"display": "RetentionTable",
"date_from": "-7d",
"insight": "RETENTION",
"period": "Day",
"retention_type": "retention_recurring",
@@ -87,6 +88,7 @@ class TestFilter(BaseTest):
"type": "events",
},
"breakdown_attribution_type": "first_touch",
"date_from": "-7d",
},
)

View File

@@ -89,7 +89,7 @@ class TestUpdateCache(APIBaseTest):
insight=insight_not_cached_because_dashboard_has_filters, dashboard=another_shared_dashboard_to_cache
)
# filters changed after dashboard linked to insight but should still affect filters hash
another_shared_dashboard_to_cache.filters = {"date_from": "-7d"}
another_shared_dashboard_to_cache.filters = {"date_from": "-14d"}
another_shared_dashboard_to_cache.save()
dashboard_do_not_cache = create_shared_dashboard(
@@ -438,7 +438,7 @@ class TestUpdateCache(APIBaseTest):
but does touch the tile
"""
dashboard_to_cache = create_shared_dashboard(
team=self.team, is_shared=True, last_accessed_at=now(), filters={"date_from": "-7d"}
team=self.team, is_shared=True, last_accessed_at=now(), filters={"date_from": "-14d"}
)
item_to_cache = Insight.objects.create(
filters=Filter(

View File

@@ -50,7 +50,6 @@ from posthog.redis import get_client
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
DATERANGE_MAP = {
"minute": datetime.timedelta(minutes=1),
"hour": datetime.timedelta(hours=1),
@@ -60,6 +59,8 @@ DATERANGE_MAP = {
}
ANONYMOUS_REGEX = r"^([a-z0-9]+\-){4}([a-z0-9]+)$"
DEFAULT_DATE_FROM_DAYS = 7
# https://stackoverflow.com/questions/4060221/how-to-reliably-open-a-file-in-the-same-directory-as-a-python-script
__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__)))
@@ -154,6 +155,9 @@ def relative_date_parse(input: str) -> datetime.datetime:
date -= relativedelta(day=1)
if match.group("position") == "End":
date -= relativedelta(day=31)
elif match.group("type") == "q":
if match.group("number"):
date -= relativedelta(weeks=13 * int(match.group("number")))
elif match.group("type") == "y":
if match.group("number"):
date -= relativedelta(years=int(match.group("number")))
@@ -170,7 +174,7 @@ def request_to_date_query(filters: Dict[str, Any], exact: Optional[bool]) -> Dic
if filters["date_from"] == "all":
date_from = None
else:
date_from = datetime.datetime.today() - relativedelta(days=7)
date_from = datetime.datetime.today() - relativedelta(days=DEFAULT_DATE_FROM_DAYS)
date_from = date_from.replace(hour=0, minute=0, second=0, microsecond=0)
date_to = None
@@ -891,8 +895,8 @@ def format_query_params_absolute_url(
offset_alias: Optional[str] = "offset",
limit_alias: Optional[str] = "limit",
) -> Optional[str]:
OFFSET_REGEX = re.compile(fr"([&?]{offset_alias}=)(\d+)")
LIMIT_REGEX = re.compile(fr"([&?]{limit_alias}=)(\d+)")
OFFSET_REGEX = re.compile(rf"([&?]{offset_alias}=)(\d+)")
LIMIT_REGEX = re.compile(rf"([&?]{limit_alias}=)(\d+)")
url_to_format = request.build_absolute_uri()
@@ -901,13 +905,13 @@ def format_query_params_absolute_url(
if offset:
if OFFSET_REGEX.search(url_to_format):
url_to_format = OFFSET_REGEX.sub(fr"\g<1>{offset}", url_to_format)
url_to_format = OFFSET_REGEX.sub(rf"\g<1>{offset}", url_to_format)
else:
url_to_format = url_to_format + ("&" if "?" in url_to_format else "?") + f"{offset_alias}={offset}"
if limit:
if LIMIT_REGEX.search(url_to_format):
url_to_format = LIMIT_REGEX.sub(fr"\g<1>{limit}", url_to_format)
url_to_format = LIMIT_REGEX.sub(rf"\g<1>{limit}", url_to_format)
else:
url_to_format = url_to_format + ("&" if "?" in url_to_format else "?") + f"{limit_alias}={limit}"