feat(filtering): added the ability to filter logs by date and date range (#2639)

This commit is contained in:
Waleed
2025-12-30 10:42:44 -08:00
committed by GitHub
parent f8b1880575
commit df099e9485
11 changed files with 1532 additions and 367 deletions

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
@@ -9,11 +9,13 @@ import {
type ComboboxOption, type ComboboxOption,
Loader, Loader,
Popover, Popover,
PopoverAnchor,
PopoverContent, PopoverContent,
PopoverItem, PopoverItem,
PopoverScrollArea, PopoverScrollArea,
PopoverTrigger, PopoverTrigger,
} from '@/components/emcn' } from '@/components/emcn'
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -35,8 +37,31 @@ const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'Past 7 days', label: 'Past 7 days' }, { value: 'Past 7 days', label: 'Past 7 days' },
{ value: 'Past 14 days', label: 'Past 14 days' }, { value: 'Past 14 days', label: 'Past 14 days' },
{ value: 'Past 30 days', label: 'Past 30 days' }, { value: 'Past 30 days', label: 'Past 30 days' },
{ value: 'Custom range', label: 'Custom range' },
] as const ] as const
/**
* Formats a date string (YYYY-MM-DD) for display.
*/
function formatDateShort(dateStr: string): string {
const date = new Date(dateStr)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
return `${months[date.getMonth()]} ${date.getDate()}`
}
type ViewMode = 'logs' | 'dashboard' type ViewMode = 'logs' | 'dashboard'
interface LogsToolbarProps { interface LogsToolbarProps {
@@ -153,7 +178,14 @@ export function LogsToolbar({
setTriggers, setTriggers,
timeRange, timeRange,
setTimeRange, setTimeRange,
startDate,
endDate,
setDateRange,
clearDateRange,
} = useFilterStore() } = useFilterStore()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
const folders = useFolderStore((state) => state.folders) const folders = useFolderStore((state) => state.folders)
const allWorkflows = useWorkflowRegistry((state) => state.workflows) const allWorkflows = useWorkflowRegistry((state) => state.workflows)
@@ -269,8 +301,50 @@ export function LogsToolbar({
const timeDisplayLabel = useMemo(() => { const timeDisplayLabel = useMemo(() => {
if (timeRange === 'All time') return 'Time' if (timeRange === 'All time') return 'Time'
if (timeRange === 'Custom range' && startDate && endDate) {
return `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`
}
if (timeRange === 'Custom range') return 'Custom range'
return timeRange return timeRange
}, [timeRange]) }, [timeRange, startDate, endDate])
/**
* Handles time range selection from combobox.
* Opens date picker when "Custom range" is selected.
*/
const handleTimeRangeChange = useCallback(
(val: string) => {
if (val === 'Custom range') {
setPreviousTimeRange(timeRange)
setDatePickerOpen(true)
} else {
clearDateRange()
setTimeRange(val as typeof timeRange)
}
},
[timeRange, setTimeRange, clearDateRange]
)
/**
* Handles date range selection from DatePicker.
*/
const handleDateRangeApply = useCallback(
(start: string, end: string) => {
setDateRange(start, end)
setDatePickerOpen(false)
},
[setDateRange]
)
/**
* Handles date picker cancel.
*/
const handleDatePickerCancel = useCallback(() => {
if (timeRange === 'Custom range' && !startDate) {
setTimeRange(previousTimeRange)
}
setDatePickerOpen(false)
}, [timeRange, startDate, previousTimeRange, setTimeRange])
const hasActiveFilters = useMemo(() => { const hasActiveFilters = useMemo(() => {
return ( return (
@@ -287,8 +361,8 @@ export function LogsToolbar({
setWorkflowIds([]) setWorkflowIds([])
setFolderIds([]) setFolderIds([])
setTriggers([]) setTriggers([])
setTimeRange('All time') clearDateRange()
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, setTimeRange]) }, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
return ( return (
<div className='flex flex-col gap-[19px]'> <div className='flex flex-col gap-[19px]'>
@@ -528,7 +602,7 @@ export function LogsToolbar({
<Combobox <Combobox
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]} options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
value={timeRange} value={timeRange}
onChange={(val) => setTimeRange(val as typeof timeRange)} onChange={handleTimeRangeChange}
placeholder='All time' placeholder='All time'
overlayContent={ overlayContent={
<span className='truncate text-[var(--text-primary)]'> <span className='truncate text-[var(--text-primary)]'>
@@ -636,19 +710,43 @@ export function LogsToolbar({
/> />
{/* Timeline Filter */} {/* Timeline Filter */}
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverAnchor asChild>
<div>
<Combobox <Combobox
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]} options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
value={timeRange} value={timeRange}
onChange={(val) => setTimeRange(val as typeof timeRange)} onChange={handleTimeRangeChange}
placeholder='Time' placeholder='Time'
overlayContent={ overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span> <span className='truncate text-[var(--text-primary)]'>
{timeDisplayLabel}
</span>
} }
size='sm' size='sm'
align='end' align='end'
className='h-[32px] w-[120px] rounded-[6px]' className='h-[32px] w-[120px] rounded-[6px]'
/> />
</div> </div>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='end'
sideOffset={4}
collisionPadding={16}
className='w-auto p-0'
>
<DatePicker
mode='range'
startDate={startDate}
endDate={endDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
inline
/>
</PopoverContent>
</Popover>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getStartDateFromTimeRange } from '@/lib/logs/filters' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import { useFolders } from '@/hooks/queries/folders' import { useFolders } from '@/hooks/queries/folders'
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs' import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
@@ -30,6 +30,8 @@ export default function Logs() {
setWorkspaceId, setWorkspaceId,
initializeFromURL, initializeFromURL,
timeRange, timeRange,
startDate,
endDate,
level, level,
workflowIds, workflowIds,
folderIds, folderIds,
@@ -72,6 +74,8 @@ export default function Logs() {
const logFilters = useMemo( const logFilters = useMemo(
() => ({ () => ({
timeRange, timeRange,
startDate,
endDate,
level, level,
workflowIds, workflowIds,
folderIds, folderIds,
@@ -79,7 +83,7 @@ export default function Logs() {
searchQuery: debouncedSearchQuery, searchQuery: debouncedSearchQuery,
limit: LOGS_PER_PAGE, limit: LOGS_PER_PAGE,
}), }),
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery] [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
) )
const logsQuery = useLogsList(workspaceId, logFilters, { const logsQuery = useLogsList(workspaceId, logFilters, {
@@ -90,13 +94,15 @@ export default function Logs() {
const dashboardFilters = useMemo( const dashboardFilters = useMemo(
() => ({ () => ({
timeRange, timeRange,
startDate,
endDate,
level, level,
workflowIds, workflowIds,
folderIds, folderIds,
triggers, triggers,
searchQuery: debouncedSearchQuery, searchQuery: debouncedSearchQuery,
}), }),
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery] [timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
) )
const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, { const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, {
@@ -261,9 +267,14 @@ export default function Logs() {
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(',')) if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
if (folderIds.length > 0) params.set('folderIds', folderIds.join(',')) if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
const startDate = getStartDateFromTimeRange(timeRange) const computedStartDate = getStartDateFromTimeRange(timeRange, startDate)
if (startDate) { if (computedStartDate) {
params.set('startDate', startDate.toISOString()) params.set('startDate', computedStartDate.toISOString())
}
const computedEndDate = getEndDateFromTimeRange(timeRange, endDate)
if (computedEndDate) {
params.set('endDate', computedEndDate.toISOString())
} }
const parsed = parseQuery(debouncedSearchQuery) const parsed = parseQuery(debouncedSearchQuery)

View File

@@ -4,12 +4,21 @@
* *
* @example * @example
* ```tsx * ```tsx
* // Basic date picker * // Basic single date picker
* <DatePicker * <DatePicker
* value={date} * value={date}
* onChange={(dateString) => setDate(dateString)} * onChange={(dateString) => setDate(dateString)}
* placeholder="Select date" * placeholder="Select date"
* /> * />
*
* // Range date picker
* <DatePicker
* mode="range"
* startDate={startDate}
* endDate={endDate}
* onRangeChange={(start, end) => handleRange(start, end)}
* placeholder="Select date range"
* />
* ``` * ```
*/ */
@@ -49,21 +58,68 @@ const datePickerVariants = cva(
} }
) )
export interface DatePickerProps /** Base props shared by both single and range modes */
interface DatePickerBaseProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>, extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof datePickerVariants> { VariantProps<typeof datePickerVariants> {
/** Current selected date value (YYYY-MM-DD string or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD format */
onChange?: (value: string) => void
/** Placeholder text when no value is selected */ /** Placeholder text when no value is selected */
placeholder?: string placeholder?: string
/** Whether the picker is disabled */ /** Whether the picker is disabled */
disabled?: boolean disabled?: boolean
/** Size variant */ /** Size variant */
size?: 'default' | 'sm' size?: 'default' | 'sm'
/** Whether to show the trigger button (set to false for inline/controlled usage) */
showTrigger?: boolean
/** Controlled open state */
open?: boolean
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void
/** Render calendar inline without popover (for use inside modals) */
inline?: boolean
} }
/** Props for single date mode */
interface DatePickerSingleProps extends DatePickerBaseProps {
/** Selection mode */
mode?: 'single'
/** Current selected date value (YYYY-MM-DD string or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD format */
onChange?: (value: string) => void
/** Not used in single mode */
startDate?: never
/** Not used in single mode */
endDate?: never
/** Not used in single mode */
onRangeChange?: never
/** Not used in single mode */
onCancel?: never
/** Not used in single mode */
onClear?: never
}
/** Props for range date mode */
interface DatePickerRangeProps extends DatePickerBaseProps {
/** Selection mode */
mode: 'range'
/** Start date for range mode (YYYY-MM-DD string or Date) */
startDate?: string | Date
/** End date for range mode (YYYY-MM-DD string or Date) */
endDate?: string | Date
/** Callback when date range is applied */
onRangeChange?: (startDate: string, endDate: string) => void
/** Callback when range selection is cancelled */
onCancel?: () => void
/** Callback when range is cleared */
onClear?: () => void
/** Not used in range mode */
value?: never
/** Not used in range mode */
onChange?: never
}
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps
/** /**
* Month names for calendar display. * Month names for calendar display.
*/ */
@@ -101,6 +157,24 @@ function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay() return new Date(year, month, 1).getDay()
} }
/**
* Short month names for display.
*/
const MONTHS_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
/** /**
* Formats a date for display in the trigger button. * Formats a date for display in the trigger button.
*/ */
@@ -113,6 +187,46 @@ function formatDateForDisplay(date: Date | null): string {
}) })
} }
/**
* Formats a date range for display.
*/
function formatDateRangeForDisplay(start: Date | null, end: Date | null): string {
if (!start && !end) return ''
if (start && !end) return formatDateForDisplay(start)
if (!start && end) return formatDateForDisplay(end)
if (start && end) {
const startStr = `${MONTHS_SHORT[start.getMonth()]} ${start.getDate()}`
const endStr =
start.getFullYear() === end.getFullYear()
? `${MONTHS_SHORT[end.getMonth()]} ${end.getDate()}`
: `${MONTHS_SHORT[end.getMonth()]} ${end.getDate()}, ${end.getFullYear()}`
return `${startStr} - ${endStr}${start.getFullYear() !== end.getFullYear() ? '' : `, ${start.getFullYear()}`}`
}
return ''
}
/**
* Checks if a date is between two dates (inclusive).
*/
function isDateInRange(date: Date, start: Date | null, end: Date | null): boolean {
if (!start || !end) return false
const time = date.getTime()
const startTime = Math.min(start.getTime(), end.getTime())
const endTime = Math.max(start.getTime(), end.getTime())
return time >= startTime && time <= endTime
}
/**
* Checks if two dates are the same day.
*/
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
/** /**
* Formats a date as YYYY-MM-DD string. * Formats a date as YYYY-MM-DD string.
*/ */
@@ -135,21 +249,17 @@ function parseDate(value: string | Date | undefined): Date | null {
} }
try { try {
// Handle YYYY-MM-DD format (treat as local date)
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number) const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day) return new Date(year, month - 1, day)
} }
// Handle ISO strings with timezone (extract date part as local)
if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) { if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return null if (Number.isNaN(date.getTime())) return null
// Use UTC date components to prevent timezone shift
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
} }
// Fallback: try parsing as-is
const date = new Date(value) const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date return Number.isNaN(date.getTime()) ? null : date
} catch { } catch {
@@ -157,46 +267,350 @@ function parseDate(value: string | Date | undefined): Date | null {
} }
} }
/**
* Calendar component for rendering a single month.
*/
interface CalendarMonthProps {
viewMonth: number
viewYear: number
selectedDate?: Date | null
rangeStart?: Date | null
rangeEnd?: Date | null
hoverDate?: Date | null
isRangeMode?: boolean
onSelectDate: (day: number) => void
onHoverDate?: (day: number | null) => void
onPrevMonth: () => void
onNextMonth: () => void
showNavigation?: 'left' | 'right' | 'both'
}
function CalendarMonth({
viewMonth,
viewYear,
selectedDate,
rangeStart,
rangeEnd,
hoverDate,
isRangeMode,
onSelectDate,
onHoverDate,
onPrevMonth,
onNextMonth,
showNavigation = 'both',
}: CalendarMonthProps) {
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth)
const calendarDays = React.useMemo(() => {
const days: (number | null)[] = []
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(null)
}
for (let day = 1; day <= daysInMonth; day++) {
days.push(day)
}
return days
}, [firstDayOfMonth, daysInMonth])
const isToday = React.useCallback(
(day: number) => {
const today = new Date()
return (
today.getDate() === day &&
today.getMonth() === viewMonth &&
today.getFullYear() === viewYear
)
},
[viewMonth, viewYear]
)
const isSelected = React.useCallback(
(day: number) => {
if (!selectedDate) return false
return (
selectedDate.getDate() === day &&
selectedDate.getMonth() === viewMonth &&
selectedDate.getFullYear() === viewYear
)
},
[selectedDate, viewMonth, viewYear]
)
const isRangeStart = React.useCallback(
(day: number) => {
if (!rangeStart) return false
return (
rangeStart.getDate() === day &&
rangeStart.getMonth() === viewMonth &&
rangeStart.getFullYear() === viewYear
)
},
[rangeStart, viewMonth, viewYear]
)
const isRangeEnd = React.useCallback(
(day: number) => {
if (!rangeEnd) return false
return (
rangeEnd.getDate() === day &&
rangeEnd.getMonth() === viewMonth &&
rangeEnd.getFullYear() === viewYear
)
},
[rangeEnd, viewMonth, viewYear]
)
const isInRange = React.useCallback(
(day: number) => {
if (!isRangeMode) return false
const date = new Date(viewYear, viewMonth, day)
// Only show range highlight when both start and end are selected
if (rangeStart && rangeEnd) {
return (
isDateInRange(date, rangeStart, rangeEnd) &&
!isSameDay(date, rangeStart) &&
!isSameDay(date, rangeEnd)
)
}
return false
},
[isRangeMode, rangeStart, rangeEnd, viewMonth, viewYear]
)
return (
<div className='flex flex-col'>
{/* Calendar Header */}
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-[12px] py-[10px]'>
{showNavigation === 'left' || showNavigation === 'both' ? (
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
onClick={onPrevMonth}
>
<ChevronLeft className='h-4 w-4' />
</button>
) : (
<div className='h-[24px] w-[24px]' />
)}
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{MONTHS[viewMonth]} {viewYear}
</span>
{showNavigation === 'right' || showNavigation === 'both' ? (
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
onClick={onNextMonth}
>
<ChevronRight className='h-4 w-4' />
</button>
) : (
<div className='h-[24px] w-[24px]' />
)}
</div>
{/* Day Headers */}
<div className='grid grid-cols-7 px-[8px] pt-[8px]'>
{DAYS.map((day) => (
<div
key={day}
className='flex h-[28px] items-center justify-center text-[11px] text-[var(--text-muted)]'
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className='grid grid-cols-7 px-[8px] pb-[8px]'>
{calendarDays.map((day, index) => {
const inRange = day !== null && isInRange(day)
const isStart = day !== null && isRangeStart(day)
const isEnd = day !== null && isRangeEnd(day)
const hasRangeHighlight = inRange || isStart || isEnd
return (
<div
key={index}
className={cn(
'relative flex h-[32px] items-center justify-center',
isRangeMode &&
hasRangeHighlight &&
'before:absolute before:inset-y-[2px] before:right-0 before:left-0 before:bg-[#60a5fa]/25',
isRangeMode && isStart && 'before:left-[2px] before:rounded-l-[4px]',
isRangeMode && isEnd && 'before:right-[2px] before:rounded-r-[4px]',
isRangeMode && isStart && isEnd && 'before:rounded-[4px]'
)}
>
{day !== null && (
<button
type='button'
className={cn(
'relative z-10 flex h-[28px] w-[28px] items-center justify-center rounded-[4px] text-[12px] transition-colors',
isRangeMode
? isStart || isEnd
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-5)]'
: isSelected(day)
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: isToday(day)
? 'bg-[var(--surface-5)] text-[var(--text-primary)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-5)]'
)}
onClick={() => onSelectDate(day)}
onMouseEnter={() => onHoverDate?.(day)}
onMouseLeave={() => onHoverDate?.(null)}
>
{day}
</button>
)}
</div>
)
})}
</div>
</div>
)
}
/** /**
* DatePicker component matching emcn design patterns. * DatePicker component matching emcn design patterns.
* Provides a calendar dropdown for date selection. * Provides a calendar dropdown for date selection.
* Supports both single date and date range modes.
*/ */
const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>( const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref) => {
( const {
{ className, variant, size, value, onChange, placeholder = 'Select date', disabled, ...props }, className,
ref variant,
) => { size,
const [open, setOpen] = React.useState(false) placeholder = props.mode === 'range' ? 'Select date range' : 'Select date',
const selectedDate = parseDate(value) disabled,
showTrigger = true,
open: controlledOpen,
onOpenChange,
inline = false,
mode: _mode,
...rest
} = props
const {
value: _value,
onChange: _onChange,
startDate: _startDate,
endDate: _endDate,
onRangeChange: _onRangeChange,
onCancel: _onCancel,
onClear: _onClear,
...htmlProps
} = rest as any
const isRangeMode = props.mode === 'range'
const isControlled = controlledOpen !== undefined
const [internalOpen, setInternalOpen] = React.useState(false)
const open = isControlled ? controlledOpen : internalOpen
const setOpen = React.useCallback(
(value: boolean) => {
if (!isControlled) {
setInternalOpen(value)
}
onOpenChange?.(value)
},
[isControlled, onOpenChange]
)
const selectedDate = !isRangeMode ? parseDate(props.value) : null
const initialStart = isRangeMode ? parseDate(props.startDate) : null
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
const [rangeEnd, setRangeEnd] = React.useState<Date | null>(initialEnd)
const [hoverDate, setHoverDate] = React.useState<Date | null>(null)
const [selectingEnd, setSelectingEnd] = React.useState(false)
const [viewMonth, setViewMonth] = React.useState(() => { const [viewMonth, setViewMonth] = React.useState(() => {
const d = selectedDate || new Date() const d = selectedDate || initialStart || new Date()
return d.getMonth() return d.getMonth()
}) })
const [viewYear, setViewYear] = React.useState(() => { const [viewYear, setViewYear] = React.useState(() => {
const d = selectedDate || new Date() const d = selectedDate || initialStart || new Date()
return d.getFullYear() return d.getFullYear()
}) })
// Update view when value changes externally const rightViewMonth = viewMonth === 11 ? 0 : viewMonth + 1
const rightViewYear = viewMonth === 11 ? viewYear + 1 : viewYear
React.useEffect(() => { React.useEffect(() => {
if (selectedDate) { if (open && isRangeMode) {
setRangeStart(initialStart)
setRangeEnd(initialEnd)
setSelectingEnd(false)
if (initialStart) {
setViewMonth(initialStart.getMonth())
setViewYear(initialStart.getFullYear())
} else {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
}
}
}, [open, isRangeMode, initialStart, initialEnd])
React.useEffect(() => {
if (!isRangeMode && selectedDate) {
setViewMonth(selectedDate.getMonth()) setViewMonth(selectedDate.getMonth())
setViewYear(selectedDate.getFullYear()) setViewYear(selectedDate.getFullYear())
} }
}, [value]) }, [isRangeMode, selectedDate])
/** /**
* Handles selection of a specific day in the calendar. * Handles selection of a specific day in single mode.
*/ */
const handleSelectDate = React.useCallback( const handleSelectDateSingle = React.useCallback(
(day: number) => { (day: number) => {
onChange?.(formatDateAsString(viewYear, viewMonth, day)) if (!isRangeMode && props.onChange) {
props.onChange(formatDateAsString(viewYear, viewMonth, day))
setOpen(false) setOpen(false)
}
}, },
[viewYear, viewMonth, onChange] [isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
) )
/**
* Handles selection of a day in range mode.
*/
const handleSelectDateRange = React.useCallback(
(year: number, month: number, day: number) => {
const date = new Date(year, month, day)
if (!selectingEnd || !rangeStart) {
setRangeStart(date)
setRangeEnd(null)
setSelectingEnd(true)
} else {
if (date < rangeStart) {
setRangeEnd(rangeStart)
setRangeStart(date)
} else {
setRangeEnd(date)
}
setSelectingEnd(false)
}
},
[selectingEnd, rangeStart]
)
/**
* Handles hover for range preview.
*/
const handleHoverDate = React.useCallback((year: number, month: number, day: number | null) => {
if (day === null) {
setHoverDate(null)
} else {
setHoverDate(new Date(year, month, day))
}
}, [])
/** /**
* Navigates to the previous month. * Navigates to the previous month.
*/ */
@@ -222,60 +636,54 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
}, [viewMonth]) }, [viewMonth])
/** /**
* Selects today's date and closes the picker. * Selects today's date (single mode only).
*/ */
const handleSelectToday = React.useCallback(() => { const handleSelectToday = React.useCallback(() => {
if (!isRangeMode && props.onChange) {
const now = new Date() const now = new Date()
setViewMonth(now.getMonth()) setViewMonth(now.getMonth())
setViewYear(now.getFullYear()) setViewYear(now.getFullYear())
onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false) setOpen(false)
}, [onChange]) }
}, [isRangeMode, props.onChange, setOpen])
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth)
/** /**
* Checks if a day is today's date. * Applies the selected range (range mode only).
*/ */
const isToday = React.useCallback( const handleApplyRange = React.useCallback(() => {
(day: number) => { if (isRangeMode && props.onRangeChange && rangeStart) {
const today = new Date() const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart
return ( const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : rangeEnd || rangeStart
today.getDate() === day && props.onRangeChange(
today.getMonth() === viewMonth && formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()),
today.getFullYear() === viewYear formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate())
)
},
[viewMonth, viewYear]
) )
setOpen(false)
}
}, [isRangeMode, props.onRangeChange, rangeStart, rangeEnd, setOpen])
/** /**
* Checks if a day is the currently selected date. * Cancels range selection.
*/ */
const isSelected = React.useCallback( const handleCancelRange = React.useCallback(() => {
(day: number) => { if (isRangeMode && props.onCancel) {
return ( props.onCancel()
selectedDate && }
selectedDate.getDate() === day && setOpen(false)
selectedDate.getMonth() === viewMonth && }, [isRangeMode, props.onCancel, setOpen])
selectedDate.getFullYear() === viewYear
)
},
[selectedDate, viewMonth, viewYear]
)
// Build calendar grid /**
const calendarDays = React.useMemo(() => { * Clears the selected range.
const days: (number | null)[] = [] */
for (let i = 0; i < firstDayOfMonth; i++) { const handleClearRange = React.useCallback(() => {
days.push(null) setRangeStart(null)
setRangeEnd(null)
setSelectingEnd(false)
if (isRangeMode && props.onClear) {
props.onClear()
} }
for (let day = 1; day <= daysInMonth; day++) { }, [isRangeMode, props.onClear])
days.push(day)
}
return days
}, [firstDayOfMonth, daysInMonth])
/** /**
* Handles keyboard events on the trigger. * Handles keyboard events on the trigger.
@@ -287,7 +695,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
setOpen(!open) setOpen(!open)
} }
}, },
[disabled, open] [disabled, open, setOpen]
) )
/** /**
@@ -297,11 +705,137 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
if (!disabled) { if (!disabled) {
setOpen(!open) setOpen(!open)
} }
}, [disabled, open]) }, [disabled, open, setOpen])
const displayValue = isRangeMode
? formatDateRangeForDisplay(initialStart, initialEnd)
: formatDateForDisplay(selectedDate)
const calendarContent = isRangeMode ? (
<>
<div className='flex'>
{/* Left Calendar */}
<CalendarMonth
viewMonth={viewMonth}
viewYear={viewYear}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
hoverDate={hoverDate}
isRangeMode
onSelectDate={(day) => handleSelectDateRange(viewYear, viewMonth, day)}
onHoverDate={(day) => handleHoverDate(viewYear, viewMonth, day)}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
showNavigation='left'
/>
{/* Divider */}
<div className='w-[1px] bg-[var(--border-1)]' />
{/* Right Calendar */}
<CalendarMonth
viewMonth={rightViewMonth}
viewYear={rightViewYear}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
hoverDate={hoverDate}
isRangeMode
onSelectDate={(day) => handleSelectDateRange(rightViewYear, rightViewMonth, day)}
onHoverDate={(day) => handleHoverDate(rightViewYear, rightViewMonth, day)}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
showNavigation='right'
/>
</div>
{/* Actions */}
<div className='flex items-center justify-between border-[var(--border-1)] border-t px-[12px] py-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={handleClearRange}
disabled={!rangeStart && !rangeEnd}
className='text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
>
Clear
</Button>
<div className='flex items-center gap-[8px]'>
<Button variant='outline' size='sm' onClick={handleCancelRange}>
Cancel
</Button>
<Button variant='active' size='sm' onClick={handleApplyRange} disabled={!rangeStart}>
Apply
</Button>
</div>
</div>
</>
) : (
<>
<CalendarMonth
viewMonth={viewMonth}
viewYear={viewYear}
selectedDate={selectedDate}
onSelectDate={handleSelectDateSingle}
onPrevMonth={goToPrevMonth}
onNextMonth={goToNextMonth}
/>
{/* Today Button */}
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
</>
)
const popoverContent = (
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
avoidCollisions={false}
className={cn(
'rounded-[6px] border border-[var(--border-1)] p-0',
isRangeMode ? 'w-auto' : 'w-[280px]'
)}
>
{calendarContent}
</PopoverContent>
)
if (inline) {
return (
<div
ref={ref}
className={cn(
'rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-2)]',
isRangeMode ? 'w-auto' : 'w-[280px]',
className
)}
{...htmlProps}
>
{calendarContent}
</div>
)
}
if (!showTrigger) {
return (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} {...htmlProps}>
<PopoverAnchor asChild>
<div />
</PopoverAnchor>
{popoverContent}
</div>
</Popover>
)
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<div ref={ref} className='relative w-full' {...props}> <div ref={ref} className='relative w-full' {...htmlProps}>
<PopoverAnchor asChild> <PopoverAnchor asChild>
<div <div
role='button' role='button'
@@ -315,8 +849,8 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
onClick={handleTriggerClick} onClick={handleTriggerClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<span className={cn('flex-1 truncate', !selectedDate && 'text-[var(--text-muted)]')}> <span className={cn('flex-1 truncate', !displayValue && 'text-[var(--text-muted)]')}>
{selectedDate ? formatDateForDisplay(selectedDate) : placeholder} {displayValue || placeholder}
</span> </span>
<ChevronDown <ChevronDown
className={cn( className={cn(
@@ -326,83 +860,11 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
/> />
</div> </div>
</PopoverAnchor> </PopoverAnchor>
{popoverContent}
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
avoidCollisions={false}
className='w-[280px] rounded-[6px] border border-[var(--border-1)] p-0'
>
{/* Calendar Header */}
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-[12px] py-[10px]'>
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
onClick={goToPrevMonth}
>
<ChevronLeft className='h-4 w-4' />
</button>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{MONTHS[viewMonth]} {viewYear}
</span>
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
onClick={goToNextMonth}
>
<ChevronRight className='h-4 w-4' />
</button>
</div>
{/* Day Headers */}
<div className='grid grid-cols-7 px-[8px] pt-[8px]'>
{DAYS.map((day) => (
<div
key={day}
className='flex h-[28px] items-center justify-center text-[11px] text-[var(--text-muted)]'
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className='grid grid-cols-7 px-[8px] pb-[8px]'>
{calendarDays.map((day, index) => (
<div key={index} className='flex h-[32px] items-center justify-center'>
{day !== null && (
<button
type='button'
className={cn(
'flex h-[28px] w-[28px] items-center justify-center rounded-[4px] text-[12px] transition-colors',
isSelected(day)
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: isToday(day)
? 'bg-[var(--surface-5)] text-[var(--text-primary)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-5)]'
)}
onClick={() => handleSelectDate(day)}
>
{day}
</button>
)}
</div>
))}
</div>
{/* Today Button */}
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
<Button variant='active' className='w-full' onClick={handleSelectToday}>
Today
</Button>
</div>
</PopoverContent>
</div> </div>
</Popover> </Popover>
) )
} })
)
DatePicker.displayName = 'DatePicker' DatePicker.displayName = 'DatePicker'

View File

@@ -1,5 +1,5 @@
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { getStartDateFromTimeRange } from '@/lib/logs/filters' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types' import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types'
@@ -16,6 +16,8 @@ export const logKeys = {
interface LogFilters { interface LogFilters {
timeRange: TimeRange timeRange: TimeRange
startDate?: string
endDate?: string
level: string level: string
workflowIds: string[] workflowIds: string[]
folderIds: string[] folderIds: string[]
@@ -45,11 +47,16 @@ function applyFilterParams(params: URLSearchParams, filters: Omit<LogFilters, 'l
params.set('folderIds', filters.folderIds.join(',')) params.set('folderIds', filters.folderIds.join(','))
} }
const startDate = getStartDateFromTimeRange(filters.timeRange) const startDate = getStartDateFromTimeRange(filters.timeRange, filters.startDate)
if (startDate) { if (startDate) {
params.set('startDate', startDate.toISOString()) params.set('startDate', startDate.toISOString())
} }
const endDate = getEndDateFromTimeRange(filters.timeRange, filters.endDate)
if (endDate) {
params.set('endDate', endDate.toISOString())
}
if (filters.searchQuery.trim()) { if (filters.searchQuery.trim()) {
const parsedQuery = parseQuery(filters.searchQuery.trim()) const parsedQuery = parseQuery(filters.searchQuery.trim())
const searchParams = queryToApiParams(parsedQuery) const searchParams = queryToApiParams(parsedQuery)

View File

@@ -29,13 +29,24 @@ export type LogFilterParams = z.infer<typeof LogFilterParamsSchema>
/** /**
* Calculates start date from a time range string. * Calculates start date from a time range string.
* Returns null for 'All time' to indicate no date filtering. * Returns null for 'All time' or 'Custom range' to indicate the dates
* should be handled separately.
* @param timeRange - The time range option selected by the user * @param timeRange - The time range option selected by the user
* @param startDate - Optional start date (YYYY-MM-DD) for custom range
* @returns Date object for the start of the range, or null for 'All time' * @returns Date object for the start of the range, or null for 'All time'
*/ */
export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null { export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: string): Date | null {
if (timeRange === 'All time') return null if (timeRange === 'All time') return null
if (timeRange === 'Custom range') {
if (startDate) {
const date = new Date(startDate)
date.setHours(0, 0, 0, 0)
return date
}
return null
}
const now = new Date() const now = new Date()
switch (timeRange) { switch (timeRange) {
@@ -62,6 +73,26 @@ export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null {
} }
} }
/**
* Gets the end date for a time range.
* Returns null for preset ranges (uses current time as implicit end).
* Returns end of day for custom ranges.
* @param timeRange - The time range option selected by the user
* @param endDate - Optional end date (YYYY-MM-DD) for custom range
* @returns Date object for the end of the range, or null for preset ranges
*/
export function getEndDateFromTimeRange(timeRange: TimeRange, endDate?: string): Date | null {
if (timeRange !== 'Custom range') return null
if (endDate) {
const date = new Date(endDate)
date.setHours(23, 59, 59, 999)
return date
}
return null
}
type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!=' type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!='
function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined { function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined {

View File

@@ -1,16 +1,22 @@
import { describe, expect, test } from 'vitest' /**
* Tests for query language parser for logs search
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
describe('parseQuery', () => { describe('parseQuery', () => {
describe('empty and whitespace input', () => { describe('empty and whitespace input', () => {
test('should handle empty string', () => { it.concurrent('should handle empty string', () => {
const result = parseQuery('') const result = parseQuery('')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('') expect(result.textSearch).toBe('')
}) })
test('should handle whitespace only', () => { it.concurrent('should handle whitespace only', () => {
const result = parseQuery(' ') const result = parseQuery(' ')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
@@ -19,14 +25,14 @@ describe('parseQuery', () => {
}) })
describe('simple text search', () => { describe('simple text search', () => {
test('should parse plain text as textSearch', () => { it.concurrent('should parse plain text as textSearch', () => {
const result = parseQuery('hello world') const result = parseQuery('hello world')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('hello world') expect(result.textSearch).toBe('hello world')
}) })
test('should preserve text case', () => { it.concurrent('should preserve text case', () => {
const result = parseQuery('Hello World') const result = parseQuery('Hello World')
expect(result.textSearch).toBe('Hello World') expect(result.textSearch).toBe('Hello World')
@@ -34,7 +40,7 @@ describe('parseQuery', () => {
}) })
describe('level filter', () => { describe('level filter', () => {
test('should parse level:error filter', () => { it.concurrent('should parse level:error filter', () => {
const result = parseQuery('level:error') const result = parseQuery('level:error')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -43,7 +49,7 @@ describe('parseQuery', () => {
expect(result.filters[0].operator).toBe('=') expect(result.filters[0].operator).toBe('=')
}) })
test('should parse level:info filter', () => { it.concurrent('should parse level:info filter', () => {
const result = parseQuery('level:info') const result = parseQuery('level:info')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -53,7 +59,7 @@ describe('parseQuery', () => {
}) })
describe('status filter (alias for level)', () => { describe('status filter (alias for level)', () => {
test('should parse status:error filter', () => { it.concurrent('should parse status:error filter', () => {
const result = parseQuery('status:error') const result = parseQuery('status:error')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -63,7 +69,7 @@ describe('parseQuery', () => {
}) })
describe('workflow filter', () => { describe('workflow filter', () => {
test('should parse workflow filter with quoted value', () => { it.concurrent('should parse workflow filter with quoted value', () => {
const result = parseQuery('workflow:"my-workflow"') const result = parseQuery('workflow:"my-workflow"')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -71,7 +77,7 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('my-workflow') expect(result.filters[0].value).toBe('my-workflow')
}) })
test('should parse workflow filter with unquoted value', () => { it.concurrent('should parse workflow filter with unquoted value', () => {
const result = parseQuery('workflow:test-workflow') const result = parseQuery('workflow:test-workflow')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -81,7 +87,7 @@ describe('parseQuery', () => {
}) })
describe('trigger filter', () => { describe('trigger filter', () => {
test('should parse trigger:api filter', () => { it.concurrent('should parse trigger:api filter', () => {
const result = parseQuery('trigger:api') const result = parseQuery('trigger:api')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -89,25 +95,25 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('api') expect(result.filters[0].value).toBe('api')
}) })
test('should parse trigger:webhook filter', () => { it.concurrent('should parse trigger:webhook filter', () => {
const result = parseQuery('trigger:webhook') const result = parseQuery('trigger:webhook')
expect(result.filters[0].value).toBe('webhook') expect(result.filters[0].value).toBe('webhook')
}) })
test('should parse trigger:schedule filter', () => { it.concurrent('should parse trigger:schedule filter', () => {
const result = parseQuery('trigger:schedule') const result = parseQuery('trigger:schedule')
expect(result.filters[0].value).toBe('schedule') expect(result.filters[0].value).toBe('schedule')
}) })
test('should parse trigger:manual filter', () => { it.concurrent('should parse trigger:manual filter', () => {
const result = parseQuery('trigger:manual') const result = parseQuery('trigger:manual')
expect(result.filters[0].value).toBe('manual') expect(result.filters[0].value).toBe('manual')
}) })
test('should parse trigger:chat filter', () => { it.concurrent('should parse trigger:chat filter', () => {
const result = parseQuery('trigger:chat') const result = parseQuery('trigger:chat')
expect(result.filters[0].value).toBe('chat') expect(result.filters[0].value).toBe('chat')
@@ -115,7 +121,7 @@ describe('parseQuery', () => {
}) })
describe('cost filter with operators', () => { describe('cost filter with operators', () => {
test('should parse cost:>0.01 filter', () => { it.concurrent('should parse cost:>0.01 filter', () => {
const result = parseQuery('cost:>0.01') const result = parseQuery('cost:>0.01')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -124,35 +130,35 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe(0.01) expect(result.filters[0].value).toBe(0.01)
}) })
test('should parse cost:<0.005 filter', () => { it.concurrent('should parse cost:<0.005 filter', () => {
const result = parseQuery('cost:<0.005') const result = parseQuery('cost:<0.005')
expect(result.filters[0].operator).toBe('<') expect(result.filters[0].operator).toBe('<')
expect(result.filters[0].value).toBe(0.005) expect(result.filters[0].value).toBe(0.005)
}) })
test('should parse cost:>=0.05 filter', () => { it.concurrent('should parse cost:>=0.05 filter', () => {
const result = parseQuery('cost:>=0.05') const result = parseQuery('cost:>=0.05')
expect(result.filters[0].operator).toBe('>=') expect(result.filters[0].operator).toBe('>=')
expect(result.filters[0].value).toBe(0.05) expect(result.filters[0].value).toBe(0.05)
}) })
test('should parse cost:<=0.1 filter', () => { it.concurrent('should parse cost:<=0.1 filter', () => {
const result = parseQuery('cost:<=0.1') const result = parseQuery('cost:<=0.1')
expect(result.filters[0].operator).toBe('<=') expect(result.filters[0].operator).toBe('<=')
expect(result.filters[0].value).toBe(0.1) expect(result.filters[0].value).toBe(0.1)
}) })
test('should parse cost:!=0 filter', () => { it.concurrent('should parse cost:!=0 filter', () => {
const result = parseQuery('cost:!=0') const result = parseQuery('cost:!=0')
expect(result.filters[0].operator).toBe('!=') expect(result.filters[0].operator).toBe('!=')
expect(result.filters[0].value).toBe(0) expect(result.filters[0].value).toBe(0)
}) })
test('should parse cost:=0 filter', () => { it.concurrent('should parse cost:=0 filter', () => {
const result = parseQuery('cost:=0') const result = parseQuery('cost:=0')
expect(result.filters[0].operator).toBe('=') expect(result.filters[0].operator).toBe('=')
@@ -161,7 +167,7 @@ describe('parseQuery', () => {
}) })
describe('duration filter', () => { describe('duration filter', () => {
test('should parse duration:>5000 (ms) filter', () => { it.concurrent('should parse duration:>5000 (ms) filter', () => {
const result = parseQuery('duration:>5000') const result = parseQuery('duration:>5000')
expect(result.filters[0].field).toBe('duration') expect(result.filters[0].field).toBe('duration')
@@ -169,19 +175,19 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe(5000) expect(result.filters[0].value).toBe(5000)
}) })
test('should parse duration with ms suffix', () => { it.concurrent('should parse duration with ms suffix', () => {
const result = parseQuery('duration:>500ms') const result = parseQuery('duration:>500ms')
expect(result.filters[0].value).toBe(500) expect(result.filters[0].value).toBe(500)
}) })
test('should parse duration with s suffix (converts to ms)', () => { it.concurrent('should parse duration with s suffix (converts to ms)', () => {
const result = parseQuery('duration:>5s') const result = parseQuery('duration:>5s')
expect(result.filters[0].value).toBe(5000) expect(result.filters[0].value).toBe(5000)
}) })
test('should parse duration:<1s filter', () => { it.concurrent('should parse duration:<1s filter', () => {
const result = parseQuery('duration:<1s') const result = parseQuery('duration:<1s')
expect(result.filters[0].operator).toBe('<') expect(result.filters[0].operator).toBe('<')
@@ -190,7 +196,7 @@ describe('parseQuery', () => {
}) })
describe('date filter', () => { describe('date filter', () => {
test('should parse date:today filter', () => { it.concurrent('should parse date:today filter', () => {
const result = parseQuery('date:today') const result = parseQuery('date:today')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -198,15 +204,67 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('today') expect(result.filters[0].value).toBe('today')
}) })
test('should parse date:yesterday filter', () => { it.concurrent('should parse date:yesterday filter', () => {
const result = parseQuery('date:yesterday') const result = parseQuery('date:yesterday')
expect(result.filters[0].value).toBe('yesterday') expect(result.filters[0].value).toBe('yesterday')
}) })
it.concurrent('should parse date:this-week filter', () => {
const result = parseQuery('date:this-week')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('this-week')
})
it.concurrent('should parse date:last-week filter', () => {
const result = parseQuery('date:last-week')
expect(result.filters[0].value).toBe('last-week')
})
it.concurrent('should parse date:this-month filter', () => {
const result = parseQuery('date:this-month')
expect(result.filters[0].value).toBe('this-month')
})
it.concurrent('should parse year-only format (YYYY)', () => {
const result = parseQuery('date:2024')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024')
})
it.concurrent('should parse month-only format (YYYY-MM)', () => {
const result = parseQuery('date:2024-12')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-12')
})
it.concurrent('should parse full date format (YYYY-MM-DD)', () => {
const result = parseQuery('date:2024-12-25')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-12-25')
})
it.concurrent('should parse date range format (YYYY-MM-DD..YYYY-MM-DD)', () => {
const result = parseQuery('date:2024-01-01..2024-01-15')
expect(result.filters).toHaveLength(1)
expect(result.filters[0].field).toBe('date')
expect(result.filters[0].value).toBe('2024-01-01..2024-01-15')
})
}) })
describe('folder filter', () => { describe('folder filter', () => {
test('should parse folder filter with quoted value', () => { it.concurrent('should parse folder filter with quoted value', () => {
const result = parseQuery('folder:"My Folder"') const result = parseQuery('folder:"My Folder"')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -216,7 +274,7 @@ describe('parseQuery', () => {
}) })
describe('ID filters', () => { describe('ID filters', () => {
test('should parse executionId filter', () => { it.concurrent('should parse executionId filter', () => {
const result = parseQuery('executionId:exec-123-abc') const result = parseQuery('executionId:exec-123-abc')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -224,7 +282,7 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('exec-123-abc') expect(result.filters[0].value).toBe('exec-123-abc')
}) })
test('should parse workflowId filter', () => { it.concurrent('should parse workflowId filter', () => {
const result = parseQuery('workflowId:wf-456-def') const result = parseQuery('workflowId:wf-456-def')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -232,7 +290,7 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('wf-456-def') expect(result.filters[0].value).toBe('wf-456-def')
}) })
test('should parse execution filter (alias)', () => { it.concurrent('should parse execution filter (alias)', () => {
const result = parseQuery('execution:exec-789') const result = parseQuery('execution:exec-789')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -240,7 +298,7 @@ describe('parseQuery', () => {
expect(result.filters[0].value).toBe('exec-789') expect(result.filters[0].value).toBe('exec-789')
}) })
test('should parse id filter', () => { it.concurrent('should parse id filter', () => {
const result = parseQuery('id:some-id-123') const result = parseQuery('id:some-id-123')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -249,7 +307,7 @@ describe('parseQuery', () => {
}) })
describe('combined filters and text', () => { describe('combined filters and text', () => {
test('should parse multiple filters', () => { it.concurrent('should parse multiple filters', () => {
const result = parseQuery('level:error trigger:api') const result = parseQuery('level:error trigger:api')
expect(result.filters).toHaveLength(2) expect(result.filters).toHaveLength(2)
@@ -258,7 +316,7 @@ describe('parseQuery', () => {
expect(result.textSearch).toBe('') expect(result.textSearch).toBe('')
}) })
test('should parse filters with text search', () => { it.concurrent('should parse filters with text search', () => {
const result = parseQuery('level:error some search text') const result = parseQuery('level:error some search text')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
@@ -266,14 +324,14 @@ describe('parseQuery', () => {
expect(result.textSearch).toBe('some search text') expect(result.textSearch).toBe('some search text')
}) })
test('should parse text before and after filters', () => { it.concurrent('should parse text before and after filters', () => {
const result = parseQuery('before level:error after') const result = parseQuery('before level:error after')
expect(result.filters).toHaveLength(1) expect(result.filters).toHaveLength(1)
expect(result.textSearch).toBe('before after') expect(result.textSearch).toBe('before after')
}) })
test('should parse complex query with multiple filters and text', () => { it.concurrent('should parse complex query with multiple filters and text', () => {
const result = parseQuery( const result = parseQuery(
'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text' 'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text'
) )
@@ -284,21 +342,21 @@ describe('parseQuery', () => {
}) })
describe('invalid filters', () => { describe('invalid filters', () => {
test('should treat unknown field as text', () => { it.concurrent('should treat unknown field as text', () => {
const result = parseQuery('unknownfield:value') const result = parseQuery('unknownfield:value')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('unknownfield:value') expect(result.textSearch).toBe('unknownfield:value')
}) })
test('should handle invalid number for cost', () => { it.concurrent('should handle invalid number for cost', () => {
const result = parseQuery('cost:>abc') const result = parseQuery('cost:>abc')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
expect(result.textSearch).toBe('cost:>abc') expect(result.textSearch).toBe('cost:>abc')
}) })
test('should handle invalid number for duration', () => { it.concurrent('should handle invalid number for duration', () => {
const result = parseQuery('duration:>notanumber') const result = parseQuery('duration:>notanumber')
expect(result.filters).toHaveLength(0) expect(result.filters).toHaveLength(0)
@@ -307,77 +365,77 @@ describe('parseQuery', () => {
}) })
describe('queryToApiParams', () => { describe('queryToApiParams', () => {
test('should return empty object for empty query', () => { it.concurrent('should return empty object for empty query', () => {
const parsed = parseQuery('') const parsed = parseQuery('')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(Object.keys(params)).toHaveLength(0) expect(Object.keys(params)).toHaveLength(0)
}) })
test('should set search param for text search', () => { it.concurrent('should set search param for text search', () => {
const parsed = parseQuery('hello world') const parsed = parseQuery('hello world')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.search).toBe('hello world') expect(params.search).toBe('hello world')
}) })
test('should set level param for level filter', () => { it.concurrent('should set level param for level filter', () => {
const parsed = parseQuery('level:error') const parsed = parseQuery('level:error')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.level).toBe('error') expect(params.level).toBe('error')
}) })
test('should combine multiple level filters with comma', () => { it.concurrent('should combine multiple level filters with comma', () => {
const parsed = parseQuery('level:error level:info') const parsed = parseQuery('level:error level:info')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.level).toBe('error,info') expect(params.level).toBe('error,info')
}) })
test('should set triggers param for trigger filter', () => { it.concurrent('should set triggers param for trigger filter', () => {
const parsed = parseQuery('trigger:api') const parsed = parseQuery('trigger:api')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.triggers).toBe('api') expect(params.triggers).toBe('api')
}) })
test('should combine multiple trigger filters', () => { it.concurrent('should combine multiple trigger filters', () => {
const parsed = parseQuery('trigger:api trigger:webhook') const parsed = parseQuery('trigger:api trigger:webhook')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.triggers).toBe('api,webhook') expect(params.triggers).toBe('api,webhook')
}) })
test('should set workflowName param for workflow filter', () => { it.concurrent('should set workflowName param for workflow filter', () => {
const parsed = parseQuery('workflow:"my-workflow"') const parsed = parseQuery('workflow:"my-workflow"')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.workflowName).toBe('my-workflow') expect(params.workflowName).toBe('my-workflow')
}) })
test('should set folderName param for folder filter', () => { it.concurrent('should set folderName param for folder filter', () => {
const parsed = parseQuery('folder:"My Folder"') const parsed = parseQuery('folder:"My Folder"')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.folderName).toBe('My Folder') expect(params.folderName).toBe('My Folder')
}) })
test('should set workflowIds param for workflowId filter', () => { it.concurrent('should set workflowIds param for workflowId filter', () => {
const parsed = parseQuery('workflowId:wf-123') const parsed = parseQuery('workflowId:wf-123')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.workflowIds).toBe('wf-123') expect(params.workflowIds).toBe('wf-123')
}) })
test('should set executionId param for executionId filter', () => { it.concurrent('should set executionId param for executionId filter', () => {
const parsed = parseQuery('executionId:exec-456') const parsed = parseQuery('executionId:exec-456')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
expect(params.executionId).toBe('exec-456') expect(params.executionId).toBe('exec-456')
}) })
test('should set cost params with operator', () => { it.concurrent('should set cost params with operator', () => {
const parsed = parseQuery('cost:>0.01') const parsed = parseQuery('cost:>0.01')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
@@ -385,7 +443,7 @@ describe('queryToApiParams', () => {
expect(params.costValue).toBe('0.01') expect(params.costValue).toBe('0.01')
}) })
test('should set duration params with operator', () => { it.concurrent('should set duration params with operator', () => {
const parsed = parseQuery('duration:>5s') const parsed = parseQuery('duration:>5s')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
@@ -393,7 +451,7 @@ describe('queryToApiParams', () => {
expect(params.durationValue).toBe('5000') expect(params.durationValue).toBe('5000')
}) })
test('should set startDate for date:today', () => { it('should set startDate for date:today', () => {
const parsed = parseQuery('date:today') const parsed = parseQuery('date:today')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
@@ -404,7 +462,7 @@ describe('queryToApiParams', () => {
expect(startDate.getTime()).toBe(today.getTime()) expect(startDate.getTime()).toBe(today.getTime())
}) })
test('should set startDate and endDate for date:yesterday', () => { it('should set startDate and endDate for date:yesterday', () => {
const parsed = parseQuery('date:yesterday') const parsed = parseQuery('date:yesterday')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)
@@ -412,7 +470,112 @@ describe('queryToApiParams', () => {
expect(params.endDate).toBeDefined() expect(params.endDate).toBeDefined()
}) })
test('should combine execution filter with text search', () => { it('should set startDate for date:this-week', () => {
const parsed = parseQuery('date:this-week')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
const startDate = new Date(params.startDate)
expect(startDate.getDay()).toBe(0)
})
it('should set startDate and endDate for date:last-week', () => {
const parsed = parseQuery('date:last-week')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
})
it('should set startDate for date:this-month', () => {
const parsed = parseQuery('date:this-month')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
const startDate = new Date(params.startDate)
expect(startDate.getDate()).toBe(1)
})
it.concurrent('should set startDate and endDate for year-only (date:2024)', () => {
const parsed = parseQuery('date:2024')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(0)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(31)
})
it.concurrent('should set startDate and endDate for month-only (date:2024-12)', () => {
const parsed = parseQuery('date:2024-12')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(11)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(31)
})
it.concurrent('should set startDate and endDate for full date (date:2024-12-25)', () => {
const parsed = parseQuery('date:2024-12-25')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(11)
expect(startDate.getDate()).toBe(25)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(11)
expect(endDate.getDate()).toBe(25)
})
it.concurrent(
'should set startDate and endDate for date range (date:2024-01-01..2024-01-15)',
() => {
const parsed = parseQuery('date:2024-01-01..2024-01-15')
const params = queryToApiParams(parsed)
expect(params.startDate).toBeDefined()
expect(params.endDate).toBeDefined()
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
expect(startDate.getFullYear()).toBe(2024)
expect(startDate.getMonth()).toBe(0)
expect(startDate.getDate()).toBe(1)
expect(endDate.getFullYear()).toBe(2024)
expect(endDate.getMonth()).toBe(0)
expect(endDate.getDate()).toBe(15)
}
)
it.concurrent('should combine execution filter with text search', () => {
const parsed = { const parsed = {
filters: [ filters: [
{ {
@@ -429,7 +592,7 @@ describe('queryToApiParams', () => {
expect(params.search).toBe('some text exec-123') expect(params.search).toBe('some text exec-123')
}) })
test('should handle complex query with all params', () => { it.concurrent('should handle complex query with all params', () => {
const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"') const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"')
const params = queryToApiParams(parsed) const params = queryToApiParams(parsed)

View File

@@ -200,11 +200,27 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
break break
case 'date': case 'date':
if (filter.operator === '=' && filter.value === 'today') { if (filter.operator === '=') {
const dateValue = String(filter.value)
// Handle range syntax: date:2024-01-01..2024-01-15
if (dateValue.includes('..')) {
const [startStr, endStr] = dateValue.split('..')
if (startStr && /^\d{4}-\d{2}-\d{2}$/.test(startStr)) {
const [year, month, day] = startStr.split('-').map(Number)
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
params.startDate = startDate.toISOString()
}
if (endStr && /^\d{4}-\d{2}-\d{2}$/.test(endStr)) {
const [year, month, day] = endStr.split('-').map(Number)
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
params.endDate = endDate.toISOString()
}
} else if (dateValue === 'today') {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
params.startDate = today.toISOString() params.startDate = today.toISOString()
} else if (filter.operator === '=' && filter.value === 'yesterday') { } else if (dateValue === 'yesterday') {
const yesterday = new Date() const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1) yesterday.setDate(yesterday.getDate() - 1)
yesterday.setHours(0, 0, 0, 0) yesterday.setHours(0, 0, 0, 0)
@@ -213,6 +229,61 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
const endOfYesterday = new Date(yesterday) const endOfYesterday = new Date(yesterday)
endOfYesterday.setHours(23, 59, 59, 999) endOfYesterday.setHours(23, 59, 59, 999)
params.endDate = endOfYesterday.toISOString() params.endDate = endOfYesterday.toISOString()
} else if (dateValue === 'this-week') {
const now = new Date()
const dayOfWeek = now.getDay()
const startOfWeek = new Date(now)
startOfWeek.setDate(now.getDate() - dayOfWeek)
startOfWeek.setHours(0, 0, 0, 0)
params.startDate = startOfWeek.toISOString()
} else if (dateValue === 'last-week') {
const now = new Date()
const dayOfWeek = now.getDay()
const startOfThisWeek = new Date(now)
startOfThisWeek.setDate(now.getDate() - dayOfWeek)
startOfThisWeek.setHours(0, 0, 0, 0)
const startOfLastWeek = new Date(startOfThisWeek)
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7)
params.startDate = startOfLastWeek.toISOString()
const endOfLastWeek = new Date(startOfThisWeek)
endOfLastWeek.setMilliseconds(-1)
params.endDate = endOfLastWeek.toISOString()
} else if (dateValue === 'this-month') {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
startOfMonth.setHours(0, 0, 0, 0)
params.startDate = startOfMonth.toISOString()
} else if (/^\d{4}$/.test(dateValue)) {
// Year only: YYYY (e.g., 2024)
const year = Number.parseInt(dateValue, 10)
const startOfYear = new Date(year, 0, 1)
startOfYear.setHours(0, 0, 0, 0)
params.startDate = startOfYear.toISOString()
const endOfYear = new Date(year, 11, 31)
endOfYear.setHours(23, 59, 59, 999)
params.endDate = endOfYear.toISOString()
} else if (/^\d{4}-\d{2}$/.test(dateValue)) {
// Month only: YYYY-MM (e.g., 2024-12)
const [year, month] = dateValue.split('-').map(Number)
const startOfMonth = new Date(year, month - 1, 1)
startOfMonth.setHours(0, 0, 0, 0)
params.startDate = startOfMonth.toISOString()
const endOfMonth = new Date(year, month, 0) // Day 0 of next month = last day of this month
endOfMonth.setHours(23, 59, 59, 999)
params.endDate = endOfMonth.toISOString()
} else if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
// Parse as a single date (YYYY-MM-DD) using local timezone
const [year, month, day] = dateValue.split('-').map(Number)
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
params.startDate = startDate.toISOString()
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
params.endDate = endDate.toISOString()
}
} }
break break

View File

@@ -1,4 +1,9 @@
import { describe, expect, test } from 'vitest' /**
* Tests for search suggestions functionality in logs search
*
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { import {
FILTER_DEFINITIONS, FILTER_DEFINITIONS,
type FolderData, type FolderData,
@@ -8,7 +13,7 @@ import {
} from '@/lib/logs/search-suggestions' } from '@/lib/logs/search-suggestions'
describe('FILTER_DEFINITIONS', () => { describe('FILTER_DEFINITIONS', () => {
test('should have level filter definition', () => { it.concurrent('should have level filter definition', () => {
const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level') const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level')
expect(levelFilter).toBeDefined() expect(levelFilter).toBeDefined()
@@ -18,7 +23,7 @@ describe('FILTER_DEFINITIONS', () => {
expect(levelFilter?.options.map((o) => o.value)).toContain('info') expect(levelFilter?.options.map((o) => o.value)).toContain('info')
}) })
test('should have cost filter definition with multiple options', () => { it.concurrent('should have cost filter definition with multiple options', () => {
const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost') const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost')
expect(costFilter).toBeDefined() expect(costFilter).toBeDefined()
@@ -28,7 +33,7 @@ describe('FILTER_DEFINITIONS', () => {
expect(costFilter?.options.map((o) => o.value)).toContain('<0.005') expect(costFilter?.options.map((o) => o.value)).toContain('<0.005')
}) })
test('should have date filter definition', () => { it.concurrent('should have date filter definition', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date') const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
expect(dateFilter).toBeDefined() expect(dateFilter).toBeDefined()
@@ -37,7 +42,39 @@ describe('FILTER_DEFINITIONS', () => {
expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday') expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday')
}) })
test('should have duration filter definition', () => { it.concurrent('should have date filter with all keyword options', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
const values = dateFilter?.options.map((o) => o.value) || []
expect(values).toContain('today')
expect(values).toContain('yesterday')
expect(values).toContain('this-week')
expect(values).toContain('last-week')
expect(values).toContain('this-month')
})
it.concurrent('should have dynamic date examples in date filter', () => {
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
const options = dateFilter?.options || []
const specificDate = options.find((o) => o.label === 'Specific date')
expect(specificDate).toBeDefined()
expect(specificDate?.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
const specificMonth = options.find((o) => o.label === 'Specific month')
expect(specificMonth).toBeDefined()
expect(specificMonth?.value).toMatch(/^\d{4}-\d{2}$/)
const specificYear = options.find((o) => o.label === 'Specific year')
expect(specificYear).toBeDefined()
expect(specificYear?.value).toMatch(/^\d{4}$/)
const dateRange = options.find((o) => o.label === 'Date range')
expect(dateRange).toBeDefined()
expect(dateRange?.value).toMatch(/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/)
})
it.concurrent('should have duration filter definition', () => {
const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration') const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration')
expect(durationFilter).toBeDefined() expect(durationFilter).toBeDefined()
@@ -69,19 +106,19 @@ describe('SearchSuggestions', () => {
] ]
describe('constructor', () => { describe('constructor', () => {
test('should create instance with empty data', () => { it.concurrent('should create instance with empty data', () => {
const suggestions = new SearchSuggestions() const suggestions = new SearchSuggestions()
expect(suggestions).toBeDefined() expect(suggestions).toBeDefined()
}) })
test('should create instance with provided data', () => { it.concurrent('should create instance with provided data', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
expect(suggestions).toBeDefined() expect(suggestions).toBeDefined()
}) })
}) })
describe('updateData', () => { describe('updateData', () => {
test('should update internal data', () => { it.concurrent('should update internal data', () => {
const suggestions = new SearchSuggestions() const suggestions = new SearchSuggestions()
suggestions.updateData(mockWorkflows, mockFolders, mockTriggers) suggestions.updateData(mockWorkflows, mockFolders, mockTriggers)
@@ -92,7 +129,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - empty input', () => { describe('getSuggestions - empty input', () => {
test('should return filter keys list for empty input', () => { it.concurrent('should return filter keys list for empty input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -101,7 +138,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.length).toBeGreaterThan(0) expect(result?.suggestions.length).toBeGreaterThan(0)
}) })
test('should include core filter keys', () => { it.concurrent('should include core filter keys', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -113,7 +150,7 @@ describe('SearchSuggestions', () => {
expect(filterValues).toContain('trigger:') expect(filterValues).toContain('trigger:')
}) })
test('should include workflow filter when workflows exist', () => { it.concurrent('should include workflow filter when workflows exist', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -121,7 +158,7 @@ describe('SearchSuggestions', () => {
expect(filterValues).toContain('workflow:') expect(filterValues).toContain('workflow:')
}) })
test('should include folder filter when folders exist', () => { it.concurrent('should include folder filter when folders exist', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -129,7 +166,7 @@ describe('SearchSuggestions', () => {
expect(filterValues).toContain('folder:') expect(filterValues).toContain('folder:')
}) })
test('should not include workflow filter when no workflows', () => { it.concurrent('should not include workflow filter when no workflows', () => {
const suggestions = new SearchSuggestions([], mockFolders, mockTriggers) const suggestions = new SearchSuggestions([], mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -139,7 +176,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - filter values (ending with colon)', () => { describe('getSuggestions - filter values (ending with colon)', () => {
test('should return level filter values', () => { it.concurrent('should return level filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:') const result = suggestions.getSuggestions('level:')
@@ -149,7 +186,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true)
}) })
test('should return cost filter values', () => { it.concurrent('should return cost filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('cost:') const result = suggestions.getSuggestions('cost:')
@@ -158,7 +195,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true)
}) })
test('should return trigger filter values', () => { it.concurrent('should return trigger filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:') const result = suggestions.getSuggestions('trigger:')
@@ -168,7 +205,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true)
}) })
test('should return workflow filter values', () => { it.concurrent('should return workflow filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:') const result = suggestions.getSuggestions('workflow:')
@@ -177,7 +214,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true) expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
}) })
test('should return folder filter values', () => { it.concurrent('should return folder filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('folder:') const result = suggestions.getSuggestions('folder:')
@@ -186,7 +223,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true) expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
}) })
test('should return null for unknown filter key', () => { it.concurrent('should return null for unknown filter key', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('unknown:') const result = suggestions.getSuggestions('unknown:')
@@ -195,7 +232,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - partial filter values', () => { describe('getSuggestions - partial filter values', () => {
test('should filter level values by partial input', () => { it.concurrent('should filter level values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:err') const result = suggestions.getSuggestions('level:err')
@@ -204,7 +241,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false) expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false)
}) })
test('should filter workflow values by partial input', () => { it.concurrent('should filter workflow values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:test') const result = suggestions.getSuggestions('workflow:test')
@@ -213,7 +250,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false) expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false)
}) })
test('should filter trigger values by partial input', () => { it.concurrent('should filter trigger values by partial input', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:sch') const result = suggestions.getSuggestions('trigger:sch')
@@ -221,7 +258,7 @@ describe('SearchSuggestions', () => {
expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true)
}) })
test('should return null when no matches found', () => { it.concurrent('should return null when no matches found', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('level:xyz') const result = suggestions.getSuggestions('level:xyz')
@@ -230,7 +267,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - plain text search (multi-section)', () => { describe('getSuggestions - plain text search (multi-section)', () => {
test('should return multi-section results for plain text', () => { it.concurrent('should return multi-section results for plain text', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('test') const result = suggestions.getSuggestions('test')
@@ -238,49 +275,49 @@ describe('SearchSuggestions', () => {
expect(result?.type).toBe('multi-section') expect(result?.type).toBe('multi-section')
}) })
test('should include show-all suggestion', () => { it.concurrent('should include show-all suggestion', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('test') const result = suggestions.getSuggestions('test')
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true) expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
}) })
test('should match workflows by name', () => { it.concurrent('should match workflows by name', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('production') const result = suggestions.getSuggestions('production')
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true) expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true)
}) })
test('should match workflows by description', () => { it.concurrent('should match workflows by description', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('API requests') const result = suggestions.getSuggestions('API requests')
expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true) expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true)
}) })
test('should match folders by name', () => { it.concurrent('should match folders by name', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('development') const result = suggestions.getSuggestions('development')
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true) expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
}) })
test('should match triggers by label', () => { it.concurrent('should match triggers by label', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('slack') const result = suggestions.getSuggestions('slack')
expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true)
}) })
test('should match filter values', () => { it.concurrent('should match filter values', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('error') const result = suggestions.getSuggestions('error')
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true) expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
}) })
test('should show suggested filters when no matches found', () => { it.concurrent('should show suggested filters when no matches found', () => {
const suggestions = new SearchSuggestions([], [], []) const suggestions = new SearchSuggestions([], [], [])
const result = suggestions.getSuggestions('xyz123') const result = suggestions.getSuggestions('xyz123')
@@ -290,7 +327,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - case insensitivity', () => { describe('getSuggestions - case insensitivity', () => {
test('should match regardless of case', () => { it.concurrent('should match regardless of case', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const lowerResult = suggestions.getSuggestions('test') const lowerResult = suggestions.getSuggestions('test')
@@ -304,7 +341,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - sorting', () => { describe('getSuggestions - sorting', () => {
test('should sort exact matches first', () => { it.concurrent('should sort exact matches first', () => {
const workflows: WorkflowData[] = [ const workflows: WorkflowData[] = [
{ id: '1', name: 'API Handler' }, { id: '1', name: 'API Handler' },
{ id: '2', name: 'API' }, { id: '2', name: 'API' },
@@ -317,7 +354,7 @@ describe('SearchSuggestions', () => {
expect(workflowSuggestions?.[0]?.label).toBe('API') expect(workflowSuggestions?.[0]?.label).toBe('API')
}) })
test('should sort prefix matches before substring matches', () => { it.concurrent('should sort prefix matches before substring matches', () => {
const workflows: WorkflowData[] = [ const workflows: WorkflowData[] = [
{ id: '1', name: 'Contains Test Inside' }, { id: '1', name: 'Contains Test Inside' },
{ id: '2', name: 'Test First' }, { id: '2', name: 'Test First' },
@@ -331,7 +368,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - result limits', () => { describe('getSuggestions - result limits', () => {
test('should limit workflow results to 8', () => { it.concurrent('should limit workflow results to 8', () => {
const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({ const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({
id: `wf-${i}`, id: `wf-${i}`,
name: `Test Workflow ${i}`, name: `Test Workflow ${i}`,
@@ -343,9 +380,9 @@ describe('SearchSuggestions', () => {
expect(workflowSuggestions?.length).toBeLessThanOrEqual(8) expect(workflowSuggestions?.length).toBeLessThanOrEqual(8)
}) })
test('should limit filter value results to 5', () => { it.concurrent('should limit filter value results to 5', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('o') // Matches multiple filter values const result = suggestions.getSuggestions('o')
const filterSuggestions = result?.suggestions.filter( const filterSuggestions = result?.suggestions.filter(
(s) => (s) =>
@@ -359,7 +396,7 @@ describe('SearchSuggestions', () => {
}) })
describe('getSuggestions - suggestion structure', () => { describe('getSuggestions - suggestion structure', () => {
test('should include correct properties for filter key suggestions', () => { it.concurrent('should include correct properties for filter key suggestions', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('') const result = suggestions.getSuggestions('')
@@ -370,7 +407,7 @@ describe('SearchSuggestions', () => {
expect(suggestion).toHaveProperty('category') expect(suggestion).toHaveProperty('category')
}) })
test('should include color for trigger suggestions', () => { it.concurrent('should include color for trigger suggestions', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('trigger:') const result = suggestions.getSuggestions('trigger:')
@@ -378,7 +415,7 @@ describe('SearchSuggestions', () => {
expect(triggerSuggestion?.color).toBeDefined() expect(triggerSuggestion?.color).toBeDefined()
}) })
test('should quote workflow names in value', () => { it.concurrent('should quote workflow names in value', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers) const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('workflow:') const result = suggestions.getSuggestions('workflow:')
@@ -386,4 +423,73 @@ describe('SearchSuggestions', () => {
expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"') expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"')
}) })
}) })
describe('getSuggestions - date filter values', () => {
it.concurrent('should return date filter keyword options', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:')
expect(result).not.toBeNull()
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'date:today')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:last-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
})
it.concurrent('should suggest year format when typing a year', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024')).toBe(true)
expect(result?.suggestions.some((s) => s.label === 'Year 2024')).toBe(true)
})
it.concurrent('should suggest month format when typing YYYY-MM', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-12')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-12')).toBe(true)
expect(result?.suggestions.some((s) => s.label === 'Dec 2024')).toBe(true)
})
it.concurrent('should suggest single date and range start when typing full date', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-12-25')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25..')).toBe(true)
})
it.concurrent('should suggest completing range when typing date..', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-01-01..')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.description?.includes('Type end date'))).toBe(true)
})
it.concurrent('should suggest complete range when both dates provided', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:2024-01-01..2024-01-15')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:2024-01-01..2024-01-15')).toBe(true)
expect(result?.suggestions.some((s) => s.description === 'Custom date range')).toBe(true)
})
it.concurrent('should filter date options by partial keyword match', () => {
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
const result = suggestions.getSuggestions('date:this')
expect(result).not.toBeNull()
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(false)
})
})
}) })

View File

@@ -9,6 +9,8 @@ export interface FilterDefinition {
label: string label: string
description?: string description?: string
}> }>
acceptsCustomValue?: boolean
customValueHint?: string
} }
export interface WorkflowData { export interface WorkflowData {
@@ -28,6 +30,20 @@ export interface TriggerData {
color: string color: string
} }
/**
* Generates current date examples for the date filter options.
*/
function getDateExamples() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const firstOfMonth = `${year}-${month}-01`
const today = `${year}-${month}-${day}`
const yearMonth = `${year}-${month}`
return { today, firstOfMonth, year: String(year), yearMonth }
}
export const FILTER_DEFINITIONS: FilterDefinition[] = [ export const FILTER_DEFINITIONS: FilterDefinition[] = [
{ {
key: 'level', key: 'level',
@@ -58,13 +74,24 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
key: 'date', key: 'date',
label: 'Date', label: 'Date',
description: 'Filter by date range', description: 'Filter by date range',
options: [ options: (() => {
const { today, firstOfMonth, year, yearMonth } = getDateExamples()
return [
{ value: 'today', label: 'Today', description: "Today's logs" }, { value: 'today', label: 'Today', description: "Today's logs" },
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" }, { value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
{ value: 'this-week', label: 'This week', description: "This week's logs" }, { value: 'this-week', label: 'This week', description: "This week's logs" },
{ value: 'last-week', label: 'Last week', description: "Last week's logs" }, { value: 'last-week', label: 'Last week', description: "Last week's logs" },
{ value: 'this-month', label: 'This month', description: "This month's logs" }, { value: 'this-month', label: 'This month', description: "This month's logs" },
], { value: today, label: 'Specific date', description: 'YYYY-MM-DD' },
{ value: yearMonth, label: 'Specific month', description: 'YYYY-MM' },
{ value: year, label: 'Specific year', description: 'YYYY' },
{
value: `${firstOfMonth}..${today}`,
label: 'Date range',
description: 'YYYY-MM-DD..YYYY-MM-DD',
},
]
})(),
}, },
{ {
key: 'duration', key: 'duration',
@@ -208,7 +235,7 @@ export class SearchSuggestions {
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key) const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key)
if (filterDef) { if (filterDef) {
const suggestions = filterDef.options const suggestions: Suggestion[] = filterDef.options
.filter( .filter(
(opt) => (opt) =>
!partial || !partial ||
@@ -220,9 +247,17 @@ export class SearchSuggestions {
value: `${key}:${opt.value}`, value: `${key}:${opt.value}`,
label: opt.label, label: opt.label,
description: opt.description, description: opt.description,
category: key as any, category: key as Suggestion['category'],
})) }))
// Handle custom date input
if (key === 'date' && partial) {
const dateSuggestions = this.getDateSuggestions(partial)
if (dateSuggestions.length > 0) {
suggestions.unshift(...dateSuggestions)
}
}
return suggestions.length > 0 return suggestions.length > 0
? { ? {
type: 'filter-values', type: 'filter-values',
@@ -372,6 +407,140 @@ export class SearchSuggestions {
: null : null
} }
/**
* Get suggestions for custom date input
*/
private getDateSuggestions(partial: string): Suggestion[] {
const suggestions: Suggestion[] = []
// Pattern for year only: YYYY
const yearPattern = /^\d{4}$/
// Pattern for month only: YYYY-MM
const monthPattern = /^\d{4}-\d{2}$/
// Pattern for full date: YYYY-MM-DD
const fullDatePattern = /^\d{4}-\d{2}-\d{2}$/
// Pattern for partial date being typed
const partialDatePattern = /^\d{4}(-\d{0,2})?(-\d{0,2})?$/
// Pattern for date range: YYYY-MM-DD..YYYY-MM-DD (complete or partial)
const rangePattern = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
const partialRangePattern = /^(\d{4}-\d{2}-\d{2})\.\.?$/
// Check if it's a complete date range
if (rangePattern.test(partial)) {
const [startDate, endDate] = partial.split('..')
suggestions.push({
id: `date-range-${partial}`,
value: `date:${partial}`,
label: `${this.formatDateLabel(startDate)} to ${this.formatDateLabel(endDate)}`,
description: 'Custom date range',
category: 'date' as any,
})
return suggestions
}
// Check if it's a partial date range (has ..)
if (partialRangePattern.test(partial)) {
const startDate = partial.replace(/\.+$/, '')
suggestions.push({
id: `date-range-hint-${partial}`,
value: `date:${startDate}..`,
label: `${this.formatDateLabel(startDate)} to ...`,
description: 'Type end date (YYYY-MM-DD)',
category: 'date' as any,
})
return suggestions
}
// Check if it's a year only (YYYY)
if (yearPattern.test(partial)) {
suggestions.push({
id: `date-year-${partial}`,
value: `date:${partial}`,
label: `Year ${partial}`,
description: 'All logs from this year',
category: 'date' as any,
})
return suggestions
}
// Check if it's a month only (YYYY-MM)
if (monthPattern.test(partial)) {
const [year, month] = partial.split('-')
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
const monthName = monthNames[Number.parseInt(month, 10) - 1] || month
suggestions.push({
id: `date-month-${partial}`,
value: `date:${partial}`,
label: `${monthName} ${year}`,
description: 'All logs from this month',
category: 'date' as any,
})
return suggestions
}
// Check if it's a complete single date
if (fullDatePattern.test(partial)) {
const date = new Date(partial)
if (!Number.isNaN(date.getTime())) {
suggestions.push({
id: `date-single-${partial}`,
value: `date:${partial}`,
label: this.formatDateLabel(partial),
description: 'Single date',
category: 'date' as any,
})
// Also suggest starting a range
suggestions.push({
id: `date-range-start-${partial}`,
value: `date:${partial}..`,
label: `${this.formatDateLabel(partial)} to ...`,
description: 'Start a date range',
category: 'date' as any,
})
}
return suggestions
}
// Check if user is typing a date pattern
if (partialDatePattern.test(partial) && partial.length >= 4) {
suggestions.push({
id: 'date-custom-hint',
value: `date:${partial}`,
label: partial,
description: 'Continue typing: YYYY, YYYY-MM, or YYYY-MM-DD',
category: 'date' as any,
})
}
return suggestions
}
/**
* Format a date string for display
*/
private formatDateLabel(dateStr: string): string {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
/** /**
* Match filter values across all definitions * Match filter values across all definitions
*/ */

View File

@@ -37,6 +37,8 @@ const parseTimeRangeFromURL = (value: string | null): TimeRange => {
return 'Past 14 days' return 'Past 14 days'
case 'past-30-days': case 'past-30-days':
return 'Past 30 days' return 'Past 30 days'
case 'custom':
return 'Custom range'
default: default:
return DEFAULT_TIME_RANGE return DEFAULT_TIME_RANGE
} }
@@ -85,6 +87,8 @@ const timeRangeToURL = (timeRange: TimeRange): string => {
return 'past-14-days' return 'past-14-days'
case 'Past 30 days': case 'Past 30 days':
return 'past-30-days' return 'past-30-days'
case 'Custom range':
return 'custom'
default: default:
return 'all-time' return 'all-time'
} }
@@ -94,6 +98,8 @@ export const useFilterStore = create<FilterState>((set, get) => ({
workspaceId: '', workspaceId: '',
viewMode: 'logs', viewMode: 'logs',
timeRange: DEFAULT_TIME_RANGE, timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
level: 'all', level: 'all',
workflowIds: [], workflowIds: [],
folderIds: [], folderIds: [],
@@ -112,6 +118,28 @@ export const useFilterStore = create<FilterState>((set, get) => ({
} }
}, },
setDateRange: (start, end) => {
set({
timeRange: 'Custom range',
startDate: start,
endDate: end,
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
clearDateRange: () => {
set({
timeRange: DEFAULT_TIME_RANGE,
startDate: undefined,
endDate: undefined,
})
if (!get().isInitializing) {
get().syncWithURL()
}
},
setLevel: (level) => { setLevel: (level) => {
set({ level }) set({ level })
if (!get().isInitializing) { if (!get().isInitializing) {
@@ -205,9 +233,13 @@ export const useFilterStore = create<FilterState>((set, get) => ({
const folderIds = parseStringArrayFromURL(params.get('folderIds')) const folderIds = parseStringArrayFromURL(params.get('folderIds'))
const triggers = parseTriggerArrayFromURL(params.get('triggers')) const triggers = parseTriggerArrayFromURL(params.get('triggers'))
const searchQuery = params.get('search') || '' const searchQuery = params.get('search') || ''
const startDate = params.get('startDate') || undefined
const endDate = params.get('endDate') || undefined
set({ set({
timeRange, timeRange,
startDate,
endDate,
level, level,
workflowIds, workflowIds,
folderIds, folderIds,
@@ -218,13 +250,23 @@ export const useFilterStore = create<FilterState>((set, get) => ({
}, },
syncWithURL: () => { syncWithURL: () => {
const { timeRange, level, workflowIds, folderIds, triggers, searchQuery } = get() const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } =
get()
const params = new URLSearchParams() const params = new URLSearchParams()
if (timeRange !== DEFAULT_TIME_RANGE) { if (timeRange !== DEFAULT_TIME_RANGE) {
params.set('timeRange', timeRangeToURL(timeRange)) params.set('timeRange', timeRangeToURL(timeRange))
} }
if (timeRange === 'Custom range') {
if (startDate) {
params.set('startDate', startDate)
}
if (endDate) {
params.set('endDate', endDate)
}
}
if (level !== 'all') { if (level !== 'all') {
params.set('level', level) params.set('level', level)
} }

View File

@@ -170,6 +170,7 @@ export type TimeRange =
| 'Past 14 days' | 'Past 14 days'
| 'Past 30 days' | 'Past 30 days'
| 'All time' | 'All time'
| 'Custom range'
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {}) export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
@@ -179,6 +180,8 @@ export interface FilterState {
workspaceId: string workspaceId: string
viewMode: 'logs' | 'dashboard' viewMode: 'logs' | 'dashboard'
timeRange: TimeRange timeRange: TimeRange
startDate?: string
endDate?: string
level: LogLevel level: LogLevel
workflowIds: string[] workflowIds: string[]
folderIds: string[] folderIds: string[]
@@ -189,6 +192,8 @@ export interface FilterState {
setWorkspaceId: (workspaceId: string) => void setWorkspaceId: (workspaceId: string) => void
setViewMode: (viewMode: 'logs' | 'dashboard') => void setViewMode: (viewMode: 'logs' | 'dashboard') => void
setTimeRange: (timeRange: TimeRange) => void setTimeRange: (timeRange: TimeRange) => void
setDateRange: (startDate: string | undefined, endDate: string | undefined) => void
clearDateRange: () => void
setLevel: (level: LogLevel) => void setLevel: (level: LogLevel) => void
setWorkflowIds: (workflowIds: string[]) => void setWorkflowIds: (workflowIds: string[]) => void
toggleWorkflowId: (workflowId: string) => void toggleWorkflowId: (workflowId: string) => void