mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat: date filter experiment (#10462)
Co-authored-by: Ben White <ben@benjackwhite.co.uk>
This commit is contained in:
committed by
GitHub
parent
8ee6c02af8
commit
dfc04afa08
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ beforeEach(() => {
|
||||
req.reply(
|
||||
decideResponse({
|
||||
'toolbar-launch-side-action': true,
|
||||
'date-filter-experiment': 'test',
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
3
frontend/src/lib/components/DateFilter/DateFilter.scss
Normal file
3
frontend/src/lib/components/DateFilter/DateFilter.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.DateFilter {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from 'antd'
|
||||
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { DatePicker } from '../DatePicker'
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
120
frontend/src/lib/components/DateFilter/dateFilterLogic.ts
Normal file
120
frontend/src/lib/components/DateFilter/dateFilterLogic.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
})),
|
||||
])
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
})),
|
||||
])
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
|
||||
span.filter {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 } : {}) }
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user