diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--dark.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--dark.png new file mode 100644 index 0000000000000..56fb681c1295e Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--light.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--light.png new file mode 100644 index 0000000000000..732914a5c71ea Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--from-today--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--dark.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--dark.png new file mode 100644 index 0000000000000..92619d1b50c02 Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--light.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--light.png new file mode 100644 index 0000000000000..b2f945f37289c Binary files /dev/null and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar--show-time--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--dark.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--dark.png index 86e2d8c764b82..280afb86c352c 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--light.png b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--light.png index 4a6a3820eeb07..03ac0e2a2ffdc 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--light.png and b/frontend/__snapshots__/lemon-ui-lemon-calendar-lemon-calendar-select--lemon-calendar-select--light.png differ diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.scss b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.scss index ed6baaf7e8955..1c4c9dfe3573e 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.scss +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.scss @@ -2,6 +2,10 @@ --lemon-calendar-row-gap: 2px; --lemon-calendar-day-width: 40px; --lemon-calendar-today-radius: 2px; + --lemon-calendar-time-column-width: 50px; + + // Tricky: needs to match the equivalent height button from LemonButton.scss + --lemon-calendar-time-button-height: 2.3125rem; .LemonCalendar__month > thead > tr:first-child > th, .LemonCalendar__month > tbody > tr > td { @@ -50,4 +54,25 @@ .LemonCalendar__range--boundary { background-color: var(--glass-border-3000); } + + &--with-time { + padding-right: calc(3 * var(--lemon-calendar-time-column-width)); + } + + .LemonCalendar__time { + & > div { + width: var(--lemon-calendar-time-column-width); + + &.ScrollableShadows { + & .ScrollableShadows__inner { + scrollbar-width: none; + scroll-behavior: smooth; + } + } + } + + &--scroll-spacer { + height: calc(100% - var(--lemon-calendar-time-button-height)); + } + } } diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.stories.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.stories.tsx index ede3a38ee7bdc..de9f071db6535 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.stories.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.stories.tsx @@ -77,3 +77,13 @@ export const SundayFirst: Story = BasicTemplate.bind({}) SundayFirst.args = { weekStartDay: 0, } + +export const ShowTime: Story = BasicTemplate.bind({}) +ShowTime.args = { + showTime: true, +} + +export const FromToday: Story = BasicTemplate.bind({}) +FromToday.args = { + fromToday: true, +} diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.test.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.test.tsx index ea52b8c681552..fae0ec6d3504a 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.test.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.test.tsx @@ -1,6 +1,7 @@ import { render, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { dayjs } from 'lib/dayjs' +import { range } from 'lib/utils' import { getAllByDataAttr, getByDataAttr } from '~/test/byDataAttr' @@ -183,4 +184,40 @@ describe('LemonCalendar', () => { expect(fourteen).toBeDefined() expect(fourteen.className.split(' ')).toContain('yolo') }) + + test('calls getLemonButtonTimeProps for each time', async () => { + const calls: any = [] + render( + { + calls.push([unit, value]) + return {} + }} + showTime + /> + ) + const minutes = range(0, 60).map((num) => ['m', num]) + expect(calls.length).toBe(74) + expect(calls).toEqual([ + ...[ + ['h', 12], + ['h', 1], + ['h', 2], + ['h', 3], + ['h', 4], + ['h', 5], + ['h', 6], + ['h', 7], + ['h', 8], + ['h', 9], + ['h', 10], + ['h', 11], + ], + ...minutes, + ...[ + ['a', 'am'], + ['a', 'pm'], + ], + ]) + }) }) diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.tsx index e4e15e050bf05..17c3fc900502d 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.tsx @@ -2,11 +2,12 @@ import './LemonCalendar.scss' import clsx from 'clsx' import { useValues } from 'kea' +import { ScrollableShadows } from 'lib/components/ScrollableShadows/ScrollableShadows' import { dayjs } from 'lib/dayjs' import { IconChevronLeft, IconChevronRight } from 'lib/lemon-ui/icons' import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' import { range } from 'lib/utils' -import { useEffect, useState } from 'react' +import { forwardRef, Ref, useEffect, useState } from 'react' import { teamLogic } from 'scenes/teamLogic' export interface LemonCalendarProps { @@ -18,10 +19,16 @@ export interface LemonCalendarProps { onLeftmostMonthChanged?: (date: dayjs.Dayjs) => void /** Use custom LemonButton properties for each date */ getLemonButtonProps?: (opts: GetLemonButtonPropsOpts) => LemonButtonProps + /** Use custom LemonButton properties for each date */ + getLemonButtonTimeProps?: (opts: GetLemonButtonTimePropsOpts) => LemonButtonProps /** Number of months */ months?: number /** 0 or unset for Sunday, 1 for Monday. */ weekStartDay?: number + /** Show a time picker */ + showTime?: boolean + /** Only allow upcoming dates */ + fromToday?: boolean } export interface GetLemonButtonPropsOpts { @@ -30,10 +37,17 @@ export interface GetLemonButtonPropsOpts { dayIndex: number weekIndex: number } +export interface GetLemonButtonTimePropsOpts { + unit: 'h' | 'm' | 'a' + value: number | string +} const dayLabels = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] -export function LemonCalendar(props: LemonCalendarProps): JSX.Element { +export const LemonCalendar = forwardRef(function LemonCalendar( + { showTime = false, ...props }: LemonCalendarProps, + ref: Ref +): JSX.Element { const { weekStartDay: teamWeekStartDay } = useValues(teamLogic) const months = Math.max(props.months ?? 1, 1) @@ -47,7 +61,11 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element { }, [props.leftmostMonth]) return ( -
+
{range(0, months).map((month) => { const startOfMonth = leftmostMonth.add(month, 'month').startOf('month') const endOfMonth = startOfMonth.endOf('month') @@ -112,12 +130,18 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element { {range(0, 7).map((day) => { const date = firstDay.add(week * 7 + day, 'day') + const pastDate = date.isBefore(today) const defaultProps: LemonButtonProps = { className: clsx('flex-col', { 'opacity-25': date.isBefore(startOfMonth) || date.isAfter(endOfMonth), LemonCalendar__today: date.isSame(today, 'd'), }), + disabledReason: + props.fromToday && pastDate + ? 'Cannot select dates in the past' + : undefined, } + const buttonProps = props.getLemonButtonProps?.({ dayIndex: day, @@ -145,6 +169,47 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element { ) })} + {showTime && ( +
+ + {[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((hour) => { + const buttonProps = props.getLemonButtonTimeProps?.({ + unit: 'h', + value: hour, + }) + + return ( + + {String(hour).padStart(2, '0')} + + ) + })} +
+ + + {range(0, 60).map((minute) => { + const buttonProps = props.getLemonButtonTimeProps?.({ + unit: 'm', + value: minute, + }) + return ( + + {String(minute).padStart(2, '0')} + + ) + })} +
+ +
+ + AM + + + PM + +
+
+ )}
) -} +}) diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.stories.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.stories.tsx index 1c6bff250dd2b..89c9f04589296 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.stories.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.stories.tsx @@ -34,6 +34,7 @@ const BasicTemplate: StoryFn = (props: LemonCalendar setValue(value) setVisible(false) }} + showTime onClose={() => setVisible(false)} /> } diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.test.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.test.tsx index dd44efa6634cc..5f2c9bbe8773b 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.test.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.test.tsx @@ -1,11 +1,13 @@ import { render, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { dayjs } from 'lib/dayjs' -import { LemonCalendarSelect } from 'lib/lemon-ui/LemonCalendar/LemonCalendarSelect' +import { getTimeElement, LemonCalendarSelect } from 'lib/lemon-ui/LemonCalendar/LemonCalendarSelect' import { useState } from 'react' import { getByDataAttr } from '~/test/byDataAttr' +import { GetLemonButtonTimePropsOpts } from './LemonCalendar' + describe('LemonCalendarSelect', () => { test('select various dates', async () => { const onClose = jest.fn() @@ -50,4 +52,65 @@ describe('LemonCalendarSelect', () => { userEvent.click(getByDataAttr(container, 'lemon-calendar-select-cancel')) expect(onClose).toHaveBeenCalled() }) + + test('select various times', async () => { + const onClose = jest.fn() + const onChange = jest.fn() + window.HTMLElement.prototype.scrollIntoView = jest.fn() + + jest.useFakeTimers().setSystemTime(new Date('2023-01-10 17:22:08')) + + function TestSelect(): JSX.Element { + const [value, setValue] = useState(null) + return ( + { + setValue(value) + onChange(value) + }} + showTime + /> + ) + } + const { container } = render() + + async function clickOnDate(day: string): Promise { + const element = container.querySelector('.LemonCalendar__month') as HTMLElement + if (element) { + userEvent.click(await within(element).findByText(day)) + userEvent.click(getByDataAttr(container, 'lemon-calendar-select-apply')) + } + } + + async function clickOnTime(props: GetLemonButtonTimePropsOpts): Promise { + const element = getTimeElement(container.querySelector('.LemonCalendar__time'), props) + if (element) { + userEvent.click(element) + userEvent.click(getByDataAttr(container, 'lemon-calendar-select-apply')) + } + } + + // click on hour 8 + await clickOnDate('15') + // sets the date to 15, hour and minutes to current time, and seconds to 0 + expect(onChange).toHaveBeenCalledWith(dayjs('2023-01-15T17:22:00.000Z')) + + // click on minute 42 + await clickOnTime({ unit: 'm', value: 42 }) + // sets the minutes but leaves all other values unchanged + expect(onChange).toHaveBeenCalledWith(dayjs('2023-01-15T17:42:00.000Z')) + + // click on 'am' + await clickOnTime({ unit: 'a', value: 'am' }) + // subtracts 12 hours from the time + expect(onChange).toHaveBeenCalledWith(dayjs('2023-01-15T05:42:00.000Z')) + + // click on hour 8 + await clickOnTime({ unit: 'h', value: 8 }) + // only changes the hour + expect(onChange).toHaveBeenCalledWith(dayjs('2023-01-15T08:42:00.000Z')) + }) }) diff --git a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.tsx b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.tsx index ab25876bac452..85b8a3bcc2086 100644 --- a/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.tsx +++ b/frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendarSelect.tsx @@ -1,20 +1,101 @@ import { IconX } from '@posthog/icons' import { dayjs } from 'lib/dayjs' import { LemonButton, LemonButtonWithSideActionProps, SideAction } from 'lib/lemon-ui/LemonButton' -import { LemonCalendar } from 'lib/lemon-ui/LemonCalendar/LemonCalendar' -import { useState } from 'react' +import { GetLemonButtonTimePropsOpts, LemonCalendar } from 'lib/lemon-ui/LemonCalendar/LemonCalendar' +import { useEffect, useMemo, useRef, useState } from 'react' import { Popover } from '../Popover' +function timeDataAttr({ unit, value }: GetLemonButtonTimePropsOpts): string { + return `${value}-${unit}` +} + +export function getTimeElement( + parent: HTMLElement | null, + props: GetLemonButtonTimePropsOpts +): HTMLDivElement | undefined | null { + return parent?.querySelector(`[data-attr="${timeDataAttr(props)}"]`) +} +function scrollToTimeElement( + calendarEl: HTMLDivElement | null, + props: GetLemonButtonTimePropsOpts, + skipAnimation: boolean +): void { + getTimeElement(calendarEl, props)?.scrollIntoView({ + block: 'start', + inline: 'nearest', + behavior: skipAnimation ? ('instant' as ScrollBehavior) : 'smooth', + }) +} + export interface LemonCalendarSelectProps { value?: dayjs.Dayjs | null onChange: (date: dayjs.Dayjs) => void months?: number onClose?: () => void + showTime?: boolean + fromToday?: boolean } -export function LemonCalendarSelect({ value, onChange, months, onClose }: LemonCalendarSelectProps): JSX.Element { - const [selectValue, setSelectValue] = useState(value ? value.startOf('day') : null) +export function LemonCalendarSelect({ + value, + onChange, + months, + onClose, + showTime, + fromToday, +}: LemonCalendarSelectProps): JSX.Element { + const calendarRef = useRef(null) + const [selectValue, setSelectValue] = useState( + value ? (showTime ? value : value.startOf('day')) : null + ) + + const isAM = useMemo(() => selectValue?.format('a') === 'am', [selectValue]) + + const scrollToTime = (date: dayjs.Dayjs, skipAnimation: boolean): void => { + const calendarEl = calendarRef.current + if (calendarEl && date) { + const hour = isAM ? date.hour() : date.hour() - 12 + scrollToTimeElement(calendarEl, { unit: 'h', value: hour }, skipAnimation) + scrollToTimeElement(calendarEl, { unit: 'm', value: date.minute() }, skipAnimation) + } + } + + const onDateClick = (date: dayjs.Dayjs | null): void => { + const now = dayjs() + + if (date) { + date = showTime ? date.hour(selectValue === null ? now.hour() : selectValue.hour()) : date.startOf('hour') + date = showTime + ? date.minute(selectValue === null ? now.minute() : selectValue.minute()) + : date.startOf('minute') + scrollToTime(date, true) + } + + setSelectValue(date) + } + + useEffect(() => { + if (selectValue) { + scrollToTime(selectValue, true) + } + }, []) + + const onTimeClick = (props: GetLemonButtonTimePropsOpts): void => { + const { value, unit } = props + + let date = selectValue || dayjs().startOf('day') + if (unit === 'h') { + date = date.hour(date.format('a') === 'am' ? Number(value) : Number(value) + 12) + } else if (unit === 'm') { + date = date.minute(Number(value)) + } else if (unit === 'a') { + date = value === 'am' ? date.subtract(12, 'hour') : date.add(12, 'hour') + } + + scrollToTime(date, false) + setSelectValue(date) + } return (
@@ -24,19 +105,33 @@ export function LemonCalendarSelect({ value, onChange, months, onClose }: LemonC } size="small" onClick={onClose} aria-label="close" noPadding /> )}
-
- { - if (date.isSame(selectValue, 'd')) { - return { ...props, status: 'default', type: 'primary' } - } - return props - }} - /> -
+ { + if (date.isSame(selectValue, 'd')) { + return { ...props, status: 'default', type: 'primary' } + } + return props + }} + getLemonButtonTimeProps={(props) => { + const selected = selectValue ? selectValue.format(props.unit) : null + return { + active: selected === String(props.value), + className: 'rounded-none', + 'data-attr': timeDataAttr(props), + onClick: () => { + if (selected != props.value) { + onTimeClick(props) + } + }, + } + }} + showTime={showTime} + fromToday={fromToday} + />
Cancel @@ -100,7 +195,7 @@ export function LemonCalendarSelectInput( } {...props.buttonProps} > - {props.value?.format('MMMM D, YYYY') ?? placeholder ?? 'Select date'} + {props.value?.format(`MMMM D, YYYY${props.showTime && ' h:mm A'}`) ?? placeholder ?? 'Select date'} ) diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx index 561efcf9de9f9..f9ae2a2b3a73b 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx @@ -51,7 +51,7 @@ interface LemonInputPropsBase } export interface LemonInputPropsText extends LemonInputPropsBase { - type?: 'text' | 'email' | 'search' | 'url' | 'password' + type?: 'text' | 'email' | 'search' | 'url' | 'password' | 'time' value?: string defaultValue?: string onChange?: (newValue: string) => void diff --git a/frontend/src/scenes/feature-flags/FeatureFlagSchedule.tsx b/frontend/src/scenes/feature-flags/FeatureFlagSchedule.tsx index a49512e672d88..e729b3e753e3e 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagSchedule.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagSchedule.tsx @@ -1,5 +1,6 @@ import { LemonButton, + LemonCalendarSelectInput, LemonCheckbox, LemonDivider, LemonSelect, @@ -10,7 +11,6 @@ import { LemonTagType, } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { DatePicker } from 'lib/components/DatePicker' import { dayjs } from 'lib/dayjs' import { More } from 'lib/lemon-ui/LemonButton/More' import { atColumn, createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' @@ -158,21 +158,14 @@ export default function FeatureFlagSchedule(): JSX.Element { ]} />
-
+
Date and time
- { - const now = new Date() - return dateMarker.toDate().getTime() < now.getTime() - }} + setScheduleDateMarker(value)} - className="h-10 w-60" - allowClear={false} + placeholder="Select date" showTime - showSecond={false} - format={DAYJS_FORMAT} - showNow={false} + fromToday />