mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
refactor(emcn): consolidate date pickers onto the chip Calendar (range support + retire legacy DatePicker) (#5222)
* improvement(knowledge): use canonical ChipDatePicker in document tags modal The document tags modal used the legacy DatePicker primitive for date-typed tag values while the rest of the knowledge base date inputs use the canonical ChipDatePicker. Swap both usages (edit + create) to ChipDatePicker (same YYYY-MM-DD value contract, full-width to match sibling fields) so the chrome matches the chip design system, and align a tag-row value label to the caption text size used by its sibling. * feat(emcn): add range mode to ChipDatePicker via the canonical Calendar ChipDatePicker was single-date only, so date-range surfaces had to fall back to the legacy DatePicker primitive. Add range support as a discriminated mode on the existing canonical Calendar (not a parallel component): a start/end selection staged behind Clear/Cancel/Apply with optional time-of-day inputs, built from the chip family (CalendarDayCell, chipVariants, ChipTimePicker) and fully tokenized. ChipDatePicker gains a matching mode='range' that renders it behind the same chip trigger. Extract the range-bounds serialization into a pure, unit-tested helper, and remove the dead logs-toolbar directory (LogsToolbar/AutocompleteSearch had no consumers; the logs page renders its toolbar inline). * refactor(emcn): retire legacy DatePicker; migrate all consumers to the chip calendar Removes the parallel DatePicker component (its own dual-month CalendarMonth, hardcoded colors, non-chip chrome) so the chip Calendar is the single date surface. Migrates every consumer: - tables row modal -> ChipDatePicker (single) - tables inline grid editor, logs filter, ee audit-logs filter -> canonical Calendar inside an emcn Popover (the same headless/anchored pattern DatePicker rendered internally; single for the grid cell, range+time for the log filters) - playground -> ChipDatePicker showcase (single, range, range+time) The range bounds keep the exact YYYY-MM-DD / YYYY-MM-DDTHH:mm wire format, so the log filters' parsing is unchanged. Calendar is imported via its component path (the emcn barrel already exports a Calendar icon). * fix(emcn): resync range calendar draft state when bound props change Cursor Bugbot: RangeCalendarView seeded its staged selection from props only once. Closing the popover unmounts it (so a fresh open re-seeds), but if the bound startDate/endDate change while it stays mounted the grid could linger on a stale draft. Add a render-phase reset keyed on the bound props. * refactor(emcn): reset range calendar via key instead of render-phase resync Replaces the imperative render-phase state reset with React's idiomatic key-based reset: the range view is keyed on its committed bounds in the Calendar dispatcher, so a newly applied range remounts it with fresh draft state. Cleaner and fully encapsulated — consumers need no special handling. Move the rationale into TSDoc (no inline comment). * fix(logs): keep the date-range popover anchor from blocking the time-range select The custom-range Calendar is anchored with an always-rendered PopoverAnchor overlaying the time-range combobox (absolute inset-0). Without pointer-events-none it would intercept clicks meant for the select, leaving it unclickable. The anchor is only a positioning reference, so disable its pointer events on both the logs and audit-logs filters.
This commit is contained in:
@@ -16,11 +16,11 @@ import {
|
||||
Card as CardIcon,
|
||||
Checkbox,
|
||||
ChevronDown,
|
||||
ChipDatePicker,
|
||||
Code,
|
||||
Combobox,
|
||||
Connections,
|
||||
Cursor,
|
||||
DatePicker,
|
||||
DocumentAttachment,
|
||||
Download,
|
||||
Duplicate,
|
||||
@@ -772,24 +772,24 @@ export default function PlaygroundPage() {
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* DatePicker */}
|
||||
<Section title='DatePicker'>
|
||||
{/* ChipDatePicker */}
|
||||
<Section title='ChipDatePicker'>
|
||||
<VariantRow label='single date'>
|
||||
<div className='w-56'>
|
||||
<DatePicker value={dateValue} onChange={setDateValue} placeholder='Select date' />
|
||||
<ChipDatePicker
|
||||
value={dateValue}
|
||||
onChange={setDateValue}
|
||||
placeholder='Select date'
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>
|
||||
{dateValue || 'No date'}
|
||||
</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<div className='w-56'>
|
||||
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='range mode'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
<ChipDatePicker
|
||||
mode='range'
|
||||
startDate={dateRangeStart}
|
||||
endDate={dateRangeEnd}
|
||||
@@ -798,17 +798,31 @@ export default function PlaygroundPage() {
|
||||
setDateRangeEnd(end)
|
||||
}}
|
||||
placeholder='Select date range'
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='range mode (with time)'>
|
||||
<div className='w-72'>
|
||||
<ChipDatePicker
|
||||
mode='range'
|
||||
showTime
|
||||
startDate={dateRangeStart}
|
||||
endDate={dateRangeEnd}
|
||||
onRangeChange={(start, end) => {
|
||||
setDateRangeStart(start)
|
||||
setDateRangeEnd(end)
|
||||
}}
|
||||
placeholder='Select date range'
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='disabled'>
|
||||
<div className='w-56'>
|
||||
<DatePicker value='2025-01-15' disabled />
|
||||
<ChipDatePicker value='2025-01-15' disabled fullWidth />
|
||||
</div>
|
||||
</VariantRow>
|
||||
<VariantRow label='inline'>
|
||||
<DatePicker inline value={dateValue} onChange={setDateValue} />
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
{/* Tooltip */}
|
||||
|
||||
+6
-4
@@ -6,13 +6,13 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
ChipCombobox,
|
||||
ChipDatePicker,
|
||||
ChipInput,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
DatePicker,
|
||||
Label,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
@@ -411,7 +411,7 @@ export function DocumentTagsModal({
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[var(--border-1)]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-xs'>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-caption'>
|
||||
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||
@@ -524,10 +524,11 @@ export function DocumentTagsModal({
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
<ChipDatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<ChipInput
|
||||
@@ -676,10 +677,11 @@ export function DocumentTagsModal({
|
||||
}}
|
||||
/>
|
||||
) : editTagForm.fieldType === 'date' ? (
|
||||
<DatePicker
|
||||
<ChipDatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<ChipInput
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { AutocompleteSearch } from './search'
|
||||
-371
@@ -1,371 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Badge, Button } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
|
||||
import {
|
||||
type FolderData,
|
||||
SearchSuggestions,
|
||||
type TriggerData,
|
||||
type WorkflowData,
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
|
||||
function truncateFilterValue(field: string, value: string): string {
|
||||
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
|
||||
return `...${value.slice(-6)}`
|
||||
}
|
||||
if (value.length > 20) {
|
||||
return `${value.slice(0, 17)}...`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
interface AutocompleteSearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AutocompleteSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search',
|
||||
className,
|
||||
onOpenChange,
|
||||
}: AutocompleteSearchProps) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { data: workflowList = [] } = useWorkflows(workspaceId)
|
||||
const { data: folders = {} } = useFolderMap(workspaceId)
|
||||
|
||||
const workflowsData = useMemo<WorkflowData[]>(() => {
|
||||
return workflowList.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
description: w.description,
|
||||
}))
|
||||
}, [workflowList])
|
||||
|
||||
const foldersData = useMemo<FolderData[]>(() => {
|
||||
return Object.values(folders).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
}))
|
||||
}, [folders])
|
||||
|
||||
const triggersData = useMemo<TriggerData[]>(() => {
|
||||
return getTriggerOptions().map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
color: t.color,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const suggestionEngine = useMemo(() => {
|
||||
return new SearchSuggestions(workflowsData, foldersData, triggersData)
|
||||
}, [workflowsData, foldersData, triggersData])
|
||||
|
||||
const handleFiltersChange = (filters: ParsedFilter[], textSearch: string) => {
|
||||
const filterStrings = filters.map(
|
||||
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
|
||||
)
|
||||
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
|
||||
onChange(fullQuery)
|
||||
}
|
||||
|
||||
const {
|
||||
appliedFilters,
|
||||
currentInput,
|
||||
textSearch,
|
||||
isOpen,
|
||||
suggestions,
|
||||
sections,
|
||||
highlightedIndex,
|
||||
highlightedBadgeIndex,
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
handleInputChange,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
removeBadge,
|
||||
clearAll,
|
||||
setHighlightedIndex,
|
||||
initializeFromQuery,
|
||||
} = useSearchState({
|
||||
onFiltersChange: handleFiltersChange,
|
||||
getSuggestions: (input) => suggestionEngine.getSuggestions(input),
|
||||
initialQuery: value,
|
||||
})
|
||||
|
||||
const lastExternalValue = useRef(value)
|
||||
useEffect(() => {
|
||||
if (value !== lastExternalValue.current) {
|
||||
lastExternalValue.current = value
|
||||
const parsed = parseQuery(value)
|
||||
initializeFromQuery(parsed.textSearch, parsed.filters)
|
||||
}
|
||||
}, [value, initializeFromQuery])
|
||||
|
||||
const [dropdownWidth, setDropdownWidth] = useState(400)
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
if (inputRef.current) {
|
||||
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 400)
|
||||
}
|
||||
}
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
onOpenChange?.(isOpen)
|
||||
}, [isOpen, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || highlightedIndex < 0) return
|
||||
const container = dropdownRef.current
|
||||
const optionEl = container?.querySelector(`[data-index="${highlightedIndex}"]`)
|
||||
if (container && optionEl) {
|
||||
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}, [isOpen, highlightedIndex])
|
||||
|
||||
const hasFilters = appliedFilters.length > 0
|
||||
const hasTextSearch = textSearch.length > 0
|
||||
const suggestionType =
|
||||
sections.length > 0 ? 'multi-section' : suggestions.length > 0 ? suggestions[0]?.category : null
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Search Input with Inline Badges */}
|
||||
<DropdownMenuPrimitive.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<div className='relative flex h-[32px] w-[400px] items-center rounded-lg bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
|
||||
{/* Search Icon */}
|
||||
<Search className='mr-1.5 ml-2 size-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
|
||||
|
||||
{/* Scrollable container for badges */}
|
||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto pr-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{/* Applied Filter Badges */}
|
||||
{appliedFilters.map((filter, index) => (
|
||||
<Badge
|
||||
key={`${filter.field}-${filter.value}`}
|
||||
variant='outline'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-xs',
|
||||
highlightedBadgeIndex === index &&
|
||||
'ring-1 ring-[var(--text-subtle)] ring-offset-1 ring-offset-[var(--surface-3)] dark:ring-offset-[var(--surface-5)]'
|
||||
)}
|
||||
onClick={() => removeBadge(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
removeBadge(index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{filter.operator !== '=' && filter.operator}
|
||||
{truncateFilterValue(filter.field, filter.originalValue)}
|
||||
</span>
|
||||
<X className='size-3 shrink-0' />
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Text Search Badge (if present) */}
|
||||
{hasTextSearch && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-xs'
|
||||
onClick={() => handleFiltersChange(appliedFilters, '')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleFiltersChange(appliedFilters, '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='max-w-[150px] truncate text-[var(--text-primary)]'>
|
||||
"{textSearch}"
|
||||
</span>
|
||||
<X className='size-3 shrink-0' />
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Input - only current typing */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder={hasFilters || hasTextSearch ? '' : placeholder}
|
||||
value={currentInput}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className='min-w-[80px] flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none outline-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear All Button */}
|
||||
{(hasFilters || hasTextSearch) && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='mr-2 ml-1.5 size-[14px] flex-shrink-0 p-0 text-[var(--text-subtle)] hover-hover:text-[var(--text-secondary)]'
|
||||
onClick={clearAll}
|
||||
>
|
||||
<X className='size-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
|
||||
{/* Dropdown */}
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={dropdownRef}
|
||||
className='z-[70] rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
|
||||
style={{ width: dropdownWidth }}
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='max-h-96 overflow-y-auto px-1'>
|
||||
{sections.length > 0 ? (
|
||||
<div className='py-1'>
|
||||
{/* Show all results (no header) */}
|
||||
{suggestions[0]?.category === 'show-all' && (
|
||||
<Button
|
||||
key={suggestions[0].id}
|
||||
variant='ghost'
|
||||
data-index={0}
|
||||
className={cn(
|
||||
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
|
||||
'hover-hover:bg-[var(--surface-5)]',
|
||||
highlightedIndex === 0 && 'bg-[var(--surface-5)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestions[0])
|
||||
}}
|
||||
>
|
||||
<div className='text-small'>{suggestions[0].label}</div>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
|
||||
{section.title}
|
||||
</div>
|
||||
{section.suggestions.map((suggestion) => {
|
||||
if (suggestion.category === 'show-all') return null
|
||||
|
||||
const index = suggestions.indexOf(suggestion)
|
||||
const isHighlighted = index === highlightedIndex
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
variant='ghost'
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
|
||||
'hover-hover:bg-[var(--surface-5)]',
|
||||
isHighlighted && 'bg-[var(--surface-5)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 truncate text-small'>
|
||||
{suggestion.label}
|
||||
</div>
|
||||
{suggestion.value !== suggestion.label && (
|
||||
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
|
||||
{suggestion.category === 'workflow' ||
|
||||
suggestion.category === 'folder'
|
||||
? `${suggestion.category}:`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Single section layout
|
||||
<div className='py-1'>
|
||||
{suggestionType === 'filters' && (
|
||||
<div className='px-3 py-1.5 font-medium text-[var(--text-tertiary)] text-caption uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
variant='ghost'
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full justify-start rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-1 focus-visible:ring-[var(--text-subtle)]',
|
||||
'hover-hover:bg-[var(--surface-5)]',
|
||||
index === highlightedIndex && 'bg-[var(--surface-5)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSuggestionSelect(suggestion)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 text-small'>{suggestion.label}</div>
|
||||
{suggestion.description && (
|
||||
<div className='shrink-0 text-[var(--text-muted)] text-xs'>
|
||||
{suggestion.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AutocompleteSearch } from './components/search'
|
||||
export { LogsToolbar } from './logs-toolbar'
|
||||
@@ -1,756 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import {
|
||||
Button,
|
||||
ChipCombobox,
|
||||
type ComboboxOption,
|
||||
DatePicker,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Loader,
|
||||
} from '@/components/emcn'
|
||||
import { Workflow } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
|
||||
import {
|
||||
formatDateShort,
|
||||
type LogStatus,
|
||||
STATUS_CONFIG,
|
||||
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import { AutocompleteSearch } from './components/search'
|
||||
|
||||
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'All time', label: 'All time' },
|
||||
{ value: 'Past 30 minutes', label: 'Past 30 minutes' },
|
||||
{ value: 'Past hour', label: 'Past hour' },
|
||||
{ value: 'Past 6 hours', label: 'Past 6 hours' },
|
||||
{ value: 'Past 12 hours', label: 'Past 12 hours' },
|
||||
{ value: 'Past 24 hours', label: 'Past 24 hours' },
|
||||
{ value: 'Past 3 days', label: 'Past 3 days' },
|
||||
{ value: 'Past 7 days', label: 'Past 7 days' },
|
||||
{ value: 'Past 14 days', label: 'Past 14 days' },
|
||||
{ value: 'Past 30 days', label: 'Past 30 days' },
|
||||
{ value: 'Custom range', label: 'Custom range' },
|
||||
] as const
|
||||
|
||||
type ViewMode = 'logs' | 'dashboard'
|
||||
|
||||
interface LogsToolbarProps {
|
||||
/** Current view mode */
|
||||
viewMode: ViewMode
|
||||
/** Callback when view mode changes */
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
/** Whether the refresh spinner is visible */
|
||||
isRefreshing: boolean
|
||||
/** Callback when refresh button is clicked */
|
||||
onRefresh: () => void
|
||||
/** Whether live mode is enabled */
|
||||
isLive: boolean
|
||||
/** Callback when live toggle is clicked */
|
||||
onToggleLive: () => void
|
||||
/** Whether export is in progress */
|
||||
isExporting: boolean
|
||||
/** Callback when export is triggered */
|
||||
onExport: () => void
|
||||
/** Whether user can edit (for export permissions) */
|
||||
canEdit: boolean
|
||||
/** Whether there are logs to export */
|
||||
hasLogs: boolean
|
||||
/** Search query value */
|
||||
searchQuery: string
|
||||
/** Callback when search query changes */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
/** Callback when search open state changes */
|
||||
onSearchOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/** Cache for color icon components to ensure stable references across renders */
|
||||
const colorIconCache = new Map<string, React.ComponentType<{ className?: string }>>()
|
||||
|
||||
/**
|
||||
* Returns a memoized icon component for a given color.
|
||||
* Uses a cache to ensure the same color always returns the same component reference,
|
||||
* which prevents unnecessary React reconciliation.
|
||||
* @param color - CSS color value for the icon background
|
||||
* @returns A React component that renders a colored square icon
|
||||
*/
|
||||
function getColorIcon(color: string): React.ComponentType<{ className?: string }> {
|
||||
const cached = colorIconCache.get(color)
|
||||
if (cached) return cached
|
||||
|
||||
const ColorIcon = ({ className }: { className?: string }) => (
|
||||
<div
|
||||
className={cn(className, 'flex-shrink-0 rounded-[3px]')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
ColorIcon.displayName = `ColorIcon(${color})`
|
||||
colorIconCache.set(color, ColorIcon)
|
||||
return ColorIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the workflow skeleton icon used as the workflow filter indicator.
|
||||
* @param props - Optional className passthrough
|
||||
* @returns The workflow skeleton icon
|
||||
*/
|
||||
function WorkflowOptionIcon({ className }: { className?: string }) {
|
||||
return <Workflow className={cn(className, 'flex-shrink-0 text-[var(--text-icon)]')} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a memoized trigger icon component for integration blocks.
|
||||
* Core trigger types (manual, api, schedule, chat, webhook) return undefined.
|
||||
* @param triggerType - The trigger type identifier
|
||||
* @returns A React component that renders the trigger icon, or undefined for core types
|
||||
*/
|
||||
function getTriggerIcon(
|
||||
triggerType: string
|
||||
): React.ComponentType<{ className?: string }> | undefined {
|
||||
if ((CORE_TRIGGER_TYPES as readonly string[]).includes(triggerType)) return undefined
|
||||
|
||||
const block = getBlock(triggerType)
|
||||
if (!block?.icon) return undefined
|
||||
|
||||
const BlockIcon = block.icon
|
||||
const TriggerIcon = ({ className }: { className?: string }) => (
|
||||
<BlockIcon className={cn(className, 'flex-shrink-0')} style={{ width: 12, height: 12 }} />
|
||||
)
|
||||
TriggerIcon.displayName = `TriggerIcon(${triggerType})`
|
||||
return TriggerIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated logs toolbar component that combines header, search, and filters.
|
||||
* Contains title, icon, view mode toggle, refresh/live controls, search bar, and filter controls.
|
||||
* @param props - The component props
|
||||
* @returns The complete logs toolbar
|
||||
*/
|
||||
export const LogsToolbar = memo(function LogsToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
isLive,
|
||||
onToggleLive,
|
||||
isExporting,
|
||||
onExport,
|
||||
canEdit,
|
||||
hasLogs,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onSearchOpenChange,
|
||||
}: LogsToolbarProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
const {
|
||||
level,
|
||||
setLevel,
|
||||
workflowIds,
|
||||
setWorkflowIds,
|
||||
folderIds,
|
||||
setFolderIds,
|
||||
triggers,
|
||||
setTriggers,
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
resetFilters,
|
||||
} = useLogFilters()
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
const dateRangeAppliedRef = useRef(false)
|
||||
const { data: folders = {} } = useFolderMap(workspaceId)
|
||||
|
||||
const { data: allWorkflowList = [] } = useWorkflows(workspaceId)
|
||||
|
||||
const workflows = useMemo(() => {
|
||||
return allWorkflowList.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
}))
|
||||
}, [allWorkflowList])
|
||||
|
||||
const folderList = useMemo(() => {
|
||||
return Object.values(folders).filter((f) => f.workspaceId === workspaceId)
|
||||
}, [folders, workspaceId])
|
||||
|
||||
const isDashboardView = viewMode === 'dashboard'
|
||||
|
||||
const selectedStatuses = useMemo((): string[] => {
|
||||
if (level === 'all' || !level) return []
|
||||
return level.split(',').filter(Boolean)
|
||||
}, [level])
|
||||
|
||||
const statusOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
(Object.keys(STATUS_CONFIG) as LogStatus[])
|
||||
.filter((status) => STATUS_CONFIG[status].filterable)
|
||||
.map((status) => ({
|
||||
value: status,
|
||||
label: STATUS_CONFIG[status].label,
|
||||
icon: getColorIcon(STATUS_CONFIG[status].color),
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(values: string[]) => {
|
||||
if (values.length === 0) {
|
||||
setLevel('all')
|
||||
} else {
|
||||
setLevel(values.join(','))
|
||||
}
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'status',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setLevel, workspaceId]
|
||||
)
|
||||
|
||||
const handleWorkflowFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setWorkflowIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'workflow',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setWorkflowIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleFolderFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setFolderIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'folder',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setFolderIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleTriggerFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setTriggers(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'trigger',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setTriggers, workspaceId]
|
||||
)
|
||||
|
||||
const statusDisplayLabel =
|
||||
selectedStatuses.length === 0
|
||||
? 'Status'
|
||||
: selectedStatuses.length === 1
|
||||
? (statusOptions.find((s) => s.value === selectedStatuses[0])?.label ?? '1 selected')
|
||||
: `${selectedStatuses.length} selected`
|
||||
|
||||
const selectedStatusColor =
|
||||
selectedStatuses.length === 1
|
||||
? (STATUS_CONFIG[selectedStatuses[0] as LogStatus]?.color ?? null)
|
||||
: null
|
||||
|
||||
const workflowOptions: ComboboxOption[] = useMemo(
|
||||
() => workflows.map((w) => ({ value: w.id, label: w.name, icon: WorkflowOptionIcon })),
|
||||
[workflows]
|
||||
)
|
||||
|
||||
const workflowDisplayLabel =
|
||||
workflowIds.length === 0
|
||||
? 'Workflow'
|
||||
: workflowIds.length === 1
|
||||
? (workflows.find((w) => w.id === workflowIds[0])?.name ?? '1 selected')
|
||||
: `${workflowIds.length} workflows`
|
||||
|
||||
const selectedWorkflow =
|
||||
workflowIds.length === 1 ? workflows.find((w) => w.id === workflowIds[0]) : null
|
||||
|
||||
const folderOptions: ComboboxOption[] = useMemo(
|
||||
() => folderList.map((f) => ({ value: f.id, label: f.name })),
|
||||
[folderList]
|
||||
)
|
||||
|
||||
const folderDisplayLabel =
|
||||
folderIds.length === 0
|
||||
? 'Folder'
|
||||
: folderIds.length === 1
|
||||
? (folderList.find((f) => f.id === folderIds[0])?.name ?? '1 selected')
|
||||
: `${folderIds.length} folders`
|
||||
|
||||
const triggerOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
getTriggerOptions().map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
icon: getTriggerIcon(t.value),
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
const triggerDisplayLabel =
|
||||
triggers.length === 0
|
||||
? 'Trigger'
|
||||
: triggers.length === 1
|
||||
? (triggerOptions.find((t) => t.value === triggers[0])?.label ?? '1 selected')
|
||||
: `${triggers.length} triggers`
|
||||
|
||||
const timeDisplayLabel =
|
||||
timeRange === 'All time'
|
||||
? 'Time'
|
||||
: timeRange === 'Custom range' && startDate && endDate
|
||||
? `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`
|
||||
: timeRange === 'Custom range'
|
||||
? 'Custom range'
|
||||
: timeRange
|
||||
|
||||
/**
|
||||
* 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)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[timeRange, setTimeRange, clearDateRange, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles date range selection from DatePicker.
|
||||
*/
|
||||
function handleDateRangeApply(start: string, end: string) {
|
||||
dateRangeAppliedRef.current = true
|
||||
setDateRange(start, end)
|
||||
setDatePickerOpen(false)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles date picker cancel.
|
||||
*/
|
||||
function handleDatePickerCancel() {
|
||||
if (timeRange === 'Custom range' && !startDate) {
|
||||
setTimeRange(previousTimeRange)
|
||||
}
|
||||
setDatePickerOpen(false)
|
||||
}
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() =>
|
||||
hasActiveFilters({
|
||||
timeRange,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
searchQuery,
|
||||
}),
|
||||
[timeRange, level, workflowIds, folderIds, triggers, searchQuery]
|
||||
)
|
||||
|
||||
function handleClearFilters() {
|
||||
resetFilters()
|
||||
onSearchQueryChange('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[19px]'>
|
||||
{/* Header Section */}
|
||||
<div className='flex items-start justify-between'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex size-[26px] items-center justify-center rounded-md border border-[#D4A843] bg-[#FDF6E3] dark:border-[#7A5F11] dark:bg-[#514215]'>
|
||||
<Library className='size-[14px] text-[#D4A843] dark:text-[#FBBC04]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-lg'>Logs</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* More options menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='default' className='size-[32px] rounded-md p-0'>
|
||||
<MoreHorizontal className='size-[14px]' />
|
||||
<span className='sr-only'>More options</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' sideOffset={4}>
|
||||
<DropdownMenuItem onSelect={onExport} disabled={!canEdit || isExporting || !hasLogs}>
|
||||
<ArrowUp className='size-3' />
|
||||
<span>Export as CSV</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Refresh button */}
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[32px] rounded-md px-2.5'
|
||||
onClick={isRefreshing ? undefined : onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader className='size-[14px]' animate />
|
||||
) : (
|
||||
<RefreshCw className='size-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Live button */}
|
||||
<Button
|
||||
variant={isLive ? 'tertiary' : 'default'}
|
||||
onClick={onToggleLive}
|
||||
className={cn(
|
||||
'h-[32px] rounded-md px-2.5',
|
||||
isLive && 'border border-[var(--brand-accent)]'
|
||||
)}
|
||||
>
|
||||
Live
|
||||
</Button>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className='flex h-[32px] items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-0.5'>
|
||||
<Button
|
||||
variant={!isDashboardView ? 'active' : 'ghost'}
|
||||
className={cn(
|
||||
'h-[26px] rounded-sm px-2.5',
|
||||
isDashboardView && 'border border-transparent'
|
||||
)}
|
||||
onClick={() => onViewModeChange('logs')}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDashboardView ? 'active' : 'ghost'}
|
||||
className={cn(
|
||||
'h-[26px] rounded-sm px-2.5',
|
||||
!isDashboardView && 'border border-transparent'
|
||||
)}
|
||||
onClick={() => onViewModeChange('dashboard')}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar Section */}
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<div className='min-w-[200px] max-w-[400px] flex-1'>
|
||||
<AutocompleteSearch
|
||||
value={searchQuery}
|
||||
onChange={onSearchQueryChange}
|
||||
placeholder='Search'
|
||||
onOpenChange={onSearchOpenChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='ml-auto flex items-center gap-2'>
|
||||
{/* Clear Filters Button */}
|
||||
{filtersActive && (
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleClearFilters}
|
||||
className='h-[32px] rounded-md px-2.5'
|
||||
>
|
||||
<span>Clear</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Filters dropdown - Small screens only */}
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='active' className='h-[32px] gap-1.5 rounded-md px-2.5 xl:hidden'>
|
||||
<span>Filters</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' sideOffset={4} className='w-[280px] p-3'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{/* Status Filter */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Status
|
||||
</span>
|
||||
<ChipCombobox
|
||||
options={statusOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedStatuses}
|
||||
onMultiSelectChange={handleStatusChange}
|
||||
placeholder='All statuses'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
{selectedStatusColor && (
|
||||
<div
|
||||
className='flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{statusDisplayLabel}</span>
|
||||
</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All statuses'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workflow Filter */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Workflow
|
||||
</span>
|
||||
<ChipCombobox
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='All workflows'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
{selectedWorkflow && (
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
)}
|
||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
showAllOption
|
||||
allOptionLabel='All workflows'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Folder Filter */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Folder
|
||||
</span>
|
||||
<ChipCombobox
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='All folders'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{folderDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search folders...'
|
||||
showAllOption
|
||||
allOptionLabel='All folders'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trigger Filter */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Trigger
|
||||
</span>
|
||||
<ChipCombobox
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='All triggers'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{triggerDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search triggers...'
|
||||
showAllOption
|
||||
allOptionLabel='All triggers'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Filter */}
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<span className='font-medium text-[var(--text-secondary)] text-caption'>
|
||||
Time Range
|
||||
</span>
|
||||
<ChipCombobox
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='All time'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{timeDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline Filters - Large screens only */}
|
||||
<div className='hidden items-center gap-2 xl:flex'>
|
||||
{/* Status Filter */}
|
||||
<ChipCombobox
|
||||
options={statusOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedStatuses}
|
||||
onMultiSelectChange={handleStatusChange}
|
||||
placeholder='Status'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
{selectedStatusColor && (
|
||||
<div
|
||||
className='flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{statusDisplayLabel}</span>
|
||||
</span>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All statuses'
|
||||
align='end'
|
||||
className='w-[120px]'
|
||||
/>
|
||||
|
||||
{/* Workflow Filter */}
|
||||
<ChipCombobox
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='Workflow'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
{selectedWorkflow && (
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
)}
|
||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
showAllOption
|
||||
allOptionLabel='All workflows'
|
||||
align='end'
|
||||
className='w-[120px]'
|
||||
/>
|
||||
|
||||
{/* Folder Filter */}
|
||||
<ChipCombobox
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='Folder'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search folders...'
|
||||
showAllOption
|
||||
allOptionLabel='All folders'
|
||||
align='end'
|
||||
className='w-[120px]'
|
||||
/>
|
||||
|
||||
{/* Trigger Filter */}
|
||||
<ChipCombobox
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='Trigger'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search triggers...'
|
||||
showAllOption
|
||||
allOptionLabel='All triggers'
|
||||
align='end'
|
||||
className='w-[120px]'
|
||||
/>
|
||||
|
||||
{/* Timeline Filter */}
|
||||
<div className='relative'>
|
||||
<ChipCombobox
|
||||
options={TIME_RANGE_OPTIONS}
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='Time'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
||||
}
|
||||
align='end'
|
||||
className='w-[160px]'
|
||||
maxHeight={320}
|
||||
/>
|
||||
<DatePicker
|
||||
mode='range'
|
||||
showTrigger={false}
|
||||
showTime
|
||||
open={datePickerOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (dateRangeAppliedRef.current) {
|
||||
dateRangeAppliedRef.current = false
|
||||
} else {
|
||||
handleDatePickerCancel()
|
||||
}
|
||||
}
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
Button,
|
||||
ChipCombobox,
|
||||
type ComboboxOption,
|
||||
DatePicker,
|
||||
Library,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
RefreshCw,
|
||||
toast,
|
||||
} from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/components/calendar/calendar'
|
||||
import { Download, Workflow } from '@/components/emcn/icons'
|
||||
import type {
|
||||
WorkflowLogDetail,
|
||||
@@ -1425,10 +1428,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
className='w-full'
|
||||
maxHeight={320}
|
||||
/>
|
||||
<DatePicker
|
||||
mode='range'
|
||||
showTrigger={false}
|
||||
showTime
|
||||
<Popover
|
||||
open={datePickerOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
@@ -1439,11 +1439,19 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
}
|
||||
}
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
/>
|
||||
>
|
||||
<PopoverAnchor className='pointer-events-none absolute inset-0' />
|
||||
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='range'
|
||||
showTime
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+4
-4
@@ -7,13 +7,13 @@ import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Checkbox,
|
||||
ChipConfirmModal,
|
||||
ChipDatePicker,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
DatePicker,
|
||||
Label,
|
||||
} from '@/components/emcn'
|
||||
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
|
||||
@@ -238,11 +238,11 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
|
||||
if (column.type === 'date') {
|
||||
return (
|
||||
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
|
||||
<DatePicker
|
||||
mode='single'
|
||||
<ChipDatePicker
|
||||
value={formatValueForInput(value, column.type) || undefined}
|
||||
onChange={(dateStr) => onChange(dateStr)}
|
||||
onChange={onChange}
|
||||
placeholder='Select date'
|
||||
fullWidth
|
||||
/>
|
||||
</ChipModalField>
|
||||
)
|
||||
|
||||
+9
-13
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { DatePicker } from '@/components/emcn'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/components/calendar/calendar'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import type { SaveReason } from '../../../types'
|
||||
@@ -20,7 +21,7 @@ interface InlineEditorProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** Inline editor for `date` columns — text input + popover DatePicker. */
|
||||
/** Inline editor for `date` columns — text input + popover calendar. */
|
||||
function InlineDateEditor({
|
||||
value,
|
||||
column,
|
||||
@@ -116,17 +117,12 @@ function InlineDateEditor({
|
||||
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none'
|
||||
)}
|
||||
/>
|
||||
<div className='absolute top-full left-0 size-0'>
|
||||
<DatePicker
|
||||
mode='single'
|
||||
value={pickerValue}
|
||||
onChange={handlePickerChange}
|
||||
open={true}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
showTrigger={false}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
<Popover open onOpenChange={handlePickerOpenChange}>
|
||||
<PopoverAnchor className='absolute top-full left-0 size-0' />
|
||||
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
|
||||
<Calendar value={pickerValue} onChange={handlePickerChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildRangeBounds,
|
||||
formatDateRangeLabel,
|
||||
parseDateValue,
|
||||
} from '@/components/emcn/components/calendar/calendar'
|
||||
|
||||
describe('parseDateValue', () => {
|
||||
it('parses a YYYY-MM-DD string as a local day', () => {
|
||||
const date = parseDateValue('2026-04-21')
|
||||
expect(date?.getFullYear()).toBe(2026)
|
||||
expect(date?.getMonth()).toBe(3)
|
||||
expect(date?.getDate()).toBe(21)
|
||||
})
|
||||
|
||||
it('parses the day from a YYYY-MM-DDTHH:mm string', () => {
|
||||
const date = parseDateValue('2026-04-21T09:30')
|
||||
expect(date?.getMonth()).toBe(3)
|
||||
expect(date?.getDate()).toBe(21)
|
||||
})
|
||||
|
||||
it('returns null for empty or invalid input', () => {
|
||||
expect(parseDateValue(undefined)).toBeNull()
|
||||
expect(parseDateValue('not-a-date')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRangeBounds', () => {
|
||||
it('serializes bare days when time is off', () => {
|
||||
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 30), {
|
||||
showTime: false,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
})
|
||||
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
|
||||
})
|
||||
|
||||
it('orders inverted bounds', () => {
|
||||
const bounds = buildRangeBounds(new Date(2026, 3, 30), new Date(2026, 3, 1), {
|
||||
showTime: false,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
})
|
||||
expect(bounds).toEqual({ start: '2026-04-01', end: '2026-04-30' })
|
||||
})
|
||||
|
||||
it('appends start time and closes the end at :59 when time is on', () => {
|
||||
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 2), {
|
||||
showTime: true,
|
||||
startTime: '09:00',
|
||||
endTime: '17:30',
|
||||
})
|
||||
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-02T17:30:59' })
|
||||
})
|
||||
|
||||
it('swaps inverted times on a single day', () => {
|
||||
const bounds = buildRangeBounds(new Date(2026, 3, 1), new Date(2026, 3, 1), {
|
||||
showTime: true,
|
||||
startTime: '18:00',
|
||||
endTime: '09:00',
|
||||
})
|
||||
expect(bounds).toEqual({ start: '2026-04-01T09:00', end: '2026-04-01T18:00:59' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateRangeLabel', () => {
|
||||
it('renders a same-year range compactly', () => {
|
||||
expect(formatDateRangeLabel('2026-04-08', '2026-04-12')).toBe('Apr 8 - Apr 12, 2026')
|
||||
})
|
||||
|
||||
it('shows both years when they differ', () => {
|
||||
expect(formatDateRangeLabel('2025-12-30', '2026-01-02')).toBe('Dec 30, 2025 - Jan 2, 2026')
|
||||
})
|
||||
|
||||
it('falls back to a single label when only one bound is set', () => {
|
||||
expect(formatDateRangeLabel('2026-04-08', undefined)).toBe('Apr 8, 2026')
|
||||
expect(formatDateRangeLabel(undefined, undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { CalendarDayCell } from '@/components/emcn/components/calendar/calendar-day-cell'
|
||||
import { Chip, chipVariants } from '@/components/emcn/components/chip/chip'
|
||||
import { chipContentLabelClass } from '@/components/emcn/components/chip/chip-chrome'
|
||||
import { ChipTimePicker } from '@/components/emcn/components/chip-time-picker/chip-time-picker'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const MONTHS = [
|
||||
@@ -24,6 +25,9 @@ const MONTHS = [
|
||||
|
||||
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] as const
|
||||
|
||||
const DEFAULT_RANGE_START_TIME = '00:00'
|
||||
const DEFAULT_RANGE_END_TIME = '23:59'
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
@@ -52,8 +56,8 @@ function toDateString(year: number, month: number, day: number): string {
|
||||
export function parseDateValue(value: string | Date | undefined): Date | null {
|
||||
if (!value) return null
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
const parsed = new Date(value)
|
||||
@@ -70,6 +74,35 @@ export function formatDateLabel(value: string | Date | undefined): string {
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact label for a date range (e.g. `May 8 - May 12, 2026`). Mirrors
|
||||
* {@link formatDateLabel} for the empty/partial cases so triggers can fall back
|
||||
* to a placeholder.
|
||||
*/
|
||||
export function formatDateRangeLabel(
|
||||
start: string | Date | undefined,
|
||||
end: string | Date | undefined
|
||||
): string {
|
||||
const startDate = parseDateValue(start)
|
||||
const endDate = parseDateValue(end)
|
||||
if (!startDate && !endDate) return ''
|
||||
if (startDate && !endDate) return formatDateLabel(startDate)
|
||||
if (!startDate && endDate) return formatDateLabel(endDate)
|
||||
if (!startDate || !endDate) return ''
|
||||
const sameYear = startDate.getFullYear() === endDate.getFullYear()
|
||||
const startLabel = startDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
})
|
||||
const endLabel = endDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
return `${startLabel} - ${endLabel}`
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
@@ -78,49 +111,121 @@ function isSameDay(a: Date, b: Date): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export interface CalendarProps {
|
||||
/** Selected date as a `YYYY-MM-DD` string or `Date`. */
|
||||
value?: string | Date
|
||||
/** Called with the picked date in `YYYY-MM-DD` format. */
|
||||
onChange?: (value: string) => void
|
||||
function isStrictlyWithin(date: Date, start: Date, end: Date): boolean {
|
||||
const time = date.getTime()
|
||||
const lo = Math.min(start.getTime(), end.getTime())
|
||||
const hi = Math.max(start.getTime(), end.getTime())
|
||||
return time > lo && time < hi
|
||||
}
|
||||
|
||||
/** Reads the `HH:mm` slice from a `YYYY-MM-DDTHH:mm` value, or a fallback. */
|
||||
function extractTime(value: string | Date | undefined, fallback: string): string {
|
||||
return typeof value === 'string' && value.includes('T') ? value.slice(11, 16) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders a start/end pair and serializes the range bounds to the wire format.
|
||||
* Without `showTime` the bounds are bare `YYYY-MM-DD` days; with it, the start
|
||||
* gains its `HH:mm` and the end is closed at `:59` seconds. On a single day the
|
||||
* times are swapped when inverted so the range never ends before it starts.
|
||||
*/
|
||||
export function buildRangeBounds(
|
||||
start: Date,
|
||||
end: Date,
|
||||
options: { showTime: boolean; startTime: string; endTime: string }
|
||||
): { start: string; end: string } {
|
||||
const ordered = end < start ? { start: end, end: start } : { start, end }
|
||||
const startStr = toDateString(
|
||||
ordered.start.getFullYear(),
|
||||
ordered.start.getMonth(),
|
||||
ordered.start.getDate()
|
||||
)
|
||||
const endStr = toDateString(
|
||||
ordered.end.getFullYear(),
|
||||
ordered.end.getMonth(),
|
||||
ordered.end.getDate()
|
||||
)
|
||||
|
||||
if (!options.showTime) return { start: startStr, end: endStr }
|
||||
|
||||
let startTime = options.startTime
|
||||
let endTime = options.endTime
|
||||
if (startStr === endStr && startTime > endTime) {
|
||||
startTime = options.endTime
|
||||
endTime = options.startTime
|
||||
}
|
||||
return { start: `${startStr}T${startTime}`, end: `${endStr}T${endTime}:59` }
|
||||
}
|
||||
|
||||
interface CalendarBaseProps {
|
||||
/** Forwarded to the root grid container. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface CalendarSingleProps extends CalendarBaseProps {
|
||||
mode?: 'single'
|
||||
/** Selected date as a `YYYY-MM-DD` string or `Date`. */
|
||||
value?: string | Date
|
||||
/** Called with the picked date in `YYYY-MM-DD` format. */
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
interface CalendarRangeProps extends CalendarBaseProps {
|
||||
mode: 'range'
|
||||
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
|
||||
startDate?: string | Date
|
||||
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string or `Date`. */
|
||||
endDate?: string | Date
|
||||
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
|
||||
showTime?: boolean
|
||||
/** Called on Apply with the ordered range bounds. */
|
||||
onRangeChange: (start: string, end: string) => void
|
||||
/** Called when the Cancel action is pressed. */
|
||||
onCancel?: () => void
|
||||
/** Called when the Clear action is pressed. */
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export type CalendarProps = CalendarSingleProps | CalendarRangeProps
|
||||
|
||||
/**
|
||||
* Single-month date grid composed from the chip family — icon-only `Chip`
|
||||
* chevrons for month navigation, day cells built on `chipVariants` (`primary`
|
||||
* fill when selected, the `border` shadow-ring on today), and a `filled`-chip
|
||||
* Today action. Pair it with {@link ChipDatePicker} for a chip trigger, or
|
||||
* embed it directly.
|
||||
* Date grid composed from the chip family — icon-only `Chip` chevrons for month
|
||||
* navigation and day cells built on `chipVariants` (`primary` fill when
|
||||
* selected, the `border` shadow-ring on today). Pair it with
|
||||
* {@link ChipDatePicker} for a chip trigger, or embed it directly.
|
||||
*
|
||||
* `mode='single'` (default) commits on day click via `onChange`. `mode='range'`
|
||||
* stages a start/end selection behind Clear/Cancel/Apply actions (with optional
|
||||
* time-of-day inputs) and commits via `onRangeChange`. The range view is keyed
|
||||
* on its committed bounds so a newly applied range remounts it with fresh draft
|
||||
* state (React's "reset all state with a key") — the staged start/end never
|
||||
* lingers when the source range changes.
|
||||
*
|
||||
* @example
|
||||
* <Calendar value={value} onChange={setValue} />
|
||||
*
|
||||
* @example
|
||||
* <Calendar mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
|
||||
*/
|
||||
export function Calendar({ value, onChange, className }: CalendarProps) {
|
||||
const selected = useMemo(() => parseDateValue(value), [value])
|
||||
const today = useMemo(() => new Date(), [])
|
||||
export function Calendar(props: CalendarProps) {
|
||||
if (props.mode === 'range') {
|
||||
return (
|
||||
<RangeCalendarView
|
||||
key={`${String(props.startDate ?? '')}|${String(props.endDate ?? '')}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <SingleCalendarView {...props} />
|
||||
}
|
||||
|
||||
function useCalendarView(seed: Date | null) {
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [view, setView] = useState(() => {
|
||||
const base = selected ?? today
|
||||
const base = seed ?? today
|
||||
return { month: base.getMonth(), year: base.getFullYear() }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
setView({ month: selected.getMonth(), year: selected.getFullYear() })
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const cells = useMemo<(number | null)[]>(() => {
|
||||
const leading = getFirstDayOfMonth(view.year, view.month)
|
||||
const total = getDaysInMonth(view.year, view.month)
|
||||
const result: (number | null)[] = []
|
||||
for (let i = 0; i < leading; i++) result.push(null)
|
||||
for (let day = 1; day <= total; day++) result.push(day)
|
||||
return result
|
||||
}, [view])
|
||||
|
||||
const goToPrevMonth = () =>
|
||||
setView((prev) =>
|
||||
prev.month === 0
|
||||
@@ -135,6 +240,63 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
|
||||
: { month: prev.month + 1, year: prev.year }
|
||||
)
|
||||
|
||||
const cells = useMemo<(number | null)[]>(() => {
|
||||
const leading = getFirstDayOfMonth(view.year, view.month)
|
||||
const total = getDaysInMonth(view.year, view.month)
|
||||
const result: (number | null)[] = []
|
||||
for (let i = 0; i < leading; i++) result.push(null)
|
||||
for (let day = 1; day <= total; day++) result.push(day)
|
||||
return result
|
||||
}, [view])
|
||||
|
||||
return { today, view, setView, goToPrevMonth, goToNextMonth, cells }
|
||||
}
|
||||
|
||||
function CalendarHeader({
|
||||
month,
|
||||
year,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
month: number
|
||||
year: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center justify-between pb-1'>
|
||||
<Chip leftIcon={ChevronLeft} aria-label='Previous month' onClick={onPrev} />
|
||||
<span className={chipContentLabelClass}>
|
||||
{MONTHS[month]} {year}
|
||||
</span>
|
||||
<Chip rightIcon={ChevronRight} aria-label='Next month' onClick={onNext} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekdayRow() {
|
||||
return (
|
||||
<div className='grid grid-cols-7'>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-caption'
|
||||
>
|
||||
{weekday}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleCalendarView({ value, onChange, className }: CalendarSingleProps) {
|
||||
const selected = useMemo(() => parseDateValue(value), [value])
|
||||
const { today, view, setView, goToPrevMonth, goToNextMonth, cells } = useCalendarView(selected)
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) setView({ month: selected.getMonth(), year: selected.getFullYear() })
|
||||
}, [selected, setView])
|
||||
|
||||
const selectDay = (day: number) => onChange?.(toDateString(view.year, view.month, day))
|
||||
|
||||
const goToToday = () => {
|
||||
@@ -144,24 +306,13 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-[256px] flex-col p-2', className)}>
|
||||
<div className='flex items-center justify-between pb-1'>
|
||||
<Chip leftIcon={ChevronLeft} aria-label='Previous month' onClick={goToPrevMonth} />
|
||||
<span className={chipContentLabelClass}>
|
||||
{MONTHS[view.month]} {view.year}
|
||||
</span>
|
||||
<Chip rightIcon={ChevronRight} aria-label='Next month' onClick={goToNextMonth} />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-7'>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-caption'
|
||||
>
|
||||
{weekday}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<CalendarHeader
|
||||
month={view.month}
|
||||
year={view.year}
|
||||
onPrev={goToPrevMonth}
|
||||
onNext={goToNextMonth}
|
||||
/>
|
||||
<WeekdayRow />
|
||||
|
||||
<div className='grid grid-cols-7'>
|
||||
{cells.map((day, index) => {
|
||||
@@ -194,3 +345,125 @@ export function Calendar({ value, onChange, className }: CalendarProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeCalendarView({
|
||||
startDate,
|
||||
endDate,
|
||||
showTime = false,
|
||||
onRangeChange,
|
||||
onCancel,
|
||||
onClear,
|
||||
className,
|
||||
}: CalendarRangeProps) {
|
||||
const seededStart = useMemo(() => parseDateValue(startDate), [startDate])
|
||||
const { today, view, goToPrevMonth, goToNextMonth, cells } = useCalendarView(seededStart)
|
||||
|
||||
const [rangeStart, setRangeStart] = useState<Date | null>(seededStart)
|
||||
const [rangeEnd, setRangeEnd] = useState<Date | null>(() => parseDateValue(endDate))
|
||||
const [selectingEnd, setSelectingEnd] = useState(false)
|
||||
const [startTime, setStartTime] = useState(() => extractTime(startDate, DEFAULT_RANGE_START_TIME))
|
||||
const [endTime, setEndTime] = useState(() => extractTime(endDate, DEFAULT_RANGE_END_TIME))
|
||||
|
||||
const pickDay = (day: number) => {
|
||||
const date = new Date(view.year, view.month, day)
|
||||
if (!selectingEnd || !rangeStart) {
|
||||
setRangeStart(date)
|
||||
setRangeEnd(null)
|
||||
setSelectingEnd(true)
|
||||
return
|
||||
}
|
||||
if (date < rangeStart) {
|
||||
setRangeEnd(rangeStart)
|
||||
setRangeStart(date)
|
||||
} else {
|
||||
setRangeEnd(date)
|
||||
}
|
||||
setSelectingEnd(false)
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setRangeStart(null)
|
||||
setRangeEnd(null)
|
||||
setSelectingEnd(false)
|
||||
onClear?.()
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
if (!rangeStart) return
|
||||
const bounds = buildRangeBounds(rangeStart, rangeEnd ?? rangeStart, {
|
||||
showTime,
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
onRangeChange(bounds.start, bounds.end)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-[256px] flex-col p-2', className)}>
|
||||
<CalendarHeader
|
||||
month={view.month}
|
||||
year={view.year}
|
||||
onPrev={goToPrevMonth}
|
||||
onNext={goToNextMonth}
|
||||
/>
|
||||
<WeekdayRow />
|
||||
|
||||
<div className='grid grid-cols-7'>
|
||||
{cells.map((day, index) => {
|
||||
if (day === null) return <div key={`empty-${index}`} className='h-[34px]' />
|
||||
|
||||
const cellDate = new Date(view.year, view.month, day)
|
||||
const isStart = rangeStart ? isSameDay(cellDate, rangeStart) : false
|
||||
const isEnd = rangeEnd ? isSameDay(cellDate, rangeEnd) : false
|
||||
const within =
|
||||
rangeStart && rangeEnd ? isStrictlyWithin(cellDate, rangeStart, rangeEnd) : false
|
||||
const hasBand = (within || isStart || isEnd) && Boolean(rangeStart && rangeEnd)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={cn(
|
||||
'relative flex h-[34px] items-center justify-center',
|
||||
hasBand &&
|
||||
'before:absolute before:inset-y-[2px] before:right-0 before:left-0 before:bg-[var(--surface-active)]',
|
||||
hasBand && isStart && 'before:left-[3px] before:rounded-l-[8px]',
|
||||
hasBand && isEnd && 'before:right-[3px] before:rounded-r-[8px]'
|
||||
)}
|
||||
>
|
||||
<CalendarDayCell
|
||||
selected={isStart || isEnd}
|
||||
today={isSameDay(cellDate, today)}
|
||||
className='relative z-[1]'
|
||||
onClick={() => pickDay(day)}
|
||||
>
|
||||
{day}
|
||||
</CalendarDayCell>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showTime && (
|
||||
<div className='mt-1 flex items-center gap-2'>
|
||||
<ChipTimePicker value={startTime} onChange={setStartTime} fullWidth flush />
|
||||
<span className='shrink-0 text-[var(--text-muted)] text-caption'>to</span>
|
||||
<ChipTimePicker value={endTime} onChange={setEndTime} fullWidth flush />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex items-center justify-between gap-2'>
|
||||
<Chip onClick={clear} disabled={!rangeStart && !rangeEnd}>
|
||||
Clear
|
||||
</Chip>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip variant='border' onClick={() => onCancel?.()}>
|
||||
Cancel
|
||||
</Chip>
|
||||
<Chip variant='primary' onClick={apply} disabled={!rangeStart}>
|
||||
Apply
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { forwardRef, useState } from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { Calendar, formatDateLabel } from '@/components/emcn/components/calendar/calendar'
|
||||
import {
|
||||
Calendar,
|
||||
formatDateLabel,
|
||||
formatDateRangeLabel,
|
||||
} from '@/components/emcn/components/calendar/calendar'
|
||||
import { chipVariants, TRIGGER_BORDER_CLASS } from '@/components/emcn/components/chip/chip'
|
||||
import { chipContentLabelClass } from '@/components/emcn/components/chip/chip-chrome'
|
||||
import { POPOVER_ANIMATION_CLASSES } from '@/components/emcn/components/popover/popover-animation'
|
||||
import { ChevronDown } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
export interface ChipDatePickerProps {
|
||||
/** Selected date as a `YYYY-MM-DD` string. */
|
||||
value?: string
|
||||
/** Called with the picked date in `YYYY-MM-DD` format. */
|
||||
onChange?: (value: string) => void
|
||||
interface ChipDatePickerBaseProps {
|
||||
/**
|
||||
* Trigger chrome. `filled` (default) is the bordered field chip with the owned
|
||||
* chevron; `ghost` is the bare toolbar pill — no border, no chevron — for
|
||||
@@ -21,11 +21,11 @@ export interface ChipDatePickerProps {
|
||||
*/
|
||||
variant?: 'filled' | 'ghost'
|
||||
/**
|
||||
* Overrides the formatted date as the trigger text, e.g. a calendar period
|
||||
* label ("June 2026") whose granularity differs from the selected day.
|
||||
* Overrides the trigger text, e.g. a calendar period label ("June 2026") whose
|
||||
* granularity differs from the selected value.
|
||||
*/
|
||||
label?: string
|
||||
/** Shown in the trigger when no date is selected. */
|
||||
/** Shown in the trigger when nothing is selected. */
|
||||
placeholder?: string
|
||||
/** Aligns the calendar popover relative to the trigger. */
|
||||
align?: 'start' | 'center' | 'end'
|
||||
@@ -39,92 +39,135 @@ export interface ChipDatePickerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ChipDatePickerSingleProps extends ChipDatePickerBaseProps {
|
||||
mode?: 'single'
|
||||
/** Selected date as a `YYYY-MM-DD` string. */
|
||||
value?: string
|
||||
/** Called with the picked date in `YYYY-MM-DD` format. */
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
interface ChipDatePickerRangeProps extends ChipDatePickerBaseProps {
|
||||
mode: 'range'
|
||||
/** Range start as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
|
||||
startDate?: string
|
||||
/** Range end as a `YYYY-MM-DD` (or `YYYY-MM-DDTHH:mm`) string. */
|
||||
endDate?: string
|
||||
/** Adds start/end time-of-day inputs; emits `YYYY-MM-DDTHH:mm` bounds. */
|
||||
showTime?: boolean
|
||||
/** Called on Apply with the ordered range bounds. */
|
||||
onRangeChange: (start: string, end: string) => void
|
||||
}
|
||||
|
||||
export type ChipDatePickerProps = ChipDatePickerSingleProps | ChipDatePickerRangeProps
|
||||
|
||||
/**
|
||||
* Date counterpart to {@link ChipDropdown} — a chip-styled trigger that opens a
|
||||
* {@link Calendar} in a popover. The default `filled` trigger reuses
|
||||
* `chipVariants` (filled + border) and the owned chevron for visual parity with
|
||||
* the other chip field controls; `ghost` renders the bare toolbar pill instead.
|
||||
*
|
||||
* `mode='single'` (default) commits on day click. `mode='range'` opens the
|
||||
* range calendar — start/end staged behind Clear/Cancel/Apply, with optional
|
||||
* time-of-day inputs — and commits via `onRangeChange`.
|
||||
*
|
||||
* @example
|
||||
* <ChipDatePicker value={value} onChange={setValue} placeholder='Select date' fullWidth />
|
||||
*
|
||||
* @example
|
||||
* // Toolbar period label that opens the calendar
|
||||
* <ChipDatePicker variant='ghost' label='June 2026' value={anchor} onChange={goToDate} />
|
||||
* <ChipDatePicker mode='range' startDate={from} endDate={to} showTime onRangeChange={apply} />
|
||||
*/
|
||||
const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(function ChipDatePicker(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
variant = 'filled',
|
||||
label,
|
||||
placeholder = 'Select date',
|
||||
align = 'start',
|
||||
disabled,
|
||||
fullWidth,
|
||||
flush,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerText = label ?? formatDateLabel(value)
|
||||
const ChipDatePicker = forwardRef<HTMLButtonElement, ChipDatePickerProps>(
|
||||
function ChipDatePicker(props, ref) {
|
||||
const {
|
||||
variant = 'filled',
|
||||
label,
|
||||
placeholder = props.mode === 'range' ? 'Select date range' : 'Select date',
|
||||
align = 'start',
|
||||
disabled,
|
||||
fullWidth,
|
||||
flush,
|
||||
className,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger asChild disabled={disabled}>
|
||||
<button
|
||||
ref={ref}
|
||||
type='button'
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
variant === 'ghost'
|
||||
? chipVariants({ fullWidth, flush })
|
||||
: cn(chipVariants({ variant: 'filled', fullWidth, flush }), TRIGGER_BORDER_CLASS),
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const triggerText =
|
||||
label ??
|
||||
(props.mode === 'range'
|
||||
? formatDateRangeLabel(props.startDate, props.endDate)
|
||||
: formatDateLabel(props.value))
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger asChild disabled={disabled}>
|
||||
<button
|
||||
ref={ref}
|
||||
type='button'
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
chipContentLabelClass,
|
||||
'flex-1',
|
||||
!triggerText && 'text-[var(--text-muted)]'
|
||||
variant === 'ghost'
|
||||
? chipVariants({ fullWidth, flush })
|
||||
: cn(chipVariants({ variant: 'filled', fullWidth, flush }), TRIGGER_BORDER_CLASS),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{triggerText || placeholder}
|
||||
</span>
|
||||
{variant === 'filled' && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
|
||||
className={cn(
|
||||
chipContentLabelClass,
|
||||
'flex-1',
|
||||
!triggerText && 'text-[var(--text-muted)]'
|
||||
)}
|
||||
>
|
||||
<ChevronDown className='h-[6px] w-[10px]' />
|
||||
{triggerText || placeholder}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm'
|
||||
)}
|
||||
>
|
||||
<Calendar
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
onChange?.(next)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
)
|
||||
})
|
||||
{variant === 'filled' && (
|
||||
<span
|
||||
aria-hidden
|
||||
className='inline-flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'
|
||||
>
|
||||
<ChevronDown className='h-[6px] w-[10px]' />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
'z-[var(--z-popover)] origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm'
|
||||
)}
|
||||
>
|
||||
{props.mode === 'range' ? (
|
||||
<Calendar
|
||||
mode='range'
|
||||
startDate={props.startDate}
|
||||
endDate={props.endDate}
|
||||
showTime={props.showTime}
|
||||
onRangeChange={(start, end) => {
|
||||
props.onRangeChange(start, end)
|
||||
setOpen(false)
|
||||
}}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
value={props.value}
|
||||
onChange={(next) => {
|
||||
props.onChange?.(next)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ChipDatePicker.displayName = 'ChipDatePicker'
|
||||
|
||||
|
||||
@@ -1,863 +0,0 @@
|
||||
/**
|
||||
* DatePicker component with calendar dropdown for date selection.
|
||||
* Uses Radix UI Popover primitives for positioning and accessibility.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic single date picker
|
||||
* <DatePicker
|
||||
* value={date}
|
||||
* onChange={(dateString) => setDate(dateString)}
|
||||
* placeholder="Select date"
|
||||
* />
|
||||
*
|
||||
* // Range date picker
|
||||
* <DatePicker
|
||||
* mode="range"
|
||||
* startDate={startDate}
|
||||
* endDate={endDate}
|
||||
* onRangeChange={(start, end) => handleRange(start, end)}
|
||||
* placeholder="Select date range"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from '@/components/emcn/components/popover/popover'
|
||||
import { TimePicker } from '@/components/emcn/components/time-picker/time-picker'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
* Variant styles for the date picker trigger button.
|
||||
* Matches the combobox and input styling patterns.
|
||||
*/
|
||||
const datePickerVariants = cva(
|
||||
'flex w-full rounded-sm border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
default: 'py-1.5 text-sm',
|
||||
sm: 'py-[5px] text-caption',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/** Base props shared by both single and range modes */
|
||||
interface DatePickerBaseProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
|
||||
VariantProps<typeof datePickerVariants> {
|
||||
/** Placeholder text when no value is selected */
|
||||
placeholder?: string
|
||||
/** Whether the picker is disabled */
|
||||
disabled?: boolean
|
||||
/** Size variant */
|
||||
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
|
||||
/** Not used in single mode */
|
||||
showTime?: never
|
||||
}
|
||||
|
||||
/** Props for range date mode */
|
||||
interface DatePickerRangeProps extends DatePickerBaseProps {
|
||||
/** Selection mode */
|
||||
mode: 'range'
|
||||
/** Start date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */
|
||||
startDate?: string | Date
|
||||
/** End date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */
|
||||
endDate?: string | Date
|
||||
/** Callback when date range is applied — returns YYYY-MM-DD or YYYY-MM-DDTHH:mm depending on showTime */
|
||||
onRangeChange?: (startDate: string, endDate: string) => void
|
||||
/** Callback when range selection is cancelled */
|
||||
onCancel?: () => void
|
||||
/** Callback when range is cleared */
|
||||
onClear?: () => void
|
||||
/** Whether to show time inputs for precise range selection */
|
||||
showTime?: boolean
|
||||
/** Not used in range mode */
|
||||
value?: never
|
||||
/** Not used in range mode */
|
||||
onChange?: never
|
||||
}
|
||||
|
||||
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps
|
||||
|
||||
/**
|
||||
* Flattened props type for safe destructuring.
|
||||
* The discriminated union prevents direct destructuring of mode-specific props,
|
||||
* so we cast to this merged shape after the forwardRef boundary.
|
||||
*/
|
||||
type FlatDatePickerProps = DatePickerBaseProps & {
|
||||
mode?: 'single' | 'range'
|
||||
value?: string | Date
|
||||
onChange?: (value: string) => void
|
||||
startDate?: string | Date
|
||||
endDate?: string | Date
|
||||
onRangeChange?: (startDate: string, endDate: string) => void
|
||||
onCancel?: () => void
|
||||
onClear?: () => void
|
||||
showTime?: boolean
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
|
||||
const MONTHS_SHORT = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
function formatDateForDisplay(date: Date | null): string {
|
||||
if (!date) return ''
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateAsString(year: number, month: number, day: number): string {
|
||||
const m = (month + 1).toString().padStart(2, '0')
|
||||
const d = day.toString().padStart(2, '0')
|
||||
return `${year}-${m}-${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string or Date value into a Date object.
|
||||
* YYYY-MM-DD strings are parsed as local time to avoid UTC offset shifts.
|
||||
*/
|
||||
function parseDate(value: string | Date | undefined): Date | null {
|
||||
if (!value) return null
|
||||
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return null
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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: _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 || !rangeStart || !rangeEnd) return false
|
||||
const date = new Date(viewYear, viewMonth, day)
|
||||
return (
|
||||
isDateInRange(date, rangeStart, rangeEnd) &&
|
||||
!isSameDay(date, rangeStart) &&
|
||||
!isSameDay(date, rangeEnd)
|
||||
)
|
||||
},
|
||||
[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-3 py-2.5'>
|
||||
{showNavigation === 'left' || showNavigation === 'both' ? (
|
||||
<button
|
||||
type='button'
|
||||
className='flex size-[24px] items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
|
||||
onClick={onPrevMonth}
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
</button>
|
||||
) : (
|
||||
<div className='size-[24px]' />
|
||||
)}
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
{MONTHS[viewMonth]} {viewYear}
|
||||
</span>
|
||||
{showNavigation === 'right' || showNavigation === 'both' ? (
|
||||
<button
|
||||
type='button'
|
||||
className='flex size-[24px] items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-primary)]'
|
||||
onClick={onNextMonth}
|
||||
>
|
||||
<ChevronRight className='size-4' />
|
||||
</button>
|
||||
) : (
|
||||
<div className='size-[24px]' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day Headers */}
|
||||
<div className='grid grid-cols-7 px-2 pt-2'>
|
||||
{DAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className='flex h-[28px] items-center justify-center text-[var(--text-muted)] text-xs'
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className='grid grid-cols-7 px-2 pb-2'>
|
||||
{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 && !rangeEnd && 'before:right-[2px] before:rounded-r-[4px]',
|
||||
isRangeMode && isStart && isEnd && 'before:rounded-sm'
|
||||
)}
|
||||
>
|
||||
{day !== null && (
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'relative z-10 flex h-[28px] w-[28px] items-center justify-center rounded-sm text-caption transition-colors',
|
||||
isRangeMode
|
||||
? isStart || isEnd
|
||||
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
|
||||
: inRange
|
||||
? 'text-[var(--text-primary)] hover-hover:bg-[#60a5fa]/40'
|
||||
: 'text-[var(--text-primary)] hover-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-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.
|
||||
* Provides a calendar dropdown for date selection.
|
||||
* Supports both single date and date range modes.
|
||||
*/
|
||||
const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
placeholder: placeholderProp,
|
||||
disabled,
|
||||
showTrigger = true,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
inline = false,
|
||||
mode,
|
||||
value,
|
||||
onChange,
|
||||
startDate,
|
||||
endDate,
|
||||
onRangeChange,
|
||||
onCancel,
|
||||
onClear,
|
||||
showTime = false,
|
||||
...htmlProps
|
||||
} = props as FlatDatePickerProps
|
||||
|
||||
const isRangeMode = mode === 'range'
|
||||
const placeholder = placeholderProp ?? (isRangeMode ? 'Select date range' : 'Select date')
|
||||
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
const open = isControlled ? controlledOpen : internalOpen
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
)
|
||||
|
||||
const selectedDate = !isRangeMode ? parseDate(value) : null
|
||||
|
||||
const [rangeStart, setRangeStart] = React.useState<Date | null>(() =>
|
||||
isRangeMode ? parseDate(startDate) : null
|
||||
)
|
||||
const [rangeEnd, setRangeEnd] = React.useState<Date | null>(() =>
|
||||
isRangeMode ? parseDate(endDate) : null
|
||||
)
|
||||
const [hoverDate, setHoverDate] = React.useState<Date | null>(null)
|
||||
const [selectingEnd, setSelectingEnd] = React.useState(false)
|
||||
const [startTime, setStartTime] = React.useState('00:00')
|
||||
const [endTime, setEndTime] = React.useState('23:59')
|
||||
|
||||
const [viewMonth, setViewMonth] = React.useState(() => {
|
||||
const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date()
|
||||
return d.getMonth()
|
||||
})
|
||||
const [viewYear, setViewYear] = React.useState(() => {
|
||||
const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date()
|
||||
return d.getFullYear()
|
||||
})
|
||||
|
||||
const rightViewMonth = viewMonth === 11 ? 0 : viewMonth + 1
|
||||
const rightViewYear = viewMonth === 11 ? viewYear + 1 : viewYear
|
||||
|
||||
// Sync range state when the popover opens with the current prop values.
|
||||
// Deps are the raw string/Date props — NOT derived Date objects — to avoid
|
||||
// an infinite re-render loop: Object.is(new Date(), new Date()) === false,
|
||||
// so derived Date objects in deps cause the effect to fire every render.
|
||||
React.useEffect(() => {
|
||||
if (!open || !isRangeMode) return
|
||||
|
||||
const start = parseDate(startDate)
|
||||
const end = parseDate(endDate)
|
||||
setRangeStart(start)
|
||||
setRangeEnd(end)
|
||||
setSelectingEnd(false)
|
||||
|
||||
if (showTime) {
|
||||
setStartTime(
|
||||
typeof startDate === 'string' && startDate.includes('T') ? startDate.slice(11, 16) : '00:00'
|
||||
)
|
||||
setEndTime(
|
||||
typeof endDate === 'string' && endDate.includes('T') ? endDate.slice(11, 16) : '23:59'
|
||||
)
|
||||
}
|
||||
|
||||
if (start) {
|
||||
setViewMonth(start.getMonth())
|
||||
setViewYear(start.getFullYear())
|
||||
} else {
|
||||
const now = new Date()
|
||||
setViewMonth(now.getMonth())
|
||||
setViewYear(now.getFullYear())
|
||||
}
|
||||
}, [open, isRangeMode, startDate, endDate, showTime])
|
||||
|
||||
// Sync the calendar view when the external single-date value changes.
|
||||
// This is a render-phase state update (derived state pattern): safe because
|
||||
// it only triggers when singleValueKey — a primitive timestamp — actually changes.
|
||||
const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined
|
||||
const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey)
|
||||
if (singleValueKey !== prevSingleValueKey) {
|
||||
setPrevSingleValueKey(singleValueKey)
|
||||
if (selectedDate) {
|
||||
setViewMonth(selectedDate.getMonth())
|
||||
setViewYear(selectedDate.getFullYear())
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDateSingle = React.useCallback(
|
||||
(day: number) => {
|
||||
if (!isRangeMode) {
|
||||
onChange?.(formatDateAsString(viewYear, viewMonth, day))
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[isRangeMode, onChange, viewYear, viewMonth, setOpen]
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
const handleHoverDate = React.useCallback((year: number, month: number, day: number | null) => {
|
||||
setHoverDate(day === null ? null : new Date(year, month, day))
|
||||
}, [])
|
||||
|
||||
const goToPrevMonth = React.useCallback(() => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11)
|
||||
setViewYear((y) => y - 1)
|
||||
} else {
|
||||
setViewMonth((m) => m - 1)
|
||||
}
|
||||
}, [viewMonth])
|
||||
|
||||
const goToNextMonth = React.useCallback(() => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0)
|
||||
setViewYear((y) => y + 1)
|
||||
} else {
|
||||
setViewMonth((m) => m + 1)
|
||||
}
|
||||
}, [viewMonth])
|
||||
|
||||
const handleSelectToday = React.useCallback(() => {
|
||||
if (!isRangeMode) {
|
||||
const now = new Date()
|
||||
setViewMonth(now.getMonth())
|
||||
setViewYear(now.getFullYear())
|
||||
onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
setOpen(false)
|
||||
}
|
||||
}, [isRangeMode, onChange, setOpen])
|
||||
|
||||
const handleApplyRange = React.useCallback(() => {
|
||||
if (!isRangeMode || !onRangeChange || !rangeStart) return
|
||||
|
||||
const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart
|
||||
const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : (rangeEnd ?? rangeStart)
|
||||
const startStr = formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate())
|
||||
const endStr = formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate())
|
||||
|
||||
let effectiveStartTime = startTime
|
||||
let effectiveEndTime = endTime
|
||||
if (showTime && startStr === endStr && startTime > endTime) {
|
||||
effectiveStartTime = endTime
|
||||
effectiveEndTime = startTime
|
||||
}
|
||||
|
||||
onRangeChange(
|
||||
showTime ? `${startStr}T${effectiveStartTime}` : startStr,
|
||||
showTime ? `${endStr}T${effectiveEndTime}:59` : endStr
|
||||
)
|
||||
setOpen(false)
|
||||
}, [isRangeMode, onRangeChange, rangeStart, rangeEnd, showTime, startTime, endTime, setOpen])
|
||||
|
||||
const handleCancelRange = React.useCallback(() => {
|
||||
if (isRangeMode) onCancel?.()
|
||||
setOpen(false)
|
||||
}, [isRangeMode, onCancel, setOpen])
|
||||
|
||||
const handleClearRange = React.useCallback(() => {
|
||||
setRangeStart(null)
|
||||
setRangeEnd(null)
|
||||
setSelectingEnd(false)
|
||||
if (isRangeMode) onClear?.()
|
||||
}, [isRangeMode, onClear])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault()
|
||||
setOpen(!open)
|
||||
}
|
||||
},
|
||||
[disabled, open, setOpen]
|
||||
)
|
||||
|
||||
const handleTriggerClick = React.useCallback(() => {
|
||||
if (!disabled) setOpen(!open)
|
||||
}, [disabled, open, setOpen])
|
||||
|
||||
const displayValue = isRangeMode
|
||||
? formatDateRangeForDisplay(parseDate(startDate), parseDate(endDate))
|
||||
: 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>
|
||||
|
||||
{/* Time inputs */}
|
||||
{showTime && (
|
||||
<div className='flex border-[var(--border-1)] border-t'>
|
||||
<div className='flex flex-1 items-center justify-between gap-2 px-3 py-2'>
|
||||
<span className='shrink-0 text-[12px] text-[var(--text-muted)]'>Start</span>
|
||||
<TimePicker size='sm' value={startTime} onChange={setStartTime} />
|
||||
</div>
|
||||
<div className='w-[1px] bg-[var(--border-1)]' />
|
||||
<div className='flex flex-1 items-center justify-between gap-2 px-3 py-2'>
|
||||
<span className='shrink-0 text-[12px] text-[var(--text-muted)]'>End</span>
|
||||
<TimePicker size='sm' value={endTime} onChange={setEndTime} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className='flex items-center justify-between border-[var(--border-1)] border-t px-3 py-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleClearRange}
|
||||
disabled={!rangeStart && !rangeEnd}
|
||||
className='text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<div className='flex items-center gap-2'>
|
||||
<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 p-2'>
|
||||
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const popoverContent = (
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
collisionPadding={16}
|
||||
className={cn(
|
||||
'rounded-md border border-[var(--border-1)] p-0',
|
||||
isRangeMode ? 'w-auto' : 'w-[280px]'
|
||||
)}
|
||||
>
|
||||
{calendarContent}
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-md 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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div ref={ref} className='relative w-full' {...htmlProps}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
datePickerVariants({ variant, size }),
|
||||
'relative cursor-pointer items-center justify-between',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={handleTriggerClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span className={cn('flex-1 truncate', !displayValue && 'text-[var(--text-muted)]')}>
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-2 h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{popoverContent}
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
DatePicker.displayName = 'DatePicker'
|
||||
|
||||
export { DatePicker, datePickerVariants }
|
||||
@@ -85,7 +85,6 @@ export {
|
||||
type ComboboxOption,
|
||||
type ComboboxOptionGroup,
|
||||
} from './combobox/combobox'
|
||||
export { DatePicker, datePickerVariants } from './date-picker/date-picker'
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
ChipInput,
|
||||
ChipSelect,
|
||||
type ComboboxOption,
|
||||
DatePicker,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/components/calendar/calendar'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
|
||||
@@ -391,10 +394,7 @@ export function AuditLogs() {
|
||||
maxHeight={320}
|
||||
align='start'
|
||||
/>
|
||||
<DatePicker
|
||||
mode='range'
|
||||
showTrigger={false}
|
||||
showTime
|
||||
<Popover
|
||||
open={datePickerOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
@@ -405,11 +405,19 @@ export function AuditLogs() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
startDate={customStartDate}
|
||||
endDate={customEndDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
/>
|
||||
>
|
||||
<PopoverAnchor className='pointer-events-none absolute inset-0' />
|
||||
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='range'
|
||||
showTime
|
||||
startDate={customStartDate}
|
||||
endDate={customEndDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
|
||||
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />
|
||||
|
||||
Reference in New Issue
Block a user