mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
<img width="1079" height="566" alt="image" src="https://github.com/user-attachments/assets/b6f360e9-dfcf-4717-86db-1fc9acc4ae6a" /> ## Problem Project logs only exposed time-range selection through the sidebar. ## Solution Add a Time range property to the filter bar using the sidebar’s picker, preset labels, and date formatting. Keep it synchronized with the sidebar and timeline, including retention checks. ## Review instructions 1. Open Project Logs and select **Time range** in the filter bar. Choose a preset and confirm the sidebar and logs update. 2. Select a custom range and confirm its label matches the sidebar’s date formatting. 3. Change the range in the sidebar or timeline and confirm the filter bar updates. Remove the time-range pill and confirm the range resets without removing other filters. 4. Select a range beyond the plan’s log retention and confirm the upgrade prompt appears. Validation: 67 focused tests passed, Studio typecheck passed, and changed Studio files passed lint. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [ ] Docs authoring skills (not applicable: no docs-site topic changes) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added human-readable filter value labels while preserving the underlying selections. * Added dedicated log time-range filters with preset and custom ranges. * Added inline date-range picker support for flexible layouts. * Added controls to show or hide filter properties based on availability. * Added consistent date-range formatting and custom range support. * **Bug Fixes** * Invalid or incomplete time-range filters are no longer applied. * Time-range filters remain separate from standard column filters. * Clipboard interactions are limited to the popover date-picker variant. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import dayjs from 'dayjs'
|
|
import timezone from 'dayjs/plugin/timezone'
|
|
import utc from 'dayjs/plugin/utc'
|
|
import { HttpResponse } from 'msw'
|
|
import { useState } from 'react'
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
|
import { render } from '../../helpers'
|
|
import { PREVIEWER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants'
|
|
import {
|
|
generateDynamicHelper,
|
|
generateDynamicHelpers,
|
|
generateHelpersFromInput,
|
|
parseCustomInput,
|
|
} from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
|
import {
|
|
LogsDatePicker,
|
|
type DatePickerValue,
|
|
} from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
|
import { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types'
|
|
import { customRender } from '@/tests/lib/custom-render'
|
|
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
|
|
|
|
dayjs.extend(timezone)
|
|
dayjs.extend(utc)
|
|
|
|
beforeEach(() => {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref',
|
|
response: () =>
|
|
HttpResponse.json<APIErrorBody>({ message: 'Project not found' }, { status: 404 }),
|
|
})
|
|
})
|
|
|
|
describe('inline and popover date-picker state', () => {
|
|
const initialRange = {
|
|
from: '2026-08-01T01:02:03.000Z',
|
|
to: '2026-08-02T04:05:06.000Z',
|
|
}
|
|
const selectedRange = {
|
|
from: '2026-09-10T07:08:09.000Z',
|
|
to: '2026-09-11T10:11:12.000Z',
|
|
}
|
|
|
|
test.each(['inline', 'popover'] as const)(
|
|
'%s applies the newly selected helper dates and times',
|
|
async (variant) => {
|
|
const user = userEvent.setup()
|
|
const onSubmit = vi.fn()
|
|
function Picker() {
|
|
const [value, setValue] = useState<DatePickerValue>(initialRange)
|
|
return (
|
|
<LogsDatePicker
|
|
variant={variant}
|
|
value={value}
|
|
helpers={[
|
|
{
|
|
text: 'Selected range',
|
|
calcFrom: () => selectedRange.from,
|
|
calcTo: () => selectedRange.to,
|
|
},
|
|
]}
|
|
onSubmit={(next) => {
|
|
setValue(next)
|
|
onSubmit(next)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
customRender(<Picker />)
|
|
if (variant === 'popover') await user.click(screen.getByRole('button'))
|
|
await user.click(screen.getByText('Selected range'))
|
|
expect(onSubmit).toHaveBeenLastCalledWith({
|
|
...selectedRange,
|
|
isHelper: true,
|
|
text: 'Selected range',
|
|
})
|
|
if (variant === 'popover') {
|
|
await user.click(screen.getByRole('button', { name: 'Selected range' }))
|
|
}
|
|
await user.click(screen.getByRole('button', { name: 'Apply' }))
|
|
expect(onSubmit).toHaveBeenLastCalledWith({ ...selectedRange, isHelper: false })
|
|
}
|
|
)
|
|
|
|
test.each(['inline', 'popover'] as const)(
|
|
'%s only handles document clipboard events when it is a popover',
|
|
async (variant) => {
|
|
const user = userEvent.setup()
|
|
const onSubmit = vi.fn()
|
|
customRender(
|
|
<LogsDatePicker
|
|
variant={variant}
|
|
open
|
|
value={initialRange}
|
|
helpers={[]}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
)
|
|
await navigator.clipboard.writeText('Unrelated text')
|
|
fireEvent.copy(document)
|
|
if (variant === 'popover') {
|
|
await waitFor(async () => {
|
|
expect(await navigator.clipboard.readText()).toBe(JSON.stringify(initialRange))
|
|
})
|
|
} else {
|
|
expect(await navigator.clipboard.readText()).toBe('Unrelated text')
|
|
}
|
|
|
|
await navigator.clipboard.writeText(JSON.stringify(selectedRange))
|
|
fireEvent.paste(document)
|
|
if (variant === 'popover') {
|
|
await waitFor(() => {
|
|
expect(screen.getAllByLabelText('Hours')[0]).toHaveValue(
|
|
new Date(selectedRange.from).getHours().toString()
|
|
)
|
|
})
|
|
}
|
|
await user.click(screen.getByRole('button', { name: 'Apply' }))
|
|
expect(onSubmit).toHaveBeenLastCalledWith({
|
|
...(variant === 'popover' ? selectedRange : initialRange),
|
|
isHelper: false,
|
|
})
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('parseCustomInput', () => {
|
|
test('returns invalid for empty input', () => {
|
|
expect(parseCustomInput('')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput(' ')).toEqual({ type: 'invalid' })
|
|
})
|
|
|
|
test('parses number only input', () => {
|
|
expect(parseCustomInput('25')).toEqual({ type: 'number', value: 25 })
|
|
expect(parseCustomInput(' 10 ')).toEqual({ type: 'number', value: 10 })
|
|
})
|
|
|
|
test('parses number with unit letter', () => {
|
|
expect(parseCustomInput('2h')).toEqual({ type: 'unit', value: 2, unit: 'hour' })
|
|
expect(parseCustomInput('30m')).toEqual({ type: 'unit', value: 30, unit: 'minute' })
|
|
expect(parseCustomInput('7d')).toEqual({ type: 'unit', value: 7, unit: 'day' })
|
|
})
|
|
|
|
test('parses number with space and unit letter', () => {
|
|
expect(parseCustomInput('2 h')).toEqual({ type: 'unit', value: 2, unit: 'hour' })
|
|
expect(parseCustomInput('30 m')).toEqual({ type: 'unit', value: 30, unit: 'minute' })
|
|
expect(parseCustomInput('7 d')).toEqual({ type: 'unit', value: 7, unit: 'day' })
|
|
})
|
|
|
|
test('parses number with full unit name prefix', () => {
|
|
expect(parseCustomInput('2hour')).toEqual({ type: 'unit', value: 2, unit: 'hour' })
|
|
expect(parseCustomInput('2hours')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('4day')).toEqual({ type: 'unit', value: 4, unit: 'day' })
|
|
expect(parseCustomInput('4days')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('30min')).toEqual({ type: 'unit', value: 30, unit: 'minute' })
|
|
expect(parseCustomInput('30minute')).toEqual({ type: 'unit', value: 30, unit: 'minute' })
|
|
})
|
|
|
|
test('is case insensitive', () => {
|
|
expect(parseCustomInput('2H')).toEqual({ type: 'unit', value: 2, unit: 'hour' })
|
|
expect(parseCustomInput('30M')).toEqual({ type: 'unit', value: 30, unit: 'minute' })
|
|
expect(parseCustomInput('7D')).toEqual({ type: 'unit', value: 7, unit: 'day' })
|
|
})
|
|
|
|
test('returns invalid for non-matching unit', () => {
|
|
expect(parseCustomInput('2x')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('2yoie')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('abc')).toEqual({ type: 'invalid' })
|
|
})
|
|
|
|
test('returns invalid for zero or negative', () => {
|
|
expect(parseCustomInput('0')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('-5')).toEqual({ type: 'invalid' })
|
|
})
|
|
|
|
test('returns invalid for amounts that fall outside the representable date range', () => {
|
|
expect(parseCustomInput('999999999')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('999999999d')).toEqual({ type: 'invalid' })
|
|
expect(parseCustomInput('99999999')).toEqual({ type: 'number', value: 99999999 })
|
|
})
|
|
})
|
|
|
|
describe('generateDynamicHelper', () => {
|
|
test('generates helper with correct text', () => {
|
|
const helper = generateDynamicHelper(5, 'hour')
|
|
expect(helper.text).toBe('Last 5 hours')
|
|
})
|
|
|
|
test('uses singular form when value is 1', () => {
|
|
expect(generateDynamicHelper(1, 'minute').text).toBe('Last 1 minute')
|
|
expect(generateDynamicHelper(1, 'hour').text).toBe('Last 1 hour')
|
|
expect(generateDynamicHelper(1, 'day').text).toBe('Last 1 day')
|
|
})
|
|
|
|
test('uses plural form when value > 1', () => {
|
|
expect(generateDynamicHelper(2, 'minute').text).toBe('Last 2 minutes')
|
|
expect(generateDynamicHelper(2, 'hour').text).toBe('Last 2 hours')
|
|
expect(generateDynamicHelper(2, 'day').text).toBe('Last 2 days')
|
|
})
|
|
|
|
test('calcFrom returns correct ISO string', () => {
|
|
const helper = generateDynamicHelper(1, 'hour')
|
|
const from = dayjs(helper.calcFrom())
|
|
const expectedFrom = dayjs().subtract(1, 'hour')
|
|
expect(from.diff(expectedFrom, 'second')).toBeLessThan(2)
|
|
})
|
|
})
|
|
|
|
describe('generateDynamicHelpers', () => {
|
|
test('generates helpers for every supported relative unit', () => {
|
|
const helpers = generateDynamicHelpers(5)
|
|
expect(helpers).toHaveLength(3)
|
|
expect(helpers[0].text).toBe('Last 5 minutes')
|
|
expect(helpers[1].text).toBe('Last 5 hours')
|
|
expect(helpers[2].text).toBe('Last 5 days')
|
|
})
|
|
})
|
|
|
|
describe('generateHelpersFromInput', () => {
|
|
test('returns null for invalid input', () => {
|
|
expect(generateHelpersFromInput('')).toBeNull()
|
|
expect(generateHelpersFromInput('abc')).toBeNull()
|
|
expect(generateHelpersFromInput('2yoie')).toBeNull()
|
|
})
|
|
|
|
test('returns a helper for every unit for number only input', () => {
|
|
const helpers = generateHelpersFromInput('25')
|
|
expect(helpers).toHaveLength(3)
|
|
expect(helpers![0].text).toBe('Last 25 minutes')
|
|
expect(helpers![1].text).toBe('Last 25 hours')
|
|
expect(helpers![2].text).toBe('Last 25 days')
|
|
})
|
|
|
|
test('returns single helper for unit input', () => {
|
|
const helpers = generateHelpersFromInput('2h')
|
|
expect(helpers).toHaveLength(1)
|
|
expect(helpers![0].text).toBe('Last 2 hours')
|
|
})
|
|
|
|
test('never returns a helper whose calcFrom throws', () => {
|
|
expect(generateHelpersFromInput('999999999')).toBeNull()
|
|
|
|
const helpers = generateHelpersFromInput('99999999')
|
|
expect(helpers).not.toBeNull()
|
|
for (const helper of helpers!) {
|
|
expect(() => helper.calcFrom()).not.toThrow()
|
|
}
|
|
})
|
|
})
|
|
|
|
const mockFn = vi.fn()
|
|
|
|
test('renders warning', async () => {
|
|
const from = dayjs().subtract(10, 'days')
|
|
const to = dayjs()
|
|
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={[]}
|
|
onSubmit={mockFn}
|
|
value={{
|
|
from: from.toISOString(),
|
|
to: to.toISOString(),
|
|
}}
|
|
/>
|
|
)
|
|
await userEvent.click(await screen.findByText(RegExp(from.format('DD MMM'))))
|
|
await screen.findByText(/memory errors/)
|
|
await screen.findByText(RegExp(from.format('DD MMM')))
|
|
})
|
|
|
|
test('renders dates in local time', async () => {
|
|
const from = dayjs().subtract(1, 'days')
|
|
const to = dayjs()
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
|
onSubmit={mockFn}
|
|
value={{
|
|
from: from.toISOString(),
|
|
to: to.toISOString(),
|
|
}}
|
|
/>
|
|
)
|
|
// renders time locally
|
|
await userEvent.click(await screen.findByText(RegExp(from.format('DD MMM'))))
|
|
await screen.findByText(RegExp(from.format('DD MMM')))
|
|
})
|
|
|
|
test('renders datepicker selected dates in local time', async () => {
|
|
const from = dayjs().date(25)
|
|
const to = dayjs().date(27)
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
|
value={{
|
|
from: from.toISOString(),
|
|
to: to.toISOString(),
|
|
}}
|
|
onSubmit={mockFn}
|
|
/>
|
|
)
|
|
// renders time locally
|
|
await userEvent.click(await screen.findByText(RegExp(from.format('DD MMM'))))
|
|
// inputs with local time
|
|
await screen.findByText(
|
|
`${from.format('DD MMM')}, ${from.format('HH:mm')} - ${to.format('DD MMM')}, ${to.format('HH:mm')}`
|
|
)
|
|
// selected date should be in local time
|
|
await screen.findByText('25', { selector: "*[aria-label*='selected'" })
|
|
await screen.findByText('27', { selector: "*[aria-label*='selected'" })
|
|
})
|
|
|
|
test('datepicker onSubmit will return ISO string of selected dates', async () => {
|
|
const mockFn = vi.fn()
|
|
const todayAt1300 = dayjs().hour(13).minute(0).second(0).millisecond(0).toISOString()
|
|
const todayAt2359 = dayjs().hour(23).minute(59).second(59).millisecond(0).toISOString()
|
|
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
|
value={{
|
|
from: todayAt1300,
|
|
to: todayAt2359,
|
|
}}
|
|
onSubmit={mockFn}
|
|
/>
|
|
)
|
|
|
|
// open the datepicker
|
|
userEvent.click(screen.getByText(/13:00/i))
|
|
|
|
const day15 = dayjs().date(15)
|
|
const day16 = day15.add(1, 'day')
|
|
|
|
// Find and click on first date
|
|
const day15Element = await screen.findByText(day15.format('D'))
|
|
userEvent.dblClick(day15Element)
|
|
|
|
// Find and click on second date
|
|
const day16Element = await screen.findByText(day16.format('D'))
|
|
userEvent.click(day16Element)
|
|
|
|
await userEvent.click(await screen.findByText('Apply'))
|
|
expect(mockFn).toBeCalled()
|
|
|
|
const call = mockFn.mock.calls[0][0]
|
|
|
|
expect(call).toMatchObject({
|
|
from: dayjs().date(day15.date()).hour(13).minute(0).second(0).millisecond(0).toISOString(),
|
|
to: dayjs().date(day16.date()).hour(23).minute(59).second(59).millisecond(0).toISOString(),
|
|
})
|
|
})
|
|
|
|
test('disabled helpers are disabled', async () => {
|
|
const helpers: DatetimeHelper[] = [
|
|
{
|
|
text: 'Last 7 days',
|
|
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
|
calcTo: () => '',
|
|
},
|
|
{
|
|
text: 'Last 30 days',
|
|
calcFrom: () => dayjs().subtract(30, 'day').startOf('day').toISOString(),
|
|
calcTo: () => '',
|
|
disabled: true,
|
|
},
|
|
]
|
|
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={helpers}
|
|
onSubmit={mockFn}
|
|
value={{
|
|
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
|
to: '',
|
|
isHelper: true,
|
|
text: 'Last 7 days',
|
|
}}
|
|
/>
|
|
)
|
|
|
|
// click the datepicker
|
|
userEvent.click(screen.getByText('Last 7 days'))
|
|
|
|
const disabledHelperContainer = await screen.findByText(/last 30 days/i)
|
|
|
|
const disabledButton = within(disabledHelperContainer).getByRole('radio', {
|
|
hidden: true,
|
|
})
|
|
|
|
expect(disabledButton.getAttribute('aria-disabled')).toBe('true')
|
|
})
|
|
|
|
test('passing a value prop shows the correct dates in the label', async () => {
|
|
const from = dayjs().subtract(10, 'days')
|
|
const to = dayjs()
|
|
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={[]}
|
|
value={{ from: from.toISOString(), to: to.toISOString() }}
|
|
onSubmit={mockFn}
|
|
/>
|
|
)
|
|
|
|
await screen.findByText(
|
|
`${from.format('DD MMM')}, ${from.format('HH:mm')} - ${to.format('DD MMM')}, ${to.format('HH:mm')}`
|
|
)
|
|
|
|
// change the date
|
|
userEvent.click(await screen.findByText(RegExp(from.format('DD MMM'))))
|
|
userEvent.click(await screen.findByText(RegExp(to.format('DD MMM'))))
|
|
|
|
await screen.findByText(
|
|
`${from.format('DD MMM')}, ${from.format('HH:mm')} - ${to.format('DD MMM')}, ${to.format('HH:mm')}`
|
|
)
|
|
})
|
|
|
|
test('opening with an unparseable date value (legacy epoch-ms its/ite) does not crash', async () => {
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={[]}
|
|
value={{ from: '1784211420000', to: '1784211540000' }}
|
|
onSubmit={mockFn}
|
|
/>
|
|
)
|
|
|
|
await userEvent.click(await screen.findByRole('button'))
|
|
expect(await screen.findByText('Apply')).toBeInTheDocument()
|
|
})
|
|
|
|
test('passing a helper as a value prop shows the helper text in the label', async () => {
|
|
const helper = {
|
|
text: 'Last 7 days',
|
|
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
|
calcTo: () => '',
|
|
}
|
|
|
|
render(
|
|
<LogsDatePicker
|
|
helpers={[helper]}
|
|
value={{
|
|
from: helper.calcFrom(),
|
|
to: helper.calcTo(),
|
|
isHelper: true,
|
|
text: helper.text,
|
|
}}
|
|
onSubmit={mockFn}
|
|
/>
|
|
)
|
|
|
|
await screen.findByText(helper.text)
|
|
})
|