Skip to content

Commit

Permalink
feat: LemonCalendar with time picker (#21675)
Browse files Browse the repository at this point in the history
  • Loading branch information
daibhin authored Apr 23, 2024
1 parent 251dabe commit 882af87
Show file tree
Hide file tree
Showing 15 changed files with 325 additions and 36 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
}
}
10 changes: 10 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
37 changes: 37 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(
<LemonCalendar
getLemonButtonTimeProps={({ unit, value }) => {
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'],
],
])
})
})
73 changes: 69 additions & 4 deletions frontend/src/lib/lemon-ui/LemonCalendar/LemonCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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<HTMLDivElement>
): JSX.Element {
const { weekStartDay: teamWeekStartDay } = useValues(teamLogic)

const months = Math.max(props.months ?? 1, 1)
Expand All @@ -47,7 +61,11 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element {
}, [props.leftmostMonth])

return (
<div className="LemonCalendar flex items-start gap-4" data-attr="lemon-calendar">
<div
ref={ref}
className={clsx('LemonCalendar relative flex items-start gap-4', showTime && 'LemonCalendar--with-time')}
data-attr="lemon-calendar"
>
{range(0, months).map((month) => {
const startOfMonth = leftmostMonth.add(month, 'month').startOf('month')
const endOfMonth = startOfMonth.endOf('month')
Expand Down Expand Up @@ -112,12 +130,18 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element {
<tr key={week} data-attr="lemon-calendar-week">
{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,
Expand Down Expand Up @@ -145,6 +169,47 @@ export function LemonCalendar(props: LemonCalendarProps): JSX.Element {
</table>
)
})}
{showTime && (
<div className="LemonCalendar__time absolute top-0 bottom-0 right-0 flex divide-x border-l">
<ScrollableShadows direction="vertical">
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((hour) => {
const buttonProps = props.getLemonButtonTimeProps?.({
unit: 'h',
value: hour,
})

return (
<LemonButton fullWidth key={hour} {...buttonProps}>
<span className="w-full text-center px-2">{String(hour).padStart(2, '0')}</span>
</LemonButton>
)
})}
<div className="LemonCalendar__time--scroll-spacer" />
</ScrollableShadows>
<ScrollableShadows direction="vertical">
{range(0, 60).map((minute) => {
const buttonProps = props.getLemonButtonTimeProps?.({
unit: 'm',
value: minute,
})
return (
<LemonButton fullWidth key={minute} {...buttonProps}>
<span className="w-full text-center px-2">{String(minute).padStart(2, '0')}</span>
</LemonButton>
)
})}
<div className="LemonCalendar__time--scroll-spacer" />
</ScrollableShadows>
<div>
<LemonButton fullWidth {...props.getLemonButtonTimeProps?.({ unit: 'a', value: 'am' })}>
<span className="w-full text-center">AM</span>
</LemonButton>
<LemonButton fullWidth {...props.getLemonButtonTimeProps?.({ unit: 'a', value: 'pm' })}>
<span className="w-full text-center">PM</span>
</LemonButton>
</div>
</div>
)}
</div>
)
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const BasicTemplate: StoryFn<typeof LemonCalendarSelect> = (props: LemonCalendar
setValue(value)
setVisible(false)
}}
showTime
onClose={() => setVisible(false)}
/>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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<dayjs.Dayjs | null>(null)
return (
<LemonCalendarSelect
months={1}
value={value}
onClose={onClose}
onChange={(value) => {
setValue(value)
onChange(value)
}}
showTime
/>
)
}
const { container } = render(<TestSelect />)

async function clickOnDate(day: string): Promise<void> {
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<void> {
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'))
})
})
Loading

0 comments on commit 882af87

Please sign in to comment.