mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
refactor(studio): centralize query sources (#49027)
## Summary - define application-owned database and logs source contracts, defaults, validation, labels, and execution endpoints - extract controlled database and logs parameter controls for reuse outside SQL snippets - adapt the SQL editor to the shared source model without changing snippet behavior - standardize source icons at 16px with a 2px stroke - keep relative logs ranges aligned with the existing date picker units ## To test 1. Open an existing query in the SQL Editor and run it against the database. 2. Switch the query source to Logs, change the time range, and confirm the query still runs as expected. ## Why Explorer queries and notebook query cells need to select an execution source without coupling that source to SQL snippets. This provides the shared registry and controlled UI foundation for those consumers. ## Impact Existing SQL snippets retain their current database/logs routing and session behavior. The registry documents the SQL editor legacy database-selector adapter while new consumers own their identifier inline. The shared Logs date picker remains unchanged; query ranges support its existing minute, hour, and day units. This PR does not add the Explorer query tab itself. ## Validation - pnpm --filter studio typecheck - focused Vitest coverage for the registry, canonical log-range utilities, SQL execution adapters, source filtering, retention locking, custom ranges, and preset selection - pnpm --filter studio run lint:ratchet Component and state tests cover this change per the Studio testing guidance; no E2E test is added. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a unified query-source menu for database queries and logs. * Added custom log time-range selection with calendar support and retention-aware upgrade prompts. * Added consistent source icons and improved database selection handling. * Added support for relative and absolute log time ranges. * **Bug Fixes** * Improved log-range validation, defaults, and current-time handling. * Updated query execution to use the correct source-specific endpoints. * **Tests** * Expanded coverage for query sources, log ranges, menus, and retention behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
09894096e2
commit
cc6fe2100a
21 files changed
+735
-534
No files matched your search
+10
-21
@@ -1,4 +1,4 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { useParams } from 'common'
|
||||
import { Check, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
|
||||
/** The label a database row/summary shows: the primary, or a replica by region + id. */
|
||||
function databaseLabel(identifier: string, region: string, projectRef: string | undefined) {
|
||||
@@ -22,35 +19,27 @@ function databaseLabel(identifier: string, region: string, projectRef: string |
|
||||
return `Read replica (${formatDatabaseRegion(region)} - ${formatDatabaseID(identifier)})`
|
||||
}
|
||||
|
||||
export const DatabaseSubMenu = ({ id }: { id: string }) => {
|
||||
export const DatabaseParametersSubMenu = ({
|
||||
identifier,
|
||||
onIdentifierChange,
|
||||
}: {
|
||||
identifier?: string
|
||||
onIdentifierChange: (identifier: string) => void
|
||||
}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const dbSelector = useDatabaseSelectorStateSnapshot()
|
||||
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
||||
|
||||
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(projectRef ?? ''),
|
||||
''
|
||||
)
|
||||
|
||||
const { data } = useReadReplicasQuery({ projectRef })
|
||||
const databases = (data ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0))
|
||||
.sort((database) => (database.identifier === projectRef ? -1 : 0))
|
||||
|
||||
const selectedDatabaseId =
|
||||
lastSelectedDb.length > 0 ? lastSelectedDb : (dbSelector.selectedDatabaseId ?? projectRef)
|
||||
const selectedDatabaseId = identifier ?? projectRef
|
||||
const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId)
|
||||
|
||||
const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica`
|
||||
|
||||
const handleSelect = (databaseId: string) => {
|
||||
dbSelector.setSelectedDatabaseId(databaseId)
|
||||
setLastSelectedDb(databaseId)
|
||||
sessionSnap.resetResult(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
@@ -71,7 +60,7 @@ export const DatabaseSubMenu = ({ id }: { id: string }) => {
|
||||
key={database.identifier}
|
||||
className="justify-between"
|
||||
disabled={isUnhealthy}
|
||||
onClick={() => handleSelect(database.identifier)}
|
||||
onClick={() => onIdentifierChange(database.identifier)}
|
||||
>
|
||||
<span>{databaseLabel(database.identifier, database.region, projectRef)}</span>
|
||||
{database.identifier === selectedDatabaseId && <Check size={14} />}
|
||||
@@ -0,0 +1,114 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
customDateRangeToLogTimeRange,
|
||||
datePickerValueToLogTimeRange,
|
||||
logTimeRangesEqual,
|
||||
logTimeRangeToDatePickerValue,
|
||||
resolveLogTimeRange,
|
||||
} from './LogTimeRange.utils'
|
||||
import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import {
|
||||
DEFAULT_LOG_TIME_RANGE,
|
||||
type LogTimeRange,
|
||||
} from '@/data/query-sources/query-source-registry'
|
||||
|
||||
describe('LogTimeRange.utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-08T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each([
|
||||
['30m', 30, 'minute'],
|
||||
['2h', 2, 'hour'],
|
||||
['7d', 7, 'day'],
|
||||
] as const)('round-trips the picker helper for %s', (_, amount, unit) => {
|
||||
const helper = generateDynamicHelper(amount, unit)
|
||||
const pickerValue = {
|
||||
from: helper.calcFrom(),
|
||||
to: helper.calcTo(),
|
||||
isHelper: true,
|
||||
text: helper.text,
|
||||
}
|
||||
const range: LogTimeRange = { type: 'relative', amount, unit }
|
||||
|
||||
expect(datePickerValueToLogTimeRange(pickerValue)).toEqual(range)
|
||||
expect(logTimeRangeToDatePickerValue(range)).toEqual(pickerValue)
|
||||
})
|
||||
|
||||
it('falls back to the default when a custom value has no valid start', () => {
|
||||
expect(datePickerValueToLogTimeRange({ from: '', to: '', isHelper: false })).toEqual(
|
||||
DEFAULT_LOG_TIME_RANGE
|
||||
)
|
||||
})
|
||||
|
||||
it('uses now when an absolute helper has an empty end', () => {
|
||||
expect(
|
||||
datePickerValueToLogTimeRange({
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '',
|
||||
isHelper: true,
|
||||
text: 'Custom',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'absolute',
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-08T12:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('compares relative and absolute ranges structurally', () => {
|
||||
expect(
|
||||
logTimeRangesEqual(
|
||||
{ type: 'relative', amount: 1, unit: 'hour' },
|
||||
{ type: 'relative', amount: 1, unit: 'hour' }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
logTimeRangesEqual(
|
||||
{ type: 'relative', amount: 1, unit: 'hour' },
|
||||
{ type: 'relative', amount: 1, unit: 'day' }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
logTimeRangesEqual(
|
||||
{ type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' },
|
||||
{ type: 'absolute', from: '2025-01-01T00:00:00.000Z', to: '2025-01-02T00:00:00.000Z' }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves a relative range against the current time', () => {
|
||||
expect(resolveLogTimeRange({ type: 'relative', amount: 2, unit: 'day' })).toEqual({
|
||||
from: dayjs().subtract(2, 'day').toISOString(),
|
||||
to: dayjs().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an absolute range through unchanged', () => {
|
||||
const range: LogTimeRange = {
|
||||
type: 'absolute',
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-02T00:00:00.000Z',
|
||||
}
|
||||
expect(resolveLogTimeRange(range)).toEqual({ from: range.from, to: range.to })
|
||||
})
|
||||
|
||||
it('clamps a custom range ending today to now', () => {
|
||||
const from = new Date('2025-01-07T09:00:00.000Z')
|
||||
expect(
|
||||
customDateRangeToLogTimeRange({
|
||||
from,
|
||||
to: new Date('2025-01-08T09:00:00.000Z'),
|
||||
})
|
||||
).toEqual({
|
||||
type: 'absolute',
|
||||
from: dayjs(from).startOf('day').toISOString(),
|
||||
to: '2025-01-08T12:00:00.000Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange'
|
||||
import {
|
||||
DEFAULT_LOG_TIME_RANGE,
|
||||
type LogTimeRange,
|
||||
} from '@/data/query-sources/query-source-registry'
|
||||
import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
export type RelativeTimeUnit = Extract<LogTimeRange, { type: 'relative' }>['unit']
|
||||
|
||||
const nowIsoDateTime = (): IsoDateTimeString => dayjs().toISOString() as IsoDateTimeString
|
||||
|
||||
function parseRelativeHelperLabel(
|
||||
text: string | undefined
|
||||
): { amount: number; unit: RelativeTimeUnit } | null {
|
||||
if (!text) return null
|
||||
const match = text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/)
|
||||
if (!match) return null
|
||||
|
||||
const amount = match[1] ? parseInt(match[1], 10) : 1
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
|
||||
const unit = match[2]
|
||||
if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null
|
||||
return { amount, unit }
|
||||
}
|
||||
|
||||
export function datePickerValueToLogTimeRange(value: DatePickerValue): LogTimeRange {
|
||||
if (value.isHelper) {
|
||||
const relative = parseRelativeHelperLabel(value.text)
|
||||
if (relative) return { type: 'relative', ...relative }
|
||||
}
|
||||
|
||||
const from = isoDateTimeString(value.from)
|
||||
if (from === null) return DEFAULT_LOG_TIME_RANGE
|
||||
const to = isoDateTimeString(value.to) ?? nowIsoDateTime()
|
||||
return { type: 'absolute', from, to }
|
||||
}
|
||||
|
||||
export function logTimeRangeToDatePickerValue(range: LogTimeRange): DatePickerValue {
|
||||
if (range.type === 'relative') {
|
||||
const helper = generateDynamicHelper(range.amount, range.unit)
|
||||
return {
|
||||
from: helper.calcFrom(),
|
||||
to: helper.calcTo(),
|
||||
isHelper: true,
|
||||
text: helper.text,
|
||||
}
|
||||
}
|
||||
return { from: range.from, to: range.to, isHelper: false }
|
||||
}
|
||||
|
||||
export function customDateRangeToLogTimeRange({
|
||||
from,
|
||||
to,
|
||||
now = new Date(),
|
||||
}: {
|
||||
from: Date
|
||||
to: Date
|
||||
now?: Date
|
||||
}): Extract<LogTimeRange, { type: 'absolute' }> {
|
||||
const nowValue = dayjs(now)
|
||||
const requestedTo = dayjs(to).endOf('day')
|
||||
|
||||
return {
|
||||
type: 'absolute',
|
||||
from: dayjs(from).startOf('day').toISOString(),
|
||||
to: requestedTo.isAfter(nowValue) ? nowValue.toISOString() : requestedTo.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function logTimeRangesEqual(a: LogTimeRange, b: LogTimeRange): boolean {
|
||||
if (a.type === 'relative' && b.type === 'relative') {
|
||||
return a.amount === b.amount && a.unit === b.unit
|
||||
}
|
||||
if (a.type === 'absolute' && b.type === 'absolute') {
|
||||
return a.from === b.from && a.to === b.to
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveLogTimeRange(range: LogTimeRange): ResolvedLogDateRange {
|
||||
if (range.type === 'relative') {
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.subtract(range.amount, range.unit).toISOString(),
|
||||
to: now.toISOString(),
|
||||
}
|
||||
}
|
||||
return { from: range.from, to: range.to }
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,57 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LogsTimeRangeSubMenu } from './LogsTimeRangeSubMenu'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
|
||||
useCheckEntitlements: () => ({ getEntitlementNumericValue: () => 1 }),
|
||||
}))
|
||||
|
||||
const renderSubMenu = ({
|
||||
onRangeChange = vi.fn(),
|
||||
onOpenCustomRange = vi.fn(),
|
||||
onShowUpgrade = vi.fn(),
|
||||
} = {}) => {
|
||||
customRender(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<LogsTimeRangeSubMenu
|
||||
range={{ type: 'relative', amount: 1, unit: 'hour' }}
|
||||
onRangeChange={onRangeChange}
|
||||
onOpenCustomRange={onOpenCustomRange}
|
||||
onShowUpgrade={onShowUpgrade}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
return { onRangeChange, onOpenCustomRange, onShowUpgrade }
|
||||
}
|
||||
|
||||
describe('LogsTimeRangeSubMenu', () => {
|
||||
it('opens the upgrade prompt instead of applying a range beyond retention', async () => {
|
||||
const { onRangeChange, onShowUpgrade } = renderSubMenu()
|
||||
|
||||
await userEvent.hover(await screen.findByText('Time range'))
|
||||
await userEvent.click(await screen.findByText('Last 7 days'))
|
||||
|
||||
expect(onShowUpgrade).toHaveBeenCalledOnce()
|
||||
expect(onRangeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes the custom-range action', async () => {
|
||||
const { onOpenCustomRange } = renderSubMenu()
|
||||
|
||||
await userEvent.hover(await screen.findByText('Time range'))
|
||||
await userEvent.click(await screen.findByText('Custom range…'))
|
||||
|
||||
expect(onOpenCustomRange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
+11
-16
@@ -8,42 +8,37 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
datePickerValueToLogDateRange,
|
||||
logDateRangesEqual,
|
||||
type LogDateRange,
|
||||
} from '../../querySource'
|
||||
import { datePickerValueToLogTimeRange, logTimeRangesEqual } from './LogTimeRange.utils'
|
||||
import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants'
|
||||
import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { LogTimeRange } from '@/data/query-sources/query-source-registry'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
|
||||
export const TimeRangeSubMenu = ({
|
||||
id,
|
||||
export const LogsTimeRangeSubMenu = ({
|
||||
range,
|
||||
onRangeChange,
|
||||
onOpenCustomRange,
|
||||
onShowUpgrade,
|
||||
}: {
|
||||
id: string
|
||||
range: LogDateRange
|
||||
range: LogTimeRange
|
||||
onRangeChange: (range: LogTimeRange) => void
|
||||
onOpenCustomRange: () => void
|
||||
onShowUpgrade: () => void
|
||||
}) => {
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
||||
const entitledToLogDays = getEntitlementNumericValue()
|
||||
|
||||
const isCustomRange = range.kind === 'absolute'
|
||||
const isCustomRange = range.type === 'absolute'
|
||||
const presets = EXPLORER_DATEPICKER_HELPERS.map((helper) => ({
|
||||
helper,
|
||||
range: datePickerValueToLogDateRange({
|
||||
range: datePickerValueToLogTimeRange({
|
||||
from: helper.calcFrom(),
|
||||
to: helper.calcTo(),
|
||||
isHelper: true,
|
||||
text: helper.text,
|
||||
}),
|
||||
}))
|
||||
const selectedPreset = presets.find((preset) => logDateRangesEqual(range, preset.range))
|
||||
const selectedPreset = presets.find((preset) => logTimeRangesEqual(range, preset.range))
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
@@ -59,7 +54,7 @@ export const TimeRangeSubMenu = ({
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
{presets.map(({ helper, range: presetRange }) => {
|
||||
const isSelected = !isCustomRange && logDateRangesEqual(range, presetRange)
|
||||
const isSelected = !isCustomRange && logTimeRangesEqual(range, presetRange)
|
||||
const isLocked = maybeShowUpgradePromptIfNotEntitled(helper.calcFrom(), entitledToLogDays)
|
||||
|
||||
return (
|
||||
@@ -68,7 +63,7 @@ export const TimeRangeSubMenu = ({
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
if (isLocked) return onShowUpgrade()
|
||||
sessionSnap.setLogRange(id, presetRange)
|
||||
onRangeChange(presetRange)
|
||||
}}
|
||||
>
|
||||
<span>{helper.text}</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Database, ScrollText } from 'lucide-react'
|
||||
|
||||
import type { QuerySourceId } from '@/data/query-sources/query-source-registry'
|
||||
|
||||
export const QuerySourceIcon = ({
|
||||
source,
|
||||
className,
|
||||
}: {
|
||||
source: QuerySourceId
|
||||
className?: string
|
||||
}) => {
|
||||
const props = { className, size: 16, strokeWidth: 2 }
|
||||
|
||||
return source === 'logs' ? <ScrollText {...props} /> : <Database {...props} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { customDateRangeToLogTimeRange } from './LogTimeRange.utils'
|
||||
import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { LogTimeRange } from '@/data/query-sources/query-source-registry'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
|
||||
export function useLogsCustomRange({
|
||||
onRangeChange,
|
||||
}: {
|
||||
onRangeChange: (range: LogTimeRange) => void
|
||||
}) {
|
||||
const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false)
|
||||
const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
|
||||
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
||||
const entitledToLogDays = getEntitlementNumericValue()
|
||||
|
||||
const handleApplyCustomRange = ({ from, to }: { from: Date; to: Date }) => {
|
||||
const range = customDateRangeToLogTimeRange({ from, to })
|
||||
if (maybeShowUpgradePromptIfNotEntitled(range.from, entitledToLogDays)) {
|
||||
setShowUpgradePrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
onRangeChange(range)
|
||||
}
|
||||
|
||||
return {
|
||||
isCustomRangeOpen,
|
||||
setIsCustomRangeOpen,
|
||||
showUpgradePrompt,
|
||||
setShowUpgradePrompt,
|
||||
handleApplyCustomRange,
|
||||
}
|
||||
}
|
||||
+54
-67
@@ -1,8 +1,6 @@
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { Check, ChevronDown, Database, ScrollText } from 'lucide-react'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -12,32 +10,23 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import {
|
||||
datePickerValueToLogDateRange,
|
||||
type QuerySource,
|
||||
type SqlSnippetSource,
|
||||
} from '../../querySource'
|
||||
import { DatabaseSubMenu } from './DatabaseSubMenu'
|
||||
import { LogsCustomRangeDialog } from './LogsCustomRangeDialog'
|
||||
import { type QuerySource, type SqlSnippetSource } from '../../querySource'
|
||||
import { resolveSourceSwitch } from './QuerySourceMenu.utils'
|
||||
import { RowLimitSubMenu } from './RowLimitSubMenu'
|
||||
import { RunAsSubMenu } from './RunAsSubMenu'
|
||||
import { TimeRangeSubMenu } from './TimeRangeSubMenu'
|
||||
import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils'
|
||||
import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/DatabaseParametersSubMenu'
|
||||
import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog'
|
||||
import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu'
|
||||
import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon'
|
||||
import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange'
|
||||
import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { QUERY_SOURCE_LABELS, QUERY_SOURCES } from '@/data/query-sources/query-source-registry'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
const SOURCE_LABEL: Record<SqlSnippetSource, string> = {
|
||||
database: 'Database',
|
||||
logs: 'Logs',
|
||||
}
|
||||
|
||||
const SourceIcon = ({ source, ...props }: { source: SqlSnippetSource; className?: string }) =>
|
||||
source === 'logs' ? <ScrollText {...props} /> : <Database {...props} />
|
||||
|
||||
type QuerySourceMenuProps = {
|
||||
id: string
|
||||
runSource: QuerySource
|
||||
@@ -67,18 +56,33 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo
|
||||
const router = useRouter()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const databaseSelector = useDatabaseSelectorStateSnapshot()
|
||||
const [lastSelectedDatabase, setLastSelectedDatabase] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref ?? ''),
|
||||
''
|
||||
)
|
||||
|
||||
const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false)
|
||||
const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
|
||||
|
||||
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
||||
const entitledToLogDays = getEntitlementNumericValue()
|
||||
const {
|
||||
isCustomRangeOpen,
|
||||
setIsCustomRangeOpen,
|
||||
showUpgradePrompt,
|
||||
setShowUpgradePrompt,
|
||||
handleApplyCustomRange,
|
||||
} = useLogsCustomRange({ onRangeChange: (range) => sessionSnap.setLogRange(id, range) })
|
||||
|
||||
const currentSource = runSource.type
|
||||
const isLogs = currentSource === 'logs'
|
||||
// A snippet materializes in the store on its first keystroke; until then a
|
||||
// `/sql/new` tab is a blank scaffold with nothing to preserve.
|
||||
const isBlankNewTab = snapV2.snippets[id] === undefined
|
||||
const databaseIdentifier =
|
||||
lastSelectedDatabase.length > 0
|
||||
? lastSelectedDatabase
|
||||
: (databaseSelector.selectedDatabaseId ?? ref)
|
||||
|
||||
const selectableSources = QUERY_SOURCES.filter(
|
||||
(source) => source.type !== 'logs' || canCreateLogsSnippet || isLogs
|
||||
)
|
||||
|
||||
const switchSource = (target: SqlSnippetSource) => {
|
||||
const next = resolveSourceSwitch({ ref, target, currentSource, isBlankNewTab })
|
||||
@@ -86,20 +90,10 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo
|
||||
router[next.method](next.url)
|
||||
}
|
||||
|
||||
const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => {
|
||||
const fromIso = dayjs(from).startOf('day').toISOString()
|
||||
if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) {
|
||||
setShowUpgradePrompt(true)
|
||||
return
|
||||
}
|
||||
sessionSnap.setLogRange(
|
||||
id,
|
||||
datePickerValueToLogDateRange({
|
||||
from: fromIso,
|
||||
to: dayjs(to).endOf('day').toISOString(),
|
||||
isHelper: false,
|
||||
})
|
||||
)
|
||||
const updateDatabaseIdentifier = (identifier: string) => {
|
||||
databaseSelector.setSelectedDatabaseId(identifier)
|
||||
setLastSelectedDatabase(identifier)
|
||||
sessionSnap.resetResult(id)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,55 +102,48 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
aria-label={`Query source: ${SOURCE_LABEL[currentSource]}`}
|
||||
icon={<SourceIcon source={currentSource} className="text-foreground-light" />}
|
||||
aria-label={`Query source: ${QUERY_SOURCE_LABELS[currentSource]}`}
|
||||
icon={<QuerySourceIcon source={currentSource} className="text-foreground-light" />}
|
||||
iconRight={<ChevronDown size={14} className="text-foreground-light" />}
|
||||
>
|
||||
{SOURCE_LABEL[currentSource]}
|
||||
{QUERY_SOURCE_LABELS[currentSource]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
switchSource('database')
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<Database size={14} className="text-foreground-light" />
|
||||
Database
|
||||
</span>
|
||||
{!isLogs && <Check size={14} />}
|
||||
</DropdownMenuItem>
|
||||
{(canCreateLogsSnippet || isLogs) && (
|
||||
{selectableSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
className="justify-between"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
switchSource('logs')
|
||||
switchSource(source.id)
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<ScrollText size={14} className="text-foreground-light" />
|
||||
Logs
|
||||
<QuerySourceIcon source={source.id} className="text-foreground-light" />
|
||||
{QUERY_SOURCE_LABELS[source.id]}
|
||||
</span>
|
||||
{isLogs && <Check size={14} />}
|
||||
{currentSource === source.id && <Check size={14} />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{runSource.type === 'logs' ? (
|
||||
<TimeRangeSubMenu
|
||||
id={id}
|
||||
<LogsTimeRangeSubMenu
|
||||
range={runSource.dateRange}
|
||||
onRangeChange={(range) => sessionSnap.setLogRange(id, range)}
|
||||
onOpenCustomRange={() => setIsCustomRangeOpen(true)}
|
||||
onShowUpgrade={() => setShowUpgradePrompt(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{IS_PLATFORM && <DatabaseSubMenu id={id} />}
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseParametersSubMenu
|
||||
identifier={databaseIdentifier}
|
||||
onIdentifierChange={updateDatabaseIdentifier}
|
||||
/>
|
||||
)}
|
||||
<RunAsSubMenu />
|
||||
<RowLimitSubMenu />
|
||||
</>
|
||||
@@ -169,7 +156,7 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo
|
||||
<LogsCustomRangeDialog
|
||||
open={isCustomRangeOpen}
|
||||
onOpenChange={setIsCustomRangeOpen}
|
||||
onApply={applyCustomRange}
|
||||
onApply={handleApplyCustomRange}
|
||||
/>
|
||||
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
|
||||
</>
|
||||
|
||||
@@ -1,43 +1,11 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
datePickerValueToLogDateRange,
|
||||
DEFAULT_LOG_DATE_RANGE,
|
||||
getSnippetSource,
|
||||
isLogsSource,
|
||||
logDateRangesEqual,
|
||||
logDateRangeToDatePickerValue,
|
||||
resolveLogRunRange,
|
||||
resolveSnippetSource,
|
||||
sqlSourceToFenceLanguage,
|
||||
type LogDateRange,
|
||||
} from './querySource'
|
||||
import {
|
||||
EXPLORER_DATEPICKER_HELPERS,
|
||||
getDefaultHelper,
|
||||
} from '@/components/interfaces/Settings/Logs/Logs.constants'
|
||||
import { generateHelpersFromInput } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { DatetimeHelper } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { isoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
/** Build the `DatePickerValue` the Logs picker submits when a helper is selected. */
|
||||
const valueFromHelper = (helper: DatetimeHelper): DatePickerValue => ({
|
||||
from: helper.calcFrom(),
|
||||
to: helper.calcTo(),
|
||||
isHelper: true,
|
||||
text: helper.text,
|
||||
})
|
||||
|
||||
/** The single dynamic helper produced from typed input like "2h" / "30m". */
|
||||
const dynamicHelper = (input: string): DatetimeHelper => {
|
||||
const generated = generateHelpersFromInput(input)
|
||||
if (!generated || generated.length !== 1) {
|
||||
throw new Error(`Expected a single dynamic helper for "${input}"`)
|
||||
}
|
||||
return generated[0]
|
||||
}
|
||||
|
||||
describe('querySource.ts:getSnippetSource', () => {
|
||||
it('maps log_sql to the logs source', () => {
|
||||
@@ -91,221 +59,3 @@ describe('querySource.ts:resolveSnippetSource', () => {
|
||||
expect(resolveSnippetSource(undefined, 'nonsense')).toBe('database')
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:datePickerValueToLogDateRange', () => {
|
||||
it('parses every static preset into a relative range', () => {
|
||||
const cases: Array<[string, { amount: number; unit: 'minute' | 'hour' | 'day' }]> = [
|
||||
['Last hour', { amount: 1, unit: 'hour' }],
|
||||
['Last 3 hours', { amount: 3, unit: 'hour' }],
|
||||
['Last 24 hours', { amount: 24, unit: 'hour' }],
|
||||
['Last 3 days', { amount: 3, unit: 'day' }],
|
||||
['Last 7 days', { amount: 7, unit: 'day' }],
|
||||
]
|
||||
|
||||
for (const [text, last] of cases) {
|
||||
const helper = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === text)
|
||||
expect(helper, `preset "${text}" exists`).toBeDefined()
|
||||
expect(datePickerValueToLogDateRange(valueFromHelper(helper!))).toEqual({
|
||||
kind: 'relative',
|
||||
last,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('parses dynamic helpers ("2h", "30m", "7d") into relative ranges', () => {
|
||||
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('2h')))).toEqual({
|
||||
kind: 'relative',
|
||||
last: { amount: 2, unit: 'hour' },
|
||||
})
|
||||
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('30m')))).toEqual({
|
||||
kind: 'relative',
|
||||
last: { amount: 30, unit: 'minute' },
|
||||
})
|
||||
expect(datePickerValueToLogDateRange(valueFromHelper(dynamicHelper('7d')))).toEqual({
|
||||
kind: 'relative',
|
||||
last: { amount: 7, unit: 'day' },
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a preset (calcTo() === "") as relative — the empty end means "now"', () => {
|
||||
const lastHour = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS)
|
||||
const value = valueFromHelper(lastHour)
|
||||
expect(value.to).toBe('')
|
||||
expect(datePickerValueToLogDateRange(value)).toEqual({
|
||||
kind: 'relative',
|
||||
last: { amount: 1, unit: 'hour' },
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades an unparseable helper to an absolute range using from/now (never empty)', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2025-01-01T12:00:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const from = '2024-12-31T00:00:00.000Z'
|
||||
const result = datePickerValueToLogDateRange({
|
||||
from,
|
||||
to: '',
|
||||
isHelper: true,
|
||||
text: 'Some custom label',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'absolute',
|
||||
from,
|
||||
to: dayjs(now).toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a custom (non-helper) pick to an absolute range with both endpoints', () => {
|
||||
const from = '2025-01-01T00:00:00.000Z'
|
||||
const to = '2025-01-02T00:00:00.000Z'
|
||||
expect(datePickerValueToLogDateRange({ from, to, isHelper: false })).toEqual({
|
||||
kind: 'absolute',
|
||||
from,
|
||||
to,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the default range when the value has no usable from', () => {
|
||||
expect(datePickerValueToLogDateRange({ from: '', to: '', isHelper: false })).toEqual(
|
||||
DEFAULT_LOG_DATE_RANGE
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:logDateRangeToDatePickerValue', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('renders a relative range exactly as the picker would emit the helper', () => {
|
||||
const value = logDateRangeToDatePickerValue({
|
||||
kind: 'relative',
|
||||
last: { amount: 1, unit: 'hour' },
|
||||
})
|
||||
expect(value).toEqual({
|
||||
from: dayjs().subtract(1, 'hour').toISOString(),
|
||||
to: dayjs().toISOString(),
|
||||
isHelper: true,
|
||||
text: 'Last 1 hour',
|
||||
})
|
||||
})
|
||||
|
||||
it('pluralizes the label for amounts greater than one', () => {
|
||||
expect(
|
||||
logDateRangeToDatePickerValue({ kind: 'relative', last: { amount: 3, unit: 'day' } }).text
|
||||
).toBe('Last 3 days')
|
||||
})
|
||||
|
||||
it('round-trips through datePickerValueToLogDateRange', () => {
|
||||
const range: LogDateRange = { kind: 'relative', last: { amount: 30, unit: 'minute' } }
|
||||
expect(datePickerValueToLogDateRange(logDateRangeToDatePickerValue(range))).toEqual(range)
|
||||
})
|
||||
|
||||
it('passes an absolute range through as a non-helper value', () => {
|
||||
const range: LogDateRange = {
|
||||
kind: 'absolute',
|
||||
from: isoDateTimeString('2025-01-01T00:00:00.000Z')!,
|
||||
to: isoDateTimeString('2025-01-02T00:00:00.000Z')!,
|
||||
}
|
||||
expect(logDateRangeToDatePickerValue(range)).toEqual({
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-02T00:00:00.000Z',
|
||||
isHelper: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:resolveLogRunRange', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-resolves a relative range against the current time', () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2025-06-15T08:30:00.000Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
expect(resolveLogRunRange({ kind: 'relative', last: { amount: 2, unit: 'hour' } })).toEqual({
|
||||
from: dayjs(now).subtract(2, 'hour').toISOString(),
|
||||
to: dayjs(now).toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('re-resolves the same relative range differently as time advances', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-06-15T08:00:00.000Z'))
|
||||
const first = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } })
|
||||
|
||||
vi.setSystemTime(new Date('2025-06-15T10:00:00.000Z'))
|
||||
const second = resolveLogRunRange({ kind: 'relative', last: { amount: 1, unit: 'hour' } })
|
||||
|
||||
expect(first).not.toEqual(second)
|
||||
expect(second.to).toBe(dayjs('2025-06-15T10:00:00.000Z').toISOString())
|
||||
})
|
||||
|
||||
it('passes an absolute range through unchanged', () => {
|
||||
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
|
||||
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
|
||||
expect(resolveLogRunRange({ kind: 'absolute', from, to })).toEqual({
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-02T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('querySource.ts:logDateRangesEqual', () => {
|
||||
it('matches relative ranges on amount + unit regardless of label formatting', () => {
|
||||
const lastHourPreset = EXPLORER_DATEPICKER_HELPERS.find((h) => h.text === 'Last hour')!
|
||||
const presetRange = datePickerValueToLogDateRange({
|
||||
from: lastHourPreset.calcFrom(),
|
||||
to: lastHourPreset.calcTo(),
|
||||
isHelper: true,
|
||||
text: lastHourPreset.text,
|
||||
})
|
||||
|
||||
expect(
|
||||
logDateRangesEqual(presetRange, { kind: 'relative', last: { amount: 1, unit: 'hour' } })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match relative ranges with a different amount or unit', () => {
|
||||
expect(
|
||||
logDateRangesEqual(
|
||||
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
|
||||
{ kind: 'relative', last: { amount: 3, unit: 'hour' } }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
logDateRangesEqual(
|
||||
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
|
||||
{ kind: 'relative', last: { amount: 1, unit: 'day' } }
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('matches absolute ranges on their ISO endpoints', () => {
|
||||
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
|
||||
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
|
||||
expect(logDateRangesEqual({ kind: 'absolute', from, to }, { kind: 'absolute', from, to })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('never matches a relative range against an absolute one', () => {
|
||||
const from = isoDateTimeString('2025-01-01T00:00:00.000Z')!
|
||||
const to = isoDateTimeString('2025-01-02T00:00:00.000Z')!
|
||||
expect(
|
||||
logDateRangesEqual(
|
||||
{ kind: 'relative', last: { amount: 1, unit: 'hour' } },
|
||||
{
|
||||
kind: 'absolute',
|
||||
from,
|
||||
to,
|
||||
}
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import type { Unit } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import { generateDynamicHelper } from '@/components/interfaces/Settings/Logs/Logs.datePickerHelpers'
|
||||
import type { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { ResolvedLogDateRange } from '@/components/interfaces/Settings/Logs/logsDateRange'
|
||||
import type { Snippet } from '@/data/content/sql-folders-query'
|
||||
import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime'
|
||||
import { type LogTimeRange, type QuerySourceId } from '@/data/query-sources/query-source-registry'
|
||||
|
||||
/**
|
||||
* Domain view of where a snippet's query runs. Derived from the content TYPE:
|
||||
@@ -14,7 +8,7 @@ import { isoDateTimeString, type IsoDateTimeString } from '@/lib/iso-datetime'
|
||||
* immutable — switching backends means creating a new snippet, not toggling this
|
||||
* value.
|
||||
*/
|
||||
export type SqlSnippetSource = 'database' | 'logs'
|
||||
export type SqlSnippetSource = QuerySourceId
|
||||
|
||||
/**
|
||||
* The single reader every surface (AI, reports, tabs, nav, execution) uses to
|
||||
@@ -61,128 +55,10 @@ export function resolveSnippetSource(
|
||||
return snippet !== undefined ? getSnippetSource(snippet) : parseSqlSnippetSource(sourceParam)
|
||||
}
|
||||
|
||||
/** `now` as a branded ISO datetime — `toISOString()` is always valid ISO-8601. */
|
||||
function nowIsoDateTime(): IsoDateTimeString {
|
||||
return dayjs().toISOString() as IsoDateTimeString
|
||||
}
|
||||
|
||||
/**
|
||||
* The units a relative log range is expressed in. Aliases the Logs date picker's
|
||||
* `Unit` so the two stay in lockstep rather than drifting as parallel unions.
|
||||
*/
|
||||
export type RelativeTimeUnit = Unit
|
||||
|
||||
/**
|
||||
* A log query's time range. Relative ranges are structural (amount + unit) and
|
||||
* re-resolve against `now` at every run — so a saved "last hour" always means the
|
||||
* hour before the run, not the hour before the snippet was opened. Absolute ranges
|
||||
* carry validated ISO datetimes and pass through unchanged.
|
||||
*/
|
||||
export type LogDateRange =
|
||||
| { kind: 'relative'; last: { amount: number; unit: RelativeTimeUnit } }
|
||||
| { kind: 'absolute'; from: IsoDateTimeString; to: IsoDateTimeString }
|
||||
|
||||
/** The range a freshly opened logs snippet starts with: the last hour. */
|
||||
export const DEFAULT_LOG_DATE_RANGE: LogDateRange = {
|
||||
kind: 'relative',
|
||||
last: { amount: 1, unit: 'hour' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime query source for a snippet, pairing the database/logs discriminant
|
||||
* with the extra state each backend needs to run. A logs run carries the active
|
||||
* time range (session state, re-resolved at every run); a database run needs
|
||||
* nothing beyond the connection the execution pipeline already resolves.
|
||||
*/
|
||||
export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogDateRange }
|
||||
|
||||
/**
|
||||
* Parse a date-picker helper's label (e.g. "Last hour", "Last 3 hours", "Last 30
|
||||
* minutes") into a relative amount/unit. Covers both the static presets in
|
||||
* `EXPLORER_DATEPICKER_HELPERS` and the dynamic helpers `generateHelpersFromInput`
|
||||
* produces from typed input like "2h"/"30m". A label with no number means one unit
|
||||
* ("Last hour"). Returns null for any other label.
|
||||
*/
|
||||
function parseRelativeHelperLabel(
|
||||
text: string | undefined
|
||||
): { amount: number; unit: RelativeTimeUnit } | null {
|
||||
if (!text) return null
|
||||
const match = text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.match(/^last\s+(?:(\d+)\s+)?(minute|hour|day)s?$/)
|
||||
if (!match) return null
|
||||
const amount = match[1] ? parseInt(match[1], 10) : 1
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
const unit = match[2]
|
||||
if (unit !== 'minute' && unit !== 'hour' && unit !== 'day') return null
|
||||
return { amount, unit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Logs date-picker value into a `LogDateRange`. Helper picks (presets and
|
||||
* dynamic "2h"/"30m" helpers) become relative ranges by parsing the helper label;
|
||||
* a preset's `calcTo()` resolves to `''` (meaning "now"), which the relative variant
|
||||
* models implicitly. Everything else — custom calendar picks, or a helper whose label
|
||||
* we can't parse — becomes an absolute range with validated ISO datetimes, degrading
|
||||
* via `from`/now rather than rejecting an empty string. A value with no usable `from`
|
||||
* falls back to the default range.
|
||||
*/
|
||||
export function datePickerValueToLogDateRange(value: DatePickerValue): LogDateRange {
|
||||
if (value.isHelper) {
|
||||
const relative = parseRelativeHelperLabel(value.text)
|
||||
if (relative) return { kind: 'relative', last: relative }
|
||||
}
|
||||
|
||||
const from = isoDateTimeString(value.from)
|
||||
if (from === null) return DEFAULT_LOG_DATE_RANGE
|
||||
const to = isoDateTimeString(value.to) ?? nowIsoDateTime()
|
||||
return { kind: 'absolute', from, to }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `LogDateRange` back into a Logs date-picker value for display. Relative
|
||||
* ranges reuse the picker's own `generateDynamicHelper` to derive the resolved
|
||||
* `from`/`to` and matching "Last N unit(s)" label, so the value is byte-for-byte
|
||||
* what the picker itself would emit for that helper. Absolute ranges pass their
|
||||
* datetimes through.
|
||||
*/
|
||||
export function logDateRangeToDatePickerValue(range: LogDateRange): DatePickerValue {
|
||||
if (range.kind === 'relative') {
|
||||
const helper = generateDynamicHelper(range.last.amount, range.last.unit)
|
||||
return { from: helper.calcFrom(), to: helper.calcTo(), isHelper: true, text: helper.text }
|
||||
}
|
||||
return { from: range.from, to: range.to, isHelper: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural equality for two log date ranges. Relative ranges match on amount +
|
||||
* unit, NOT display text — "Last hour" and "Last 1 hour" render differently but
|
||||
* are the same range, so comparing labels is unreliable. Absolute ranges match on
|
||||
* their (validated) ISO endpoints.
|
||||
*/
|
||||
export function logDateRangesEqual(a: LogDateRange, b: LogDateRange): boolean {
|
||||
if (a.kind === 'relative' && b.kind === 'relative') {
|
||||
return a.last.amount === b.last.amount && a.last.unit === b.last.unit
|
||||
}
|
||||
if (a.kind === 'absolute' && b.kind === 'absolute') {
|
||||
return a.from === b.from && a.to === b.to
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `LogDateRange` to concrete ISO endpoints for a run. Relative ranges
|
||||
* re-resolve against `now` (so "last hour" is always the hour before the run);
|
||||
* absolute ranges pass through. Reuses the Logs `ResolvedLogDateRange` shape.
|
||||
*/
|
||||
export function resolveLogRunRange(range: LogDateRange): ResolvedLogDateRange {
|
||||
if (range.kind === 'relative') {
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.subtract(range.last.amount, range.last.unit).toISOString(),
|
||||
to: now.toISOString(),
|
||||
}
|
||||
}
|
||||
return { from: range.from, to: range.to }
|
||||
}
|
||||
export type QuerySource = { type: 'database' } | { type: 'logs'; dateRange: LogTimeRange }
|
||||
@@ -99,8 +99,9 @@ describe('useLogsSqlExecution', () => {
|
||||
it('resolves a relative session range to a from/to window around now', async () => {
|
||||
const captured = mockLogsAllOtel([])
|
||||
sqlEditorSessionState.setLogRange(SNIPPET_ID, {
|
||||
kind: 'relative',
|
||||
last: { amount: 2, unit: 'hour' },
|
||||
type: 'relative',
|
||||
amount: 2,
|
||||
unit: 'hour',
|
||||
})
|
||||
|
||||
const { result } = renderLogsExecution()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE, resolveLogRunRange } from './querySource'
|
||||
import { resolveLogTimeRange } from '@/components/interfaces/QuerySources/LogTimeRange.utils'
|
||||
import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation'
|
||||
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
|
||||
import { type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql'
|
||||
import {
|
||||
DEFAULT_LOG_TIME_RANGE,
|
||||
QUERY_SOURCE_REGISTRY,
|
||||
} from '@/data/query-sources/query-source-registry'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import {
|
||||
getSqlEditorSessionSnapshot,
|
||||
@@ -46,11 +49,11 @@ export function useLogsSqlExecution({ id }: UseLogsSqlExecutionArgs) {
|
||||
|
||||
// Re-read imperatively so a range picked immediately before the run is
|
||||
// honored; relative ranges re-resolve against `now` here.
|
||||
const range = resolveLogRunRange(
|
||||
getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_DATE_RANGE
|
||||
const range = resolveLogTimeRange(
|
||||
getSqlEditorSessionSnapshot().logRange[id] ?? DEFAULT_LOG_TIME_RANGE
|
||||
)
|
||||
|
||||
mutate({ projectRef, sql, range, endpoint: logsAllEndpointUrl(true) })
|
||||
mutate({ projectRef, sql, range, endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint })
|
||||
|
||||
track('sql_editor_query_run_button_clicked', { source: 'logs' })
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE } from './querySource'
|
||||
import { useRunSource } from './useRunSource'
|
||||
import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry'
|
||||
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import {
|
||||
renderSqlEditorHook,
|
||||
@@ -31,22 +31,23 @@ describe('useRunSource', () => {
|
||||
|
||||
const { result } = renderSqlEditorHook(() => useRunSource(id))
|
||||
|
||||
expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE })
|
||||
expect(result.current).toEqual({ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE })
|
||||
})
|
||||
|
||||
it('resolves a logs snippet to its session-stored range when one is set', () => {
|
||||
const id = 'logs-snippet-custom-range'
|
||||
seedSnippet({ id, source: 'logs' })
|
||||
sqlEditorSessionState.setLogRange(id, {
|
||||
kind: 'relative',
|
||||
last: { amount: 2, unit: 'hour' },
|
||||
type: 'relative',
|
||||
amount: 2,
|
||||
unit: 'hour',
|
||||
})
|
||||
|
||||
const { result } = renderSqlEditorHook(() => useRunSource(id))
|
||||
|
||||
expect(result.current).toEqual({
|
||||
type: 'logs',
|
||||
dateRange: { kind: 'relative', last: { amount: 2, unit: 'hour' } },
|
||||
dateRange: { type: 'relative', amount: 2, unit: 'hour' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import {
|
||||
DEFAULT_LOG_DATE_RANGE,
|
||||
isLogsSource,
|
||||
resolveSnippetSource,
|
||||
type QuerySource,
|
||||
} from './querySource'
|
||||
import { isLogsSource, resolveSnippetSource, type QuerySource } from './querySource'
|
||||
import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
||||
|
||||
@@ -33,7 +29,7 @@ export function useRunSource(id: string): QuerySource {
|
||||
|
||||
return useMemo<QuerySource>(() => {
|
||||
if (isLogsSource(source)) {
|
||||
return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_DATE_RANGE }
|
||||
return { type: 'logs', dateRange: logRange ?? DEFAULT_LOG_TIME_RANGE }
|
||||
}
|
||||
return { type: 'database' }
|
||||
}, [source, logRange])
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
cellSourceSchema,
|
||||
createDefaultCellSource,
|
||||
getQuerySource,
|
||||
logTimeRangeSchema,
|
||||
QUERY_SOURCES,
|
||||
} from './query-source-registry'
|
||||
|
||||
describe('query source registry', () => {
|
||||
it('registers database and logs sources with their execution endpoints', () => {
|
||||
expect(QUERY_SOURCES.map(({ id }) => id)).toEqual(['database', 'logs'])
|
||||
expect(getQuerySource('database').endpoint).toBe('/platform/pg-meta/{ref}/query')
|
||||
expect(getQuerySource('logs').endpoint).toBe(
|
||||
'/platform/projects/{ref}/analytics/endpoints/logs.all.otel'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates independent, valid default cell bindings', () => {
|
||||
const first = createDefaultCellSource('logs')
|
||||
const second = createDefaultCellSource('logs')
|
||||
|
||||
expect(cellSourceSchema.parse(first)).toEqual({
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
parameters: {
|
||||
time_range: { type: 'relative', amount: 1, unit: 'hour' },
|
||||
},
|
||||
})
|
||||
expect(first.parameters.time_range).not.toBe(second.parameters.time_range)
|
||||
expect(cellSourceSchema.parse(createDefaultCellSource('database'))).toEqual({
|
||||
id: 'database',
|
||||
type: 'database',
|
||||
parameters: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects parameters that do not match the selected source type', () => {
|
||||
expect(() =>
|
||||
cellSourceSchema.parse({
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
parameters: { identifier: 'replica-1' },
|
||||
})
|
||||
).toThrow()
|
||||
|
||||
expect(() =>
|
||||
cellSourceSchema.parse({
|
||||
id: 'database',
|
||||
type: 'database',
|
||||
parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } },
|
||||
})
|
||||
).toThrow()
|
||||
|
||||
expect(() =>
|
||||
cellSourceSchema.parse({
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
parameters: { time_range: { type: 'relative', amount: 2, unit: 'week' } },
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects absolute ranges that do not move forward in time', () => {
|
||||
expect(
|
||||
logTimeRangeSchema.safeParse({
|
||||
type: 'absolute',
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-02T00:00:00.000Z',
|
||||
}).success
|
||||
).toBe(true)
|
||||
|
||||
const equal = logTimeRangeSchema.safeParse({
|
||||
type: 'absolute',
|
||||
from: '2025-01-01T00:00:00.000Z',
|
||||
to: '2025-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(equal.success).toBe(false)
|
||||
expect(equal.error?.issues[0].path).toEqual(['to'])
|
||||
|
||||
expect(
|
||||
logTimeRangeSchema.safeParse({
|
||||
type: 'absolute',
|
||||
from: '2025-01-02T00:00:00.000Z',
|
||||
to: '2025-01-01T00:00:00.000Z',
|
||||
}).success
|
||||
).toBe(false)
|
||||
|
||||
expect(() =>
|
||||
cellSourceSchema.parse({
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
parameters: {
|
||||
time_range: {
|
||||
type: 'absolute',
|
||||
from: '2025-01-02T00:00:00.000Z',
|
||||
to: '2025-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('reports an invalid endpoint against its own field rather than the ordering rule', () => {
|
||||
const result = logTimeRangeSchema.safeParse({
|
||||
type: 'absolute',
|
||||
from: 'not-a-date',
|
||||
to: '2025-01-01T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error?.issues).toHaveLength(1)
|
||||
expect(result.error?.issues[0].path).toEqual(['from'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import dayjs from 'dayjs'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
|
||||
import { isoDateTimeString } from '@/lib/iso-datetime'
|
||||
|
||||
export type LogTimeRange =
|
||||
| {
|
||||
type: 'relative'
|
||||
amount: number
|
||||
unit: 'minute' | 'hour' | 'day'
|
||||
}
|
||||
| {
|
||||
type: 'absolute'
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export type DatabaseSource = {
|
||||
id: 'database'
|
||||
type: 'database'
|
||||
endpoint: '/platform/pg-meta/{ref}/query'
|
||||
parameters: {
|
||||
/**
|
||||
* Query-owned database selection. The SQL editor still adapts its legacy
|
||||
* global/local-storage selector into this shape; new consumers persist the
|
||||
* identifier directly with their query.
|
||||
*/
|
||||
identifier?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type LogsSource = {
|
||||
id: 'logs'
|
||||
type: 'logs'
|
||||
endpoint: ReturnType<typeof logsAllEndpointUrl>
|
||||
parameters: {
|
||||
time_range: LogTimeRange
|
||||
}
|
||||
}
|
||||
|
||||
/** Sources are registered by Studio; query surfaces only store a source binding. */
|
||||
export type Source = DatabaseSource | LogsSource
|
||||
|
||||
export type CellSourceOf<S extends Source> = Pick<S, 'type' | 'id' | 'parameters'>
|
||||
|
||||
export type CellSource = CellSourceOf<DatabaseSource> | CellSourceOf<LogsSource>
|
||||
|
||||
export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = {
|
||||
type: 'relative',
|
||||
amount: 1,
|
||||
unit: 'hour',
|
||||
}
|
||||
|
||||
export const QUERY_SOURCE_REGISTRY = {
|
||||
database: {
|
||||
id: 'database',
|
||||
type: 'database',
|
||||
endpoint: '/platform/pg-meta/{ref}/query',
|
||||
parameters: {},
|
||||
},
|
||||
logs: {
|
||||
id: 'logs',
|
||||
type: 'logs',
|
||||
endpoint: logsAllEndpointUrl(true),
|
||||
parameters: { time_range: DEFAULT_LOG_TIME_RANGE },
|
||||
},
|
||||
} as const satisfies Record<Source['id'], Source>
|
||||
|
||||
export type QuerySourceId = keyof typeof QUERY_SOURCE_REGISTRY
|
||||
|
||||
export const QUERY_SOURCES = Object.values(QUERY_SOURCE_REGISTRY) satisfies Source[]
|
||||
|
||||
export const QUERY_SOURCE_LABELS: Record<QuerySourceId, string> = {
|
||||
database: 'Database',
|
||||
logs: 'Logs',
|
||||
}
|
||||
|
||||
const isoDateTimeSchema = z.string().refine((value) => isoDateTimeString(value) !== null, {
|
||||
message: 'must be a valid ISO-8601 datetime',
|
||||
})
|
||||
|
||||
export const logTimeRangeSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
type: z.literal('relative'),
|
||||
amount: z.number().int().positive(),
|
||||
unit: z.enum(['minute', 'hour', 'day']),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('absolute'),
|
||||
from: isoDateTimeSchema,
|
||||
to: isoDateTimeSchema,
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.refine(
|
||||
(range) => {
|
||||
if (range.type !== 'absolute') return true
|
||||
|
||||
const from = dayjs(range.from)
|
||||
const to = dayjs(range.to)
|
||||
// An unparseable endpoint is already reported against its own field; the
|
||||
// ordering rule stays quiet so it doesn't add a second, misleading issue.
|
||||
if (!from.isValid() || !to.isValid()) return true
|
||||
|
||||
return to.isAfter(from)
|
||||
},
|
||||
{
|
||||
message: 'must be later than the start of the range',
|
||||
path: ['to'],
|
||||
}
|
||||
)
|
||||
|
||||
export const cellSourceSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
id: z.literal('database'),
|
||||
type: z.literal('database'),
|
||||
parameters: z.object({ identifier: z.string().optional() }).strict(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: z.literal('logs'),
|
||||
type: z.literal('logs'),
|
||||
parameters: z.object({ time_range: logTimeRangeSchema }).strict(),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
|
||||
export function createDefaultCellSource(id: 'database'): CellSourceOf<DatabaseSource>
|
||||
export function createDefaultCellSource(id: 'logs'): CellSourceOf<LogsSource>
|
||||
export function createDefaultCellSource(id: QuerySourceId): CellSource
|
||||
export function createDefaultCellSource(id: QuerySourceId): CellSource {
|
||||
const source = QUERY_SOURCE_REGISTRY[id]
|
||||
|
||||
if (source.type === 'logs') {
|
||||
return {
|
||||
id: source.id,
|
||||
type: source.type,
|
||||
parameters: { time_range: { ...source.parameters.time_range } },
|
||||
}
|
||||
}
|
||||
|
||||
return { id: source.id, type: source.type, parameters: { ...source.parameters } }
|
||||
}
|
||||
|
||||
export const getQuerySource = (id: QuerySourceId): Source => QUERY_SOURCE_REGISTRY[id]
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createNodeTree,
|
||||
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
|
||||
import { handleError as handleErrorFetchers, post } from '@/data/fetchers'
|
||||
import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry'
|
||||
import { MB } from '@/lib/constants'
|
||||
import { sqlEventParser } from '@/lib/sql-event-parser'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
@@ -159,7 +160,7 @@ export async function executeSql<T = any>(
|
||||
|
||||
const key =
|
||||
queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ?? ''
|
||||
const result = await post('/platform/pg-meta/{ref}/query', {
|
||||
const result = await post(QUERY_SOURCE_REGISTRY.database.endpoint, {
|
||||
...options,
|
||||
body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled },
|
||||
params: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
|
||||
|
||||
import type { LogDateRange } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import type { LogTimeRange } from '@/data/query-sources/query-source-registry'
|
||||
|
||||
/**
|
||||
* Ephemeral, per-session SQL editor state that is NOT persisted: query results,
|
||||
@@ -35,11 +35,11 @@ export const sqlEditorSessionState = proxy({
|
||||
* The logs time range for a logs snippet, keyed by snippet id. Session state —
|
||||
* never written to snippet content — so it works on read-only shared snippets
|
||||
* and resets on reload. An unset snippet has no entry; read sites fall back to
|
||||
* `DEFAULT_LOG_DATE_RANGE`.
|
||||
* `DEFAULT_LOG_TIME_RANGE`.
|
||||
*/
|
||||
logRange: {} as { [snippetId: string]: LogDateRange },
|
||||
logRange: {} as { [snippetId: string]: LogTimeRange },
|
||||
|
||||
setLogRange: (id: string, range: LogDateRange) => {
|
||||
setLogRange: (id: string, range: LogTimeRange) => {
|
||||
sqlEditorSessionState.logRange[id] = range
|
||||
},
|
||||
|
||||
|
||||
@@ -1,42 +1,58 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_LOG_DATE_RANGE } from '@/components/interfaces/SQLEditor/querySource'
|
||||
import { QuerySourceMenu } from '@/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu'
|
||||
import { DEFAULT_LOG_TIME_RANGE } from '@/data/query-sources/query-source-registry'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
// QuerySourceMenu renders a Radix dropdown (+ nested dialog), both of which use Web Animations.
|
||||
mockAnimationsApi()
|
||||
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref',
|
||||
response: {
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
organization_id: 1,
|
||||
name: 'Test Project',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
cloud_provider: 'AWS',
|
||||
region: 'us-east-1',
|
||||
db_host: 'db.default.supabase.co',
|
||||
restUrl: 'https://default.supabase.co/rest/v1/',
|
||||
inserted_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
subscription_id: 'sub_123',
|
||||
is_branch_enabled: false,
|
||||
is_physical_backups_enabled: false,
|
||||
high_availability: false,
|
||||
integration_source: null,
|
||||
connectionString: 'postgresql://postgres@localhost:5432/postgres',
|
||||
is_hibernating: false,
|
||||
},
|
||||
beforeEach(() => {
|
||||
addAPIMock({
|
||||
method: 'get',
|
||||
path: '/platform/projects/:ref',
|
||||
response: {
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
organization_id: 1,
|
||||
name: 'Test Project',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
cloud_provider: 'AWS',
|
||||
region: 'us-east-1',
|
||||
db_host: 'db.default.supabase.co',
|
||||
restUrl: 'https://default.supabase.co/rest/v1/',
|
||||
inserted_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
subscription_id: 'sub_123',
|
||||
is_branch_enabled: false,
|
||||
is_physical_backups_enabled: false,
|
||||
high_availability: false,
|
||||
integration_source: null,
|
||||
connectionString: 'postgresql://postgres@localhost:5432/postgres',
|
||||
is_hibernating: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuerySourceMenu', () => {
|
||||
it('hides logs when creating logs queries is unavailable', async () => {
|
||||
customRender(
|
||||
<QuerySourceMenu
|
||||
id="database-snippet"
|
||||
runSource={{ type: 'database' }}
|
||||
canCreateLogsSnippet={false}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' }))
|
||||
|
||||
expect(screen.queryByText('Logs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the dropdown open across a source switch, so the new source’s controls appear without reopening it', async () => {
|
||||
// Selecting a source doesn't mutate `runSource` in place — it navigates to a
|
||||
// fresh tab, and the parent re-renders this component with the new source once
|
||||
@@ -57,7 +73,7 @@ describe('QuerySourceMenu', () => {
|
||||
rerender(
|
||||
<QuerySourceMenu
|
||||
id="new-snippet"
|
||||
runSource={{ type: 'logs', dateRange: DEFAULT_LOG_DATE_RANGE }}
|
||||
runSource={{ type: 'logs', dateRange: DEFAULT_LOG_TIME_RANGE }}
|
||||
canCreateLogsSnippet
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('generateDynamicHelper', () => {
|
||||
})
|
||||
|
||||
describe('generateDynamicHelpers', () => {
|
||||
test('generates 3 helpers for minutes, hours, days', () => {
|
||||
test('generates helpers for every supported relative unit', () => {
|
||||
const helpers = generateDynamicHelpers(5)
|
||||
expect(helpers).toHaveLength(3)
|
||||
expect(helpers[0].text).toBe('Last 5 minutes')
|
||||
@@ -112,7 +112,7 @@ describe('generateHelpersFromInput', () => {
|
||||
expect(generateHelpersFromInput('2yoie')).toBeNull()
|
||||
})
|
||||
|
||||
test('returns 3 helpers for number only input', () => {
|
||||
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')
|
||||
|
||||
Reference in new issue
Block a user