diff --git a/cypress/e2e/insights.js b/cypress/e2e/insights.js index 48dd4274dc..ff00665e90 100644 --- a/cypress/e2e/insights.js +++ b/cypress/e2e/insights.js @@ -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') + }) + }) }) diff --git a/cypress/e2e/trends.js b/cypress/e2e/trends.js index c9546f0673..a0fb938d0f 100644 --- a/cypress/e2e/trends.js +++ b/cypress/e2e/trends.js @@ -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') }) diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 46a05a66ec..5d7beb4f76 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -19,6 +19,7 @@ beforeEach(() => { req.reply( decideResponse({ 'toolbar-launch-side-action': true, + 'date-filter-experiment': 'test', }) ) ) diff --git a/frontend/src/lib/components/DateFilter/DateFilter.scss b/frontend/src/lib/components/DateFilter/DateFilter.scss new file mode 100644 index 0000000000..beb84741d6 --- /dev/null +++ b/frontend/src/lib/components/DateFilter/DateFilter.scss @@ -0,0 +1,3 @@ +.DateFilter { + padding: 0.5rem; +} diff --git a/frontend/src/lib/components/DateFilter/DateFilter.tsx b/frontend/src/lib/components/DateFilter/DateFilter.tsx index 781522c541..05d69d6188 100644 --- a/frontend/src/lib/components/DateFilter/DateFilter.tsx +++ b/frontend/src/lib/components/DateFilter/DateFilter.tsx @@ -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 + getPopupContainer?: () => HTMLElement + dateOptions?: dateMappingOption[] isDateFormatted?: boolean - selectProps?: SelectProps + selectProps?: SelectProps // 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({ ) } + +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(null) + const rollingDateRangeRef = useRef(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 ? ( + setRangeDateFrom(date)} + onDateToChange={(date) => setRangeDateTo(date)} + onApplyClick={onApplyClick} + onClickOutside={close} + rangeDateFrom={rangeDateFrom} + rangeDateTo={rangeDateTo} + disableBeforeYear={2015} + /> + ) : ( +
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 ( + + { + setDate(values[0], values[1]) + close() + }} + type={isHighlighted ? 'highlighted' : 'stealth'} + fullWidth + > + {key} + + + ) + })} + {showRollingRangePicker && ( + { + setDate(fromDate, '') + close() + }} + makeLabel={makeLabel} + popup={{ + ref: rollingDateRangeRef, + }} + /> + )} + + + {'Custom fixed time period'} + +
+ ) + + return ( + } + > + {value} + + ) +} + +export function DateFilter(props: RawDateFilterProps): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const experimentEnabled = featureFlags[FEATURE_FLAGS.DATE_FILTER_EXPERIMENT] === 'test' + return experimentEnabled ? : <_DateFilter {...props} /> +} diff --git a/frontend/src/lib/components/DateFilter/DateFilterRange.tsx b/frontend/src/lib/components/DateFilter/DateFilterRange.tsx index c1e442a0e4..d0fcb3dc73 100644 --- a/frontend/src/lib/components/DateFilter/DateFilterRange.tsx +++ b/frontend/src/lib/components/DateFilter/DateFilterRange.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' import { Button } from 'antd' - import { dayjs } from 'lib/dayjs' import { DatePicker } from '../DatePicker' diff --git a/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.scss b/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.scss new file mode 100644 index 0000000000..e2525f4b6b --- /dev/null +++ b/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.scss @@ -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; +} diff --git a/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.tsx b/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.tsx new file mode 100644 index 0000000000..e549bbb092 --- /dev/null +++ b/frontend/src/lib/components/DateFilter/DateFilterRangeExperiment.tsx @@ -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(null) + const [calendarOpen, setCalendarOpen] = useState(true) + + useOutsideClickHandler( + '.datefilter-datepicker', + () => { + if (calendarOpen) { + setCalendarOpen(false) + } + }, + [calendarOpen], + ['INPUT'] + ) + + return ( +
+ + < + +
+
+ +
+ { + 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 ( +
+ {current.date()} +
+ ) + }} + /> +
+ +
+
+ ) +} diff --git a/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.scss b/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.scss new file mode 100644 index 0000000000..e0349a830e --- /dev/null +++ b/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.scss @@ -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; +} diff --git a/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.tsx b/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.tsx new file mode 100644 index 0000000000..6a49eb323c --- /dev/null +++ b/frontend/src/lib/components/DateFilter/RollingDateRangeFilter.tsx @@ -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 + } +} + +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): void => { + const newValue = event.target.value ? parseFloat(event.target.value) : undefined + setCounter(newValue) + } + + return ( + +
+

In the last

+
e.stopPropagation()}> + + - + + + + + + +
+ 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" + /> +
+
+ ) +} diff --git a/frontend/src/lib/components/DateFilter/dateFilterLogic.test.ts b/frontend/src/lib/components/DateFilter/dateFilterLogic.test.ts new file mode 100644 index 0000000000..6118ee0fd7 --- /dev/null +++ b/frontend/src/lib/components/DateFilter/dateFilterLogic.test.ts @@ -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 + + 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, + }) + }) +}) diff --git a/frontend/src/lib/components/DateFilter/dateFilterLogic.ts b/frontend/src/lib/components/DateFilter/dateFilterLogic.ts new file mode 100644 index 0000000000..b9272165b9 --- /dev/null +++ b/frontend/src/lib/components/DateFilter/dateFilterLogic.ts @@ -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([ + 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) + } + }, + })), +]) diff --git a/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.test.ts b/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.test.ts new file mode 100644 index 0000000000..4dc1861fbf --- /dev/null +++ b/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.test.ts @@ -0,0 +1,42 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { rollingDateRangeFilterLogic } from './rollingDateRangeFilterLogic' + +describe('rollingDateRangeFilterLogic', () => { + let logic: ReturnType + + 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', + }) + }) +}) diff --git a/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.ts b/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.ts new file mode 100644 index 0000000000..bd3eab4b66 --- /dev/null +++ b/frontend/src/lib/components/DateFilter/rollingDateRangeFilterLogic.ts @@ -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([ + 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) + }, + })), +]) diff --git a/frontend/src/lib/components/IntervalFilter/intervalFilterLogic.ts b/frontend/src/lib/components/IntervalFilter/intervalFilterLogic.ts index b1cb430e7e..4a0acfd754 100644 --- a/frontend/src/lib/components/IntervalFilter/intervalFilterLogic.ts +++ b/frontend/src/lib/components/IntervalFilter/intervalFilterLogic.ts @@ -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({ props: {} as InsightLogicProps, @@ -12,17 +15,56 @@ export const intervalFilterLogic = kea({ 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], diff --git a/frontend/src/lib/components/LemonButton/LemonButton.tsx b/frontend/src/lib/components/LemonButton/LemonButton.tsx index 4246071eac..700df33106 100644 --- a/frontend/src/lib/components/LemonButton/LemonButton.tsx +++ b/frontend/src/lib/components/LemonButton/LemonButton.tsx @@ -20,6 +20,7 @@ export interface LemonButtonPropsBase extends Omit, 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 ( { setPopupVisible(false) onClickOutside?.(e) }} onClickInside={(e) => { + e.stopPropagation() closeOnClickInside && setPopupVisible(false) onClickInside?.(e) }} diff --git a/frontend/src/lib/components/LemonSelect.tsx b/frontend/src/lib/components/LemonSelect.tsx index 8be3fff361..47e994af00 100644 --- a/frontend/src/lib/components/LemonSelect.tsx +++ b/frontend/src/lib/components/LemonSelect.tsx @@ -30,6 +30,11 @@ export interface LemonSelectProps dropdownMaxContentWidth?: boolean dropdownPlacement?: PopupProps['placement'] allowClear?: boolean + className?: string + popup?: { + className?: string + ref?: React.MutableRefObject + } } export function LemonSelect({ @@ -41,6 +46,8 @@ export function LemonSelect({ dropdownMaxContentWidth = false, dropdownPlacement, allowClear = false, + className, + popup, ...buttonProps }: LemonSelectProps): JSX.Element { const [localValue, setLocalValue] = useState(value) @@ -82,9 +89,11 @@ export function LemonSelect({ onMouseLeave={() => setHover(false)} > ( - <> + {section.label ? ( typeof section.label === 'string' ? (
{section.label}
@@ -116,11 +125,12 @@ export function LemonSelect({ ))} {i < sections.length - 1 ? : null} - +
)), sameWidth: dropdownMatchSelectWidth, placement: dropdownPlacement, actionable: true, + className: popup?.className, maxContentWidth: dropdownMaxContentWidth, }} icon={localValue && allOptions[localValue]?.icon} diff --git a/frontend/src/lib/components/Popup/Popup.tsx b/frontend/src/lib/components/Popup/Popup.tsx index 7ce6c75f37..c03ec36bf3 100644 --- a/frontend/src/lib/components/Popup/Popup.tsx +++ b/frontend/src/lib/components/Popup/Popup.tsx @@ -16,6 +16,7 @@ import { } from '@floating-ui/react-dom-interactions' export interface PopupProps { + ref?: React.MutableRefObject visible?: boolean onClickOutside?: (event: Event) => void onClickInside?: MouseEventHandler @@ -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 | 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({ - 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( + ( + { + 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({ + 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( - - -
} - style={{ position: strategy, top: y ?? 0, left: x ?? 0 }} - onClick={onClickInside} - > -
{overlay}
-
-
-
, - document.body - )} - - ) -} + return ( + <> + {clonedChildren} + {ReactDOM.createPortal( + + +
} + style={{ position: strategy, top: y ?? 0, left: x ?? 0, ...style }} + onClick={onClickInside} + > +
+ {overlay} +
+
+
+
, + getPopupContainer ? getPopupContainer() : document.body + )} + + ) + } +) diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 8f9d28fd70..6ff95d0435 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -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). */ diff --git a/frontend/src/lib/hooks/useOutsideClickHandler.ts b/frontend/src/lib/hooks/useOutsideClickHandler.ts index e041cbc222..aaaf857d4e 100644 --- a/frontend/src/lib/hooks/useOutsideClickHandler.ts +++ b/frontend/src/lib/hooks/useOutsideClickHandler.ts @@ -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 | React.MutableRefObject[], + refOrRefs: string | React.MutableRefObject | (React.MutableRefObject | 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) } diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index 90495f3d0c..f68bafb332 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -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') diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index e606a3cf32..e6b220dba8 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -710,76 +710,183 @@ export function determineDifferenceType( const DATE_FORMAT = 'D MMM YYYY' -export const dateMapping: Record = { - 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 = 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 { diff --git a/frontend/src/scenes/insights/Insight.scss b/frontend/src/scenes/insights/Insight.scss index 590029b4bc..2f3aae6c4f 100644 --- a/frontend/src/scenes/insights/Insight.scss +++ b/frontend/src/scenes/insights/Insight.scss @@ -137,6 +137,8 @@ span.filter { font-size: 14px; + display: flex; + align-items: center; &:not(:last-child) { margin-right: 0.5rem; diff --git a/frontend/src/scenes/insights/InsightDisplayConfig.tsx b/frontend/src/scenes/insights/InsightDisplayConfig.tsx index 4d95b2d81c..a08386c30d 100644 --- a/frontend/src/scenes/insights/InsightDisplayConfig.tsx +++ b/frontend/src/scenes/insights/InsightDisplayConfig.tsx @@ -90,7 +90,7 @@ export function InsightDisplayConfig({ filters, activeView, disableTable }: Insi Date range ( diff --git a/frontend/src/scenes/insights/insightCommandLogic.ts b/frontend/src/scenes/insights/insightCommandLogic.ts index e1ee6e40c1..7380f97dd8 100644 --- a/frontend/src/scenes/insights/insightCommandLogic.ts +++ b/frontend/src/scenes/insights/insightCommandLogic.ts @@ -33,7 +33,7 @@ export const insightCommandLogic = kea({ compareFilterLogic(props).actions.toggleCompare() }, }, - ...Object.entries(dateMapping).map(([key, { values }]) => ({ + ...dateMapping.map(({ key, values }) => ({ icon: RiseOutlined, display: `Set Time Range to ${key}`, executor: () => { diff --git a/frontend/src/scenes/insights/insightLogic.ts b/frontend/src/scenes/insights/insightLogic.ts index 356089aa90..fe39aa490d 100644 --- a/frontend/src/scenes/insights/insightLogic.ts +++ b/frontend/src/scenes/insights/insightLogic.ts @@ -247,6 +247,7 @@ export const insightLogic = kea({ cache.abortController = new AbortController() const { filters } = values + const insight = (filters.insight as InsightType | undefined) || InsightType.TRENDS const params = { ...filters, ...(refresh ? { refresh: true } : {}) } diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 15a9a6d6b3..8ec5936266 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -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 { ))} - - Last modified: +
+ Last modified: setSavedInsightsFilters({ dateFrom: fromDate, dateTo: toDate }) } + makeLabel={(key) => ( + <> + + {key} + + )} /> - +
{tab !== SavedInsightsTabs.Yours ? ( Created by: diff --git a/frontend/src/scenes/session-recordings/SessionRecordingsTable.tsx b/frontend/src/scenes/session-recordings/SessionRecordingsTable.tsx index ab5348b6c6..c30151af04 100644 --- a/frontend/src/scenes/session-recordings/SessionRecordingsTable.tsx +++ b/frontend/src/scenes/session-recordings/SessionRecordingsTable.tsx @@ -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'] }, + ]} /> diff --git a/frontend/src/toolbar/button/ToolbarButton.scss b/frontend/src/toolbar/button/ToolbarButton.scss index aeda4ad078..ee7a3f0308 100644 --- a/frontend/src/toolbar/button/ToolbarButton.scss +++ b/frontend/src/toolbar/button/ToolbarButton.scss @@ -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); } diff --git a/frontend/src/toolbar/stats/HeatmapStats.tsx b/frontend/src/toolbar/stats/HeatmapStats.tsx index 5539a9ebce..6ba30904d2 100644 --- a/frontend/src/toolbar/stats/HeatmapStats.tsx +++ b/frontend/src/toolbar/stats/HeatmapStats.tsx @@ -32,6 +32,7 @@ export function HeatmapStats(): JSX.Element { onChange={(date_from, date_to) => setHeatmapFilter({ date_from, date_to })} getPopupContainer={getShadowRootPopupContainer} /> + {heatmapLoading ? : null}
diff --git a/frontend/src/toolbar/styles.scss b/frontend/src/toolbar/styles.scss index a34f63441f..f54127c174 100644 --- a/frontend/src/toolbar/styles.scss +++ b/frontend/src/toolbar/styles.scss @@ -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; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4434716ab0..30184a303b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { diff --git a/posthog/api/insight.py b/posthog/api/insight.py index 01fc26dd70..aa8493b719 100644 --- a/posthog/api/insight.py +++ b/posthog/api/insight.py @@ -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 diff --git a/posthog/api/test/test_dashboard.py b/posthog/api/test/test_dashboard.py index e5882d8f43..18a188096f 100644 --- a/posthog/api/test/test_dashboard.py +++ b/posthog/api/test/test_dashboard.py @@ -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")) diff --git a/posthog/api/test/test_insight.py b/posthog/api/test/test_insight.py index 0e0a49c090..f8d58d972e 100644 --- a/posthog/api/test/test_insight.py +++ b/posthog/api/test/test_insight.py @@ -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) diff --git a/posthog/models/filters/mixins/common.py b/posthog/models/filters/mixins/common.py index f132b3200b..81f7f3cdd6 100644 --- a/posthog/models/filters/mixins/common.py +++ b/posthog/models/filters/mixins/common.py @@ -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( diff --git a/posthog/models/filters/test/test_retention_filter.py b/posthog/models/filters/test/test_retention_filter.py index 37716d4f02..f97d83d7b1 100644 --- a/posthog/models/filters/test/test_retention_filter.py +++ b/posthog/models/filters/test/test_retention_filter.py @@ -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", }, ) diff --git a/posthog/tasks/test/test_update_cache.py b/posthog/tasks/test/test_update_cache.py index a5cda7bd08..6eb1cec652 100644 --- a/posthog/tasks/test/test_update_cache.py +++ b/posthog/tasks/test/test_update_cache.py @@ -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( diff --git a/posthog/utils.py b/posthog/utils.py index 6a87f83eab..36327afadc 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -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}"