mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(filtering): added the ability to filter logs by date and date range (#2639)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user