mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-31 01:37:58 -05:00
Compare commits
1 Commits
staging
...
fix/kbtags
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ec8ef2a6 |
@@ -202,7 +202,6 @@ describe('Knowledge Search Utils', () => {
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -233,7 +232,6 @@ describe('Knowledge Search Utils', () => {
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -262,7 +260,6 @@ describe('Knowledge Search Utils', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -292,7 +289,6 @@ describe('Knowledge Search Utils', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -325,7 +321,6 @@ describe('Knowledge Search Utils', () => {
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -346,7 +341,6 @@ describe('Knowledge Search Utils', () => {
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -380,7 +374,6 @@ describe('Knowledge Search Utils', () => {
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -413,7 +406,6 @@ describe('Knowledge Search Utils', () => {
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
})
|
||||
@@ -427,4 +419,97 @@ describe('Knowledge Search Utils', () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Date Filter Format Handling', () => {
|
||||
it('should accept date-only format (YYYY-MM-DD) in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'eq',
|
||||
value: '2024-01-15',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp format in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'eq',
|
||||
value: '2024-01-15T14:30:00',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp with UTC timezone in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'gte',
|
||||
value: '2024-01-15T14:30:00Z',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp with timezone offset in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'lt',
|
||||
value: '2024-01-15T14:30:00+05:00',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should support all date comparison operators', () => {
|
||||
const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']
|
||||
const validDateValue = '2024-01-15'
|
||||
|
||||
for (const operator of operators) {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator,
|
||||
value: validDateValue,
|
||||
}
|
||||
expect(filter.operator).toBe(operator)
|
||||
}
|
||||
})
|
||||
|
||||
it('should support between operator with date range', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'between',
|
||||
value: '2024-01-01',
|
||||
valueTo: '2024-12-31',
|
||||
}
|
||||
|
||||
expect(filter.operator).toBe('between')
|
||||
expect(filter.value).toBe('2024-01-01')
|
||||
expect(filter.valueTo).toBe('2024-12-31')
|
||||
})
|
||||
|
||||
it('should support between operator with timestamp range', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'between',
|
||||
value: '2024-01-01T00:00:00',
|
||||
valueTo: '2024-12-31T23:59:59',
|
||||
}
|
||||
|
||||
expect(filter.operator).toBe('between')
|
||||
expect(filter.value).toMatch(/T\d{2}:\d{2}:\d{2}$/)
|
||||
expect(filter.valueTo).toMatch(/T\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,39 +203,74 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date operators - expects YYYY-MM-DD format from frontend
|
||||
// Handle date operators - accepts YYYY-MM-DD or ISO 8601 timestamp
|
||||
if (fieldType === 'date') {
|
||||
const dateStr = String(value)
|
||||
// Validate YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
|
||||
const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
const datetimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
|
||||
|
||||
// Validate format - accept date-only or timestamp
|
||||
const isDateOnly = dateOnlyRegex.test(dateStr)
|
||||
const isTimestamp = datetimeRegex.test(dateStr)
|
||||
|
||||
if (!isDateOnly && !isTimestamp) {
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use date comparison for date-only values, timestamp comparison for timestamps
|
||||
const castType = isDateOnly ? '::date' : '::timestamp'
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
case 'neq':
|
||||
return sql`${column}::date != ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date != ${dateStr}::date`
|
||||
: sql`${column}::timestamp != ${dateStr}::timestamp`
|
||||
case 'gt':
|
||||
return sql`${column}::date > ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date > ${dateStr}::date`
|
||||
: sql`${column}::timestamp > ${dateStr}::timestamp`
|
||||
case 'gte':
|
||||
return sql`${column}::date >= ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date >= ${dateStr}::date`
|
||||
: sql`${column}::timestamp >= ${dateStr}::timestamp`
|
||||
case 'lt':
|
||||
return sql`${column}::date < ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date < ${dateStr}::date`
|
||||
: sql`${column}::timestamp < ${dateStr}::timestamp`
|
||||
case 'lte':
|
||||
return sql`${column}::date <= ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date <= ${dateStr}::date`
|
||||
: sql`${column}::timestamp <= ${dateStr}::timestamp`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const dateStrTo = String(valueTo)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
const isToDateOnly = dateOnlyRegex.test(dateStrTo)
|
||||
const isToTimestamp = datetimeRegex.test(dateStrTo)
|
||||
if (!isToDateOnly && !isToTimestamp) {
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
}
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
// Use date comparison if both are date-only, otherwise use timestamp
|
||||
if (isDateOnly && isToDateOnly) {
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
}
|
||||
return sql`${column}::timestamp >= ${dateStr}::timestamp AND ${column}::timestamp <= ${dateStrTo}::timestamp`
|
||||
}
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
default:
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ export default function PlaygroundPage() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
|
||||
const [dateValue, setDateValue] = useState('')
|
||||
const [dateTimeValue, setDateTimeValue] = useState('')
|
||||
const [dateTimePreset, setDateTimePreset] = useState('2025-01-30T14:30:00')
|
||||
const [dateRangeStart, setDateRangeStart] = useState('')
|
||||
const [dateRangeEnd, setDateRangeEnd] = useState('')
|
||||
const [tagItems, setTagItems] = useState<TagItem[]>([
|
||||
@@ -708,6 +710,30 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='with time (empty)'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
value={dateTimeValue}
|
||||
onChange={setDateTimeValue}
|
||||
placeholder='Select date and time'
|
||||
showTime
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>
|
||||
{dateTimeValue || 'No value'}
|
||||
</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='with time (preset)'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
value={dateTimePreset}
|
||||
onChange={setDateTimePreset}
|
||||
placeholder='Select date and time'
|
||||
showTime
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{dateTimePreset}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<div className='w-56'>
|
||||
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
|
||||
|
||||
@@ -25,6 +25,10 @@ interface ChunkContextMenuProps {
|
||||
* Empty space action (shown when right-clicking on empty space)
|
||||
*/
|
||||
onAddChunk?: () => void
|
||||
/**
|
||||
* View document tags (shown when right-clicking on empty space)
|
||||
*/
|
||||
onViewTags?: () => void
|
||||
/**
|
||||
* Whether the chunk is currently enabled
|
||||
*/
|
||||
@@ -75,6 +79,7 @@ export function ChunkContextMenu({
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
onAddChunk,
|
||||
onViewTags,
|
||||
isChunkEnabled = true,
|
||||
hasChunk,
|
||||
disableToggleEnabled = false,
|
||||
@@ -181,17 +186,29 @@ export function ChunkContextMenu({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onAddChunk && (
|
||||
<PopoverItem
|
||||
disabled={disableAddChunk}
|
||||
onClick={() => {
|
||||
onAddChunk()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create chunk
|
||||
</PopoverItem>
|
||||
)
|
||||
<>
|
||||
{onAddChunk && (
|
||||
<PopoverItem
|
||||
disabled={disableAddChunk}
|
||||
onClick={() => {
|
||||
onAddChunk()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create chunk
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewTags()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View tags
|
||||
</PopoverItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
@@ -384,7 +385,7 @@ export function DocumentTagsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Document Tags</span>
|
||||
@@ -405,9 +406,9 @@ export function DocumentTagsModal({
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
<Badge variant='type' size='sm'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
</Badge>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||
@@ -419,9 +420,9 @@ export function DocumentTagsModal({
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,7 +527,8 @@ export function DocumentTagsModal({
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
showTime
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -679,7 +681,8 @@ export function DocumentTagsModal({
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
showTime
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
||||
@@ -641,7 +641,7 @@ export function Document({
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Tags
|
||||
Document tags
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
@@ -864,10 +864,7 @@ export function Document({
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
>
|
||||
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content, 150, searchQuery)}
|
||||
searchQuery={searchQuery}
|
||||
@@ -1152,6 +1149,7 @@ export function Document({
|
||||
? () => setIsCreateChunkModalOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onViewTags={() => setShowTagsModal(true)}
|
||||
disableToggleEnabled={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowl
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
|
||||
import {
|
||||
ActionBar,
|
||||
AddDocumentsModal,
|
||||
@@ -367,6 +368,8 @@ export function KnowledgeBase({
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
|
||||
const [documentForTags, setDocumentForTags] = useState<DocumentData | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -525,7 +528,6 @@ export function KnowledgeBase({
|
||||
|
||||
const newEnabled = !document.enabled
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(docId, { enabled: newEnabled })
|
||||
|
||||
updateDocumentMutation(
|
||||
@@ -536,7 +538,6 @@ export function KnowledgeBase({
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
// Rollback on error
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
},
|
||||
}
|
||||
@@ -547,7 +548,6 @@ export function KnowledgeBase({
|
||||
* Handles retrying a failed document processing
|
||||
*/
|
||||
const handleRetryDocument = (docId: string) => {
|
||||
// Optimistic update
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
@@ -593,7 +593,6 @@ export function KnowledgeBase({
|
||||
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||
const previousName = currentDoc?.filename
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(documentId, { filename: newName })
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -609,7 +608,6 @@ export function KnowledgeBase({
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
// Rollback on error
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
}
|
||||
@@ -973,7 +971,7 @@ export function KnowledgeBase({
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Tags
|
||||
Tag definitions
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
@@ -1221,17 +1219,9 @@ export function KnowledgeBase({
|
||||
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
|
||||
return <IconComponent className='h-6 w-5 flex-shrink-0' />
|
||||
})()}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={doc.filename}
|
||||
>
|
||||
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{doc.filename}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
|
||||
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
|
||||
@@ -1556,6 +1546,22 @@ export function KnowledgeBase({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Tags Modal */}
|
||||
{documentForTags && (
|
||||
<DocumentTagsModal
|
||||
open={showDocumentTagsModal}
|
||||
onOpenChange={setShowDocumentTagsModal}
|
||||
knowledgeBaseId={id}
|
||||
documentId={documentForTags.id}
|
||||
documentData={documentForTags}
|
||||
onDocumentUpdate={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
updateDocument(documentForTags.id, { [key]: value })
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionBar
|
||||
selectedCount={selectedDocuments.size}
|
||||
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
|
||||
@@ -1624,13 +1630,8 @@ export function KnowledgeBase({
|
||||
onViewTags={
|
||||
contextMenuDocument && selectedDocuments.size === 1
|
||||
? () => {
|
||||
const urlParams = new URLSearchParams({
|
||||
kbName: knowledgeBaseName,
|
||||
docName: contextMenuDocument.filename || 'Document',
|
||||
})
|
||||
router.push(
|
||||
`/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
|
||||
)
|
||||
setDocumentForTags(contextMenuDocument)
|
||||
setShowDocumentTagsModal(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
type TagUsageData,
|
||||
useCreateTagDefinition,
|
||||
useDeleteTagDefinition,
|
||||
useTagUsageQuery,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
@@ -33,13 +38,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
totalCount: number
|
||||
@@ -91,45 +89,23 @@ interface BaseTagsModalProps {
|
||||
}
|
||||
|
||||
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const createTagMutation = useCreateTagDefinition()
|
||||
const deleteTagMutation = useDeleteTagDefinition()
|
||||
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(
|
||||
open ? knowledgeBaseId : null
|
||||
)
|
||||
|
||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [createTagForm, setCreateTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
|
||||
const fetchTagUsage = useCallback(async () => {
|
||||
if (!knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tag usage')
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setTagUsageData(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tag usage:', error)
|
||||
}
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchTagUsage()
|
||||
}
|
||||
}, [open, fetchTagUsage])
|
||||
|
||||
const getTagUsage = (tagSlot: string): TagUsageData => {
|
||||
return (
|
||||
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
|
||||
@@ -143,13 +119,29 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
const handleDeleteTagClick = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
setDeleteTagDialogOpen(true)
|
||||
|
||||
const { data: freshTagUsage } = await refetchTagUsage()
|
||||
const tagUsage = freshTagUsage?.find((usage) => usage.tagSlot === tag.tagSlot)
|
||||
const documentCount = tagUsage?.documentCount ?? 0
|
||||
|
||||
if (documentCount === 0) {
|
||||
try {
|
||||
await deleteTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
tagDefinitionId: tag.id,
|
||||
})
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tag definition:', error)
|
||||
}
|
||||
} else {
|
||||
setDeleteTagDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDocuments = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
await refetchTagUsage()
|
||||
setViewDocumentsDialogOpen(true)
|
||||
}
|
||||
|
||||
@@ -219,8 +211,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
fieldType: createTagForm.fieldType,
|
||||
})
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
@@ -240,8 +230,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
tagDefinitionId: selectedTag.id,
|
||||
})
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setDeleteTagDialogOpen(false)
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
@@ -265,7 +253,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Tags</span>
|
||||
@@ -315,9 +303,10 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
e.stopPropagation()
|
||||
handleDeleteTagClick(tag)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Tag</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
Add tag definition
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -415,7 +404,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{/* Delete Tag Confirmation Dialog */}
|
||||
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Delete Tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
@@ -458,7 +447,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{/* View Documents Dialog */}
|
||||
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('KnowledgeHeader')
|
||||
|
||||
@@ -55,43 +56,23 @@ interface Workspace {
|
||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
|
||||
const updateKnowledgeBase = useUpdateKnowledgeBase()
|
||||
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
|
||||
!!options?.knowledgeBaseId
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!options?.knowledgeBaseId) return
|
||||
|
||||
const fetchWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoadingWorkspaces(true)
|
||||
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions,
|
||||
}))
|
||||
|
||||
setWorkspaces(availableWorkspaces)
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsLoadingWorkspaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkspaces()
|
||||
}, [options?.knowledgeBaseId])
|
||||
const workspaces = useMemo<Workspace[]>(
|
||||
() =>
|
||||
allWorkspaces
|
||||
.filter((ws) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions as 'admin' | 'write' | 'read',
|
||||
})),
|
||||
[allWorkspaces]
|
||||
)
|
||||
|
||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useKnowledgeBasesQuery } from '@/hooks/queries/knowledge'
|
||||
import { useRecentLogs } from '@/hooks/queries/logs'
|
||||
import { useTemplates } from '@/hooks/queries/templates'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -107,10 +110,10 @@ export interface MentionDataReturn {
|
||||
|
||||
// Ensure loaded functions
|
||||
ensurePastChatsLoaded: () => Promise<void>
|
||||
ensureKnowledgeLoaded: () => Promise<void>
|
||||
ensureKnowledgeLoaded: () => void
|
||||
ensureBlocksLoaded: () => Promise<void>
|
||||
ensureTemplatesLoaded: () => Promise<void>
|
||||
ensureLogsLoaded: () => Promise<void>
|
||||
ensureTemplatesLoaded: () => void
|
||||
ensureLogsLoaded: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,8 +131,20 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
const [pastChats, setPastChats] = useState<PastChat[]>([])
|
||||
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
|
||||
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
|
||||
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
|
||||
const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false)
|
||||
const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery(
|
||||
workspaceId,
|
||||
{ enabled: shouldLoadKnowledge }
|
||||
)
|
||||
|
||||
const knowledgeBases = useMemo<KnowledgeItem[]>(() => {
|
||||
const sorted = [...knowledgeData].sort((a, b) => {
|
||||
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
})
|
||||
return sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))
|
||||
}, [knowledgeData])
|
||||
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
@@ -138,11 +153,39 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
|
||||
const [shouldLoadTemplates, setShouldLoadTemplates] = useState(false)
|
||||
const { data: templatesData, isLoading: isLoadingTemplates } = useTemplates(
|
||||
{ limit: 50, offset: 0 },
|
||||
{ enabled: shouldLoadTemplates }
|
||||
)
|
||||
|
||||
const [logsList, setLogsList] = useState<LogItem[]>([])
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||
const templatesList = useMemo<TemplateItem[]>(() => {
|
||||
const items = templatesData?.data ?? []
|
||||
return items
|
||||
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
}, [templatesData])
|
||||
|
||||
const [shouldLoadLogs, setShouldLoadLogs] = useState(false)
|
||||
const { data: logsData = [], isLoading: isLoadingLogs } = useRecentLogs(workspaceId, 50, {
|
||||
enabled: shouldLoadLogs,
|
||||
})
|
||||
|
||||
const logsList = useMemo<LogItem[]>(
|
||||
() =>
|
||||
logsData.map((l) => ({
|
||||
id: l.id,
|
||||
executionId: l.executionId || l.id,
|
||||
level: l.level,
|
||||
trigger: l.trigger || null,
|
||||
createdAt: l.createdAt,
|
||||
workflowName:
|
||||
(l.workflow && (l.workflow.name || l.workflow.title)) ||
|
||||
l.workflowName ||
|
||||
'Untitled Workflow',
|
||||
})),
|
||||
[logsData]
|
||||
)
|
||||
|
||||
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
|
||||
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
|
||||
@@ -191,7 +234,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch current blocks from store
|
||||
const workflowStoreBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
@@ -248,25 +290,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
/**
|
||||
* Ensures knowledge bases are loaded
|
||||
*/
|
||||
const ensureKnowledgeLoaded = useCallback(async () => {
|
||||
if (isLoadingKnowledge || knowledgeBases.length > 0) return
|
||||
try {
|
||||
setIsLoadingKnowledge(true)
|
||||
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const sorted = [...items].sort((a: any, b: any) => {
|
||||
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
})
|
||||
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingKnowledge(false)
|
||||
const ensureKnowledgeLoaded = useCallback(() => {
|
||||
if (!shouldLoadKnowledge) {
|
||||
setShouldLoadKnowledge(true)
|
||||
}
|
||||
}, [isLoadingKnowledge, knowledgeBases.length, workspaceId])
|
||||
}, [shouldLoadKnowledge])
|
||||
|
||||
/**
|
||||
* Ensures blocks are loaded
|
||||
@@ -319,55 +347,22 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
/**
|
||||
* Ensures templates are loaded
|
||||
*/
|
||||
const ensureTemplatesLoaded = useCallback(async () => {
|
||||
if (isLoadingTemplates || templatesList.length > 0) return
|
||||
try {
|
||||
setIsLoadingTemplates(true)
|
||||
const resp = await fetch('/api/templates?limit=50&offset=0')
|
||||
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const mapped = items
|
||||
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a: any, b: any) => b.stars - a.stars)
|
||||
setTemplatesList(mapped)
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingTemplates(false)
|
||||
const ensureTemplatesLoaded = useCallback(() => {
|
||||
if (!shouldLoadTemplates) {
|
||||
setShouldLoadTemplates(true)
|
||||
}
|
||||
}, [isLoadingTemplates, templatesList.length])
|
||||
}, [shouldLoadTemplates])
|
||||
|
||||
/**
|
||||
* Ensures logs are loaded
|
||||
*/
|
||||
const ensureLogsLoaded = useCallback(async () => {
|
||||
if (isLoadingLogs || logsList.length > 0) return
|
||||
try {
|
||||
setIsLoadingLogs(true)
|
||||
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
|
||||
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const mapped = items.map((l: any) => ({
|
||||
id: l.id,
|
||||
executionId: l.executionId || l.id,
|
||||
level: l.level,
|
||||
trigger: l.trigger || null,
|
||||
createdAt: l.createdAt,
|
||||
workflowName:
|
||||
(l.workflow && (l.workflow.name || l.workflow.title)) ||
|
||||
l.workflowName ||
|
||||
'Untitled Workflow',
|
||||
}))
|
||||
setLogsList(mapped)
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingLogs(false)
|
||||
const ensureLogsLoaded = useCallback(() => {
|
||||
if (!shouldLoadLogs) {
|
||||
setShouldLoadLogs(true)
|
||||
}
|
||||
}, [isLoadingLogs, logsList.length, workspaceId])
|
||||
}, [shouldLoadLogs])
|
||||
|
||||
return {
|
||||
// State
|
||||
pastChats,
|
||||
isLoadingPastChats,
|
||||
workflows,
|
||||
@@ -382,8 +377,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
isLoadingLogs,
|
||||
workflowBlocks,
|
||||
isLoadingWorkflowBlocks,
|
||||
|
||||
// Operations
|
||||
ensurePastChatsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
|
||||
@@ -28,7 +28,7 @@ const checkboxVariants = cva(
|
||||
'border-[var(--border-1)] bg-transparent',
|
||||
'focus-visible:outline-none',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
|
||||
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Input } from '../input/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
|
||||
|
||||
const comboboxVariants = cva(
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -460,7 +460,7 @@ const Combobox = memo(
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
'w-full pr-[40px] font-medium transition-colors hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'w-full pr-[40px] font-medium transition-colors',
|
||||
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
|
||||
SelectedIcon && !overlayContent && 'pl-[28px]',
|
||||
className
|
||||
|
||||
@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
* Matches the combobox and input styling patterns.
|
||||
*/
|
||||
const datePickerVariants = cva(
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -82,10 +82,12 @@ interface DatePickerBaseProps
|
||||
interface DatePickerSingleProps extends DatePickerBaseProps {
|
||||
/** Selection mode */
|
||||
mode?: 'single'
|
||||
/** Current selected date value (YYYY-MM-DD string or Date) */
|
||||
/** Current selected date value (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss string, or Date) */
|
||||
value?: string | Date
|
||||
/** Callback when date changes, returns YYYY-MM-DD format */
|
||||
/** Callback when date changes, returns YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format */
|
||||
onChange?: (value: string) => void
|
||||
/** When true, shows time picker after date selection and outputs ISO 8601 format */
|
||||
showTime?: boolean
|
||||
/** Not used in single mode */
|
||||
startDate?: never
|
||||
/** Not used in single mode */
|
||||
@@ -177,14 +179,91 @@ const MONTHS_SHORT = [
|
||||
|
||||
/**
|
||||
* Formats a date for display in the trigger button.
|
||||
* If time is provided, formats as "Jan 30, 2026 at 2:30 PM"
|
||||
*/
|
||||
function formatDateForDisplay(date: Date | null): string {
|
||||
function formatDateForDisplay(date: Date | null, time?: string | null): string {
|
||||
if (!date) return ''
|
||||
return date.toLocaleDateString('en-US', {
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
if (time) {
|
||||
return `${dateStr} at ${formatDisplayTime(time)}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a 24h time string to 12h display format with AM/PM.
|
||||
*/
|
||||
function formatDisplayTime(time: string): string {
|
||||
if (!time) return ''
|
||||
const [hours, minutes] = time.split(':')
|
||||
const hour = Number.parseInt(hours, 10)
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hour % 12 || 12
|
||||
return `${displayHour}:${minutes} ${ampm}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 12h time components to 24h format string.
|
||||
*/
|
||||
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
|
||||
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
|
||||
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a 24h time string into 12h components.
|
||||
* Returns default 12:00 PM if no time provided (for UI display only).
|
||||
*/
|
||||
function parseTimeComponents(time: string | null): {
|
||||
hour: string
|
||||
minute: string
|
||||
ampm: 'AM' | 'PM'
|
||||
} {
|
||||
if (!time) return { hour: '12', minute: '00', ampm: 'PM' }
|
||||
const [hours, minutes] = time.split(':')
|
||||
const hour24 = Number.parseInt(hours, 10)
|
||||
const isAM = hour24 < 12
|
||||
return {
|
||||
hour: (hour24 % 12 || 12).toString(),
|
||||
minute: minutes || '00',
|
||||
ampm: isAM ? 'AM' : 'PM',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value contains time information.
|
||||
*/
|
||||
function valueHasTime(value: string | Date | undefined): boolean {
|
||||
if (!value) return false
|
||||
if (value instanceof Date) {
|
||||
// Check if time is not midnight (default)
|
||||
return value.getHours() !== 0 || value.getMinutes() !== 0
|
||||
}
|
||||
// Check for ISO datetime format: YYYY-MM-DDTHH:mm
|
||||
return /T\d{2}:\d{2}/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts time from a datetime string or Date object.
|
||||
* Returns HH:mm format or null if no time present.
|
||||
*/
|
||||
function extractTimeFromValue(value: string | Date | undefined): string | null {
|
||||
if (!value) return null
|
||||
if (value instanceof Date) {
|
||||
// Only return time if it's not midnight (which could be default)
|
||||
if (value.getHours() === 0 && value.getMinutes() === 0) return null
|
||||
return `${value.getHours().toString().padStart(2, '0')}:${value.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
// Check for ISO datetime format: YYYY-MM-DDTHH:mm:ss
|
||||
const match = value.match(/T(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
return `${match[1]}:${match[2]}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,12 +307,16 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as YYYY-MM-DD string.
|
||||
* Formats a date as YYYY-MM-DD string, optionally with time as YYYY-MM-DDTHH:mm:ss.
|
||||
*/
|
||||
function formatDateAsString(year: number, month: number, day: number): string {
|
||||
function formatDateAsString(year: number, month: number, day: number, time?: string): string {
|
||||
const m = (month + 1).toString().padStart(2, '0')
|
||||
const d = day.toString().padStart(2, '0')
|
||||
return `${year}-${m}-${d}`
|
||||
const dateStr = `${year}-${m}-${d}`
|
||||
if (time) {
|
||||
return `${dateStr}T${time}:00`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,6 +581,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
const {
|
||||
value: _value,
|
||||
onChange: _onChange,
|
||||
showTime: _showTime,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
onRangeChange: _onRangeChange,
|
||||
@@ -507,6 +591,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
} = rest as any
|
||||
|
||||
const isRangeMode = props.mode === 'range'
|
||||
const showTime = !isRangeMode && (props as DatePickerSingleProps).showTime === true
|
||||
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
@@ -524,6 +609,37 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
|
||||
const selectedDate = !isRangeMode ? parseDate(props.value) : null
|
||||
|
||||
// Time state for showTime mode
|
||||
// Track whether the incoming value has time
|
||||
const valueTimeInfo = React.useMemo(() => {
|
||||
if (!showTime) return { hasTime: false, time: null }
|
||||
const time = extractTimeFromValue(props.value)
|
||||
return { hasTime: time !== null, time }
|
||||
}, [showTime, props.value])
|
||||
|
||||
const parsedTime = React.useMemo(
|
||||
() => parseTimeComponents(valueTimeInfo.time),
|
||||
[valueTimeInfo.time]
|
||||
)
|
||||
const [hour, setHour] = React.useState(parsedTime.hour)
|
||||
const [minute, setMinute] = React.useState(parsedTime.minute)
|
||||
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsedTime.ampm)
|
||||
// Track whether user has explicitly set time (either from value or interaction)
|
||||
const [timeWasSet, setTimeWasSet] = React.useState(valueTimeInfo.hasTime)
|
||||
const hourInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync time state when value changes
|
||||
React.useEffect(() => {
|
||||
if (showTime) {
|
||||
const time = extractTimeFromValue(props.value)
|
||||
const newParsed = parseTimeComponents(time)
|
||||
setHour(newParsed.hour)
|
||||
setMinute(newParsed.minute)
|
||||
setAmpm(newParsed.ampm)
|
||||
setTimeWasSet(time !== null)
|
||||
}
|
||||
}, [showTime, props.value])
|
||||
|
||||
const initialStart = isRangeMode ? parseDate(props.startDate) : null
|
||||
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
|
||||
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
|
||||
@@ -566,17 +682,186 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [isRangeMode, selectedDate])
|
||||
|
||||
/**
|
||||
* Gets the current time string in 24h format.
|
||||
*/
|
||||
const getCurrentTimeString = React.useCallback(() => {
|
||||
const h = Number.parseInt(hour) || 12
|
||||
const m = Number.parseInt(minute) || 0
|
||||
return formatStorageTime(h, m, ampm)
|
||||
}, [hour, minute, ampm])
|
||||
|
||||
/**
|
||||
* Handles selection of a specific day in single mode.
|
||||
*/
|
||||
const handleSelectDateSingle = React.useCallback(
|
||||
(day: number) => {
|
||||
if (!isRangeMode && props.onChange) {
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day))
|
||||
setOpen(false)
|
||||
if (showTime && timeWasSet) {
|
||||
// Only include time if it was explicitly set
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day, getCurrentTimeString()))
|
||||
} else {
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day))
|
||||
if (!showTime) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
|
||||
[
|
||||
isRangeMode,
|
||||
viewYear,
|
||||
viewMonth,
|
||||
props.onChange,
|
||||
setOpen,
|
||||
showTime,
|
||||
getCurrentTimeString,
|
||||
timeWasSet,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles hour input change.
|
||||
*/
|
||||
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
|
||||
setHour(val)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles hour input blur - validates and updates value.
|
||||
*/
|
||||
const handleHourBlur = React.useCallback(() => {
|
||||
const numVal = Number.parseInt(hour) || 12
|
||||
const clamped = Math.min(12, Math.max(1, numVal))
|
||||
setHour(clamped.toString())
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(clamped, Number.parseInt(minute) || 0, ampm)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [hour, minute, ampm, selectedDate, props.onChange, showTime])
|
||||
|
||||
/**
|
||||
* Handles minute input change.
|
||||
*/
|
||||
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
|
||||
setMinute(val)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles minute input blur - validates and updates value.
|
||||
*/
|
||||
const handleMinuteBlur = React.useCallback(() => {
|
||||
const numVal = Number.parseInt(minute) || 0
|
||||
const clamped = Math.min(59, Math.max(0, numVal))
|
||||
setMinute(clamped.toString().padStart(2, '0'))
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(Number.parseInt(hour) || 12, clamped, ampm)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [minute, hour, ampm, selectedDate, props.onChange, showTime])
|
||||
|
||||
/**
|
||||
* Handles AM/PM toggle.
|
||||
*/
|
||||
const handleAmpmChange = React.useCallback(
|
||||
(newAmpm: 'AM' | 'PM') => {
|
||||
setAmpm(newAmpm)
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(
|
||||
Number.parseInt(hour) || 12,
|
||||
Number.parseInt(minute) || 0,
|
||||
newAmpm
|
||||
)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[hour, minute, selectedDate, props.onChange, showTime]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation in hour input (Enter, ArrowUp, ArrowDown).
|
||||
*/
|
||||
const handleHourKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
setOpen(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setHour((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 12
|
||||
const next = num >= 12 ? 1 : num + 1
|
||||
return next.toString()
|
||||
})
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setHour((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 12
|
||||
const next = num <= 1 ? 12 : num - 1
|
||||
return next.toString()
|
||||
})
|
||||
}
|
||||
},
|
||||
[setOpen, timeWasSet]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation in minute input (Enter, ArrowUp, ArrowDown).
|
||||
*/
|
||||
const handleMinuteKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
setOpen(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setMinute((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 0
|
||||
const next = num >= 59 ? 0 : num + 1
|
||||
return next.toString().padStart(2, '0')
|
||||
})
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setMinute((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 0
|
||||
const next = num <= 0 ? 59 : num - 1
|
||||
return next.toString().padStart(2, '0')
|
||||
})
|
||||
}
|
||||
},
|
||||
[setOpen, timeWasSet]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -640,16 +925,31 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
|
||||
/**
|
||||
* Selects today's date (single mode only).
|
||||
* Preserves existing time if set, otherwise outputs date only.
|
||||
*/
|
||||
const handleSelectToday = React.useCallback(() => {
|
||||
if (!isRangeMode && props.onChange) {
|
||||
const now = new Date()
|
||||
setViewMonth(now.getMonth())
|
||||
setViewYear(now.getFullYear())
|
||||
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
setOpen(false)
|
||||
if (showTime && timeWasSet) {
|
||||
// Only include time if it was explicitly set
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
getCurrentTimeString()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
if (!showTime) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isRangeMode, props.onChange, setOpen])
|
||||
}, [isRangeMode, props.onChange, setOpen, showTime, getCurrentTimeString, timeWasSet])
|
||||
|
||||
/**
|
||||
* Applies the selected range (range mode only).
|
||||
@@ -710,9 +1010,11 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [disabled, open, setOpen])
|
||||
|
||||
// Only show time in display if it was explicitly set
|
||||
const displayTime = showTime && timeWasSet ? getCurrentTimeString() : null
|
||||
const displayValue = isRangeMode
|
||||
? formatDateRangeForDisplay(initialStart, initialEnd)
|
||||
: formatDateForDisplay(selectedDate)
|
||||
: formatDateForDisplay(selectedDate, displayTime)
|
||||
|
||||
const calendarContent = isRangeMode ? (
|
||||
<>
|
||||
@@ -783,12 +1085,81 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
onNextMonth={goToNextMonth}
|
||||
/>
|
||||
|
||||
{/* Today Button */}
|
||||
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
{/* Time Picker (when showTime is enabled) */}
|
||||
{showTime && (
|
||||
<div className='flex items-center justify-center gap-[6px] border-[var(--border-1)] border-t px-[12px] py-[10px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-muted)]'>Time:</span>
|
||||
<input
|
||||
ref={hourInputRef}
|
||||
className={cn(
|
||||
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
|
||||
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
value={hour}
|
||||
onChange={(e) => {
|
||||
handleHourChange(e)
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
}}
|
||||
onBlur={handleHourBlur}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onKeyDown={handleHourKeyDown}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
maxLength={2}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
|
||||
<input
|
||||
className={cn(
|
||||
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
|
||||
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
value={minute}
|
||||
onChange={(e) => {
|
||||
handleMinuteChange(e)
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
}}
|
||||
onBlur={handleMinuteBlur}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onKeyDown={handleMinuteKeyDown}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
maxLength={2}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]',
|
||||
!timeWasSet && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{(['AM', 'PM'] as const).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
type='button'
|
||||
onClick={() => handleAmpmChange(period)}
|
||||
className={cn(
|
||||
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
|
||||
timeWasSet && ampm === period
|
||||
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
|
||||
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today Button (only shown when time picker is not enabled) */}
|
||||
{!showTime && (
|
||||
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export const knowledgeKeys = {
|
||||
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
|
||||
tagDefinitions: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
|
||||
tagUsage: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
|
||||
documents: (knowledgeBaseId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
|
||||
document: (knowledgeBaseId: string, documentId: string) =>
|
||||
@@ -910,6 +912,38 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
export interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
export async function fetchTagUsage(knowledgeBaseId: string): Promise<TagUsageData[]> {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tag usage: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || 'Failed to fetch tag usage')
|
||||
}
|
||||
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
}
|
||||
|
||||
export function useTagUsageQuery(knowledgeBaseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''),
|
||||
queryFn: () => fetchTagUsage(knowledgeBaseId as string),
|
||||
enabled: Boolean(knowledgeBaseId),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreateTagDefinitionParams {
|
||||
knowledgeBaseId: string
|
||||
displayName: string
|
||||
@@ -968,6 +1002,9 @@ export function useCreateTagDefinition() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1006,6 +1043,9 @@ export function useDeleteTagDefinition() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const logKeys = {
|
||||
lists: () => [...logKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
|
||||
[...logKeys.lists(), workspaceId ?? '', filters] as const,
|
||||
recent: (workspaceId: string | undefined, limit: number) =>
|
||||
[...logKeys.all, 'recent', workspaceId ?? '', limit] as const,
|
||||
details: () => [...logKeys.all, 'detail'] as const,
|
||||
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
||||
stats: (workspaceId: string | undefined, filters: object) =>
|
||||
@@ -248,3 +250,57 @@ export function useExecutionSnapshot(executionId: string | undefined) {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple recent logs data for lightweight use cases (e.g., mention suggestions)
|
||||
*/
|
||||
export interface RecentLog {
|
||||
id: string
|
||||
executionId?: string
|
||||
level: string
|
||||
trigger: string | null
|
||||
createdAt: string
|
||||
workflow?: {
|
||||
name?: string
|
||||
title?: string
|
||||
}
|
||||
workflowName?: string
|
||||
}
|
||||
|
||||
async function fetchRecentLogs(workspaceId: string, limit: number): Promise<RecentLog[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('workspaceId', workspaceId)
|
||||
params.set('limit', limit.toString())
|
||||
params.set('details', 'full')
|
||||
|
||||
const response = await fetch(`/api/logs?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recent logs')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return Array.isArray(data?.data) ? data.data : []
|
||||
}
|
||||
|
||||
interface UseRecentLogsOptions {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching recent logs with minimal filtering.
|
||||
* Useful for lightweight use cases like mention suggestions.
|
||||
*/
|
||||
export function useRecentLogs(
|
||||
workspaceId: string | undefined,
|
||||
limit = 50,
|
||||
options?: UseRecentLogsOptions
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.recent(workspaceId, limit),
|
||||
queryFn: () => fetchRecentLogs(workspaceId as string, limit),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function getPlaceholderForFieldType(fieldType: string): string {
|
||||
case 'number':
|
||||
return 'Enter number'
|
||||
case 'date':
|
||||
return 'YYYY-MM-DD'
|
||||
return 'YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
default:
|
||||
return 'Enter value'
|
||||
}
|
||||
|
||||
187
apps/sim/lib/knowledge/tags/utils.test.ts
Normal file
187
apps/sim/lib/knowledge/tags/utils.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for knowledge tag validation utility functions
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseBooleanValue, parseDateValue, parseNumberValue, validateTagValue } from './utils'
|
||||
|
||||
describe('Knowledge Tag Utils', () => {
|
||||
describe('validateTagValue', () => {
|
||||
describe('boolean validation', () => {
|
||||
it('should accept "true" as valid boolean', () => {
|
||||
expect(validateTagValue('isActive', 'true', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept "false" as valid boolean', () => {
|
||||
expect(validateTagValue('isActive', 'false', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept case-insensitive boolean values', () => {
|
||||
expect(validateTagValue('isActive', 'TRUE', 'boolean')).toBeNull()
|
||||
expect(validateTagValue('isActive', 'FALSE', 'boolean')).toBeNull()
|
||||
expect(validateTagValue('isActive', 'True', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject invalid boolean values', () => {
|
||||
const result = validateTagValue('isActive', 'yes', 'boolean')
|
||||
expect(result).toContain('expects a boolean value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('number validation', () => {
|
||||
it('should accept valid integers', () => {
|
||||
expect(validateTagValue('count', '42', 'number')).toBeNull()
|
||||
expect(validateTagValue('count', '-10', 'number')).toBeNull()
|
||||
expect(validateTagValue('count', '0', 'number')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid decimals', () => {
|
||||
expect(validateTagValue('price', '19.99', 'number')).toBeNull()
|
||||
expect(validateTagValue('price', '-3.14', 'number')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject non-numeric values', () => {
|
||||
const result = validateTagValue('count', 'abc', 'number')
|
||||
expect(result).toContain('expects a number value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date validation', () => {
|
||||
it('should accept valid YYYY-MM-DD format', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-12-31', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp without timezone', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T00:00:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T23:59:59', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with seconds omitted', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with UTC timezone', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00Z', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with timezone offset', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00+05:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00-08:00', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with milliseconds', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00.123Z', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject invalid date format', () => {
|
||||
const result = validateTagValue('createdAt', '01/15/2024', 'date')
|
||||
expect(result).toContain('expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format')
|
||||
})
|
||||
|
||||
it('should reject invalid date values like Feb 31', () => {
|
||||
const result = validateTagValue('createdAt', '2024-02-31', 'date')
|
||||
expect(result).toContain('invalid date')
|
||||
})
|
||||
|
||||
it('should reject invalid time values', () => {
|
||||
const result = validateTagValue('createdAt', '2024-01-15T25:00:00', 'date')
|
||||
expect(result).toContain('invalid time')
|
||||
})
|
||||
|
||||
it('should reject invalid minute values', () => {
|
||||
const result = validateTagValue('createdAt', '2024-01-15T12:61:00', 'date')
|
||||
expect(result).toContain('invalid time')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text/default validation', () => {
|
||||
it('should accept any string for text type', () => {
|
||||
expect(validateTagValue('name', 'anything goes', 'text')).toBeNull()
|
||||
expect(validateTagValue('name', '123', 'text')).toBeNull()
|
||||
expect(validateTagValue('name', '', 'text')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDateValue', () => {
|
||||
it('should parse valid YYYY-MM-DD format', () => {
|
||||
const result = parseDateValue('2024-01-15')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
expect(result?.getMonth()).toBe(0) // January is 0
|
||||
expect(result?.getDate()).toBe(15)
|
||||
})
|
||||
|
||||
it('should parse valid ISO 8601 timestamp', () => {
|
||||
const result = parseDateValue('2024-01-15T14:30:00')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
expect(result?.getMonth()).toBe(0)
|
||||
expect(result?.getDate()).toBe(15)
|
||||
expect(result?.getHours()).toBe(14)
|
||||
expect(result?.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
it('should parse valid ISO 8601 timestamp with UTC timezone', () => {
|
||||
const result = parseDateValue('2024-01-15T14:30:00Z')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
})
|
||||
|
||||
it('should return null for invalid format', () => {
|
||||
expect(parseDateValue('01/15/2024')).toBeNull()
|
||||
expect(parseDateValue('invalid')).toBeNull()
|
||||
expect(parseDateValue('')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid date values', () => {
|
||||
expect(parseDateValue('2024-02-31')).toBeNull() // Feb 31 doesn't exist
|
||||
expect(parseDateValue('2024-13-01')).toBeNull() // Month 13 doesn't exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNumberValue', () => {
|
||||
it('should parse valid integers', () => {
|
||||
expect(parseNumberValue('42')).toBe(42)
|
||||
expect(parseNumberValue('-10')).toBe(-10)
|
||||
expect(parseNumberValue('0')).toBe(0)
|
||||
})
|
||||
|
||||
it('should parse valid decimals', () => {
|
||||
expect(parseNumberValue('19.99')).toBe(19.99)
|
||||
expect(parseNumberValue('-3.14')).toBeCloseTo(-3.14)
|
||||
})
|
||||
|
||||
it('should return null for non-numeric strings', () => {
|
||||
expect(parseNumberValue('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return 0 for empty string (JavaScript Number behavior)', () => {
|
||||
expect(parseNumberValue('')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBooleanValue', () => {
|
||||
it('should parse "true" to true', () => {
|
||||
expect(parseBooleanValue('true')).toBe(true)
|
||||
expect(parseBooleanValue('TRUE')).toBe(true)
|
||||
expect(parseBooleanValue(' true ')).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse "false" to false', () => {
|
||||
expect(parseBooleanValue('false')).toBe(false)
|
||||
expect(parseBooleanValue('FALSE')).toBe(false)
|
||||
expect(parseBooleanValue(' false ')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return null for invalid values', () => {
|
||||
expect(parseBooleanValue('yes')).toBeNull()
|
||||
expect(parseBooleanValue('no')).toBeNull()
|
||||
expect(parseBooleanValue('1')).toBeNull()
|
||||
expect(parseBooleanValue('')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,14 @@
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
||||
const ISO_WITH_TZ_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
|
||||
|
||||
/**
|
||||
* Check if a string is a valid date format (YYYY-MM-DD or ISO 8601 timestamp)
|
||||
*/
|
||||
function isValidDateFormat(value: string): boolean {
|
||||
return DATE_ONLY_REGEX.test(value) || DATETIME_REGEX.test(value) || ISO_WITH_TZ_REGEX.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a tag value against its expected field type
|
||||
* Returns an error message if invalid, or null if valid
|
||||
@@ -21,16 +32,35 @@ export function validateTagValue(tagName: string, value: string, fieldType: stri
|
||||
return null
|
||||
}
|
||||
case 'date': {
|
||||
// Check format first
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
|
||||
// Check format first - accept YYYY-MM-DD or ISO 8601 datetime
|
||||
if (!isValidDateFormat(stringValue)) {
|
||||
return `Tag "${tagName}" expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format, but received "${value}"`
|
||||
}
|
||||
|
||||
// Extract date parts for validation
|
||||
const datePart = stringValue.split('T')[0]
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
|
||||
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||
const [year, month, day] = stringValue.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
return `Tag "${tagName}" has an invalid date: "${value}"`
|
||||
}
|
||||
|
||||
// If timestamp is included, validate time components
|
||||
if (stringValue.includes('T')) {
|
||||
const timePart = stringValue.split('T')[1]
|
||||
// Extract hours and minutes, ignoring timezone
|
||||
const timeMatch = timePart.match(/^(\d{2}):(\d{2})/)
|
||||
if (timeMatch) {
|
||||
const hours = Number.parseInt(timeMatch[1], 10)
|
||||
const minutes = Number.parseInt(timeMatch[2], 10)
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return `Tag "${tagName}" has an invalid time: "${value}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
default:
|
||||
@@ -56,25 +86,44 @@ export function parseNumberValue(value: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string to Date with strict YYYY-MM-DD validation
|
||||
* Parse a string to Date with validation for YYYY-MM-DD or ISO 8601 timestamp
|
||||
* Returns null if invalid format or invalid date
|
||||
*/
|
||||
export function parseDateValue(value: string): Date | null {
|
||||
const stringValue = String(value).trim()
|
||||
|
||||
// Must be YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||
// Must be valid date format
|
||||
if (!isValidDateFormat(stringValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract date parts
|
||||
const datePart = stringValue.split('T')[0]
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
|
||||
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||
const [year, month, day] = stringValue.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
// First check date-only validity
|
||||
const testDate = new Date(year, month - 1, day)
|
||||
if (
|
||||
testDate.getFullYear() !== year ||
|
||||
testDate.getMonth() !== month - 1 ||
|
||||
testDate.getDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date
|
||||
// If timestamp is included, parse with time
|
||||
if (stringValue.includes('T')) {
|
||||
// Use native Date parsing for ISO strings
|
||||
const date = new Date(stringValue)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
// Date-only: return date at midnight local time
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user