diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 6224e046e..82c9a6983 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -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}$/) + }) + }) }) diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index 3eba10f91..8d93f4d5f 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -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` } } diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index bd5e6b7b5..1fb542a14 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -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([ @@ -708,6 +710,30 @@ export default function PlaygroundPage() { {dateValue || 'No date'} + +
+ +
+ + {dateTimeValue || 'No value'} + +
+ +
+ +
+ {dateTimePreset} +
{}} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx index 4586b3306..4509f810b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx @@ -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 && ( - { - onAddChunk() - onClose() - }} - > - Create chunk - - ) + <> + {onAddChunk && ( + { + onAddChunk() + onClose() + }} + > + Create chunk + + )} + {onViewTags && ( + { + onViewTags() + onClose() + }} + > + View tags + + )} + )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx index 13c01e223..253cb4ee7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx @@ -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 ( - +
Document Tags @@ -405,9 +406,9 @@ export function DocumentTagsModal({ {tag.displayName} - + {FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType} - +
{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)]' > - +
@@ -526,7 +527,8 @@ export function DocumentTagsModal({ setEditTagForm({ ...editTagForm, value })} - placeholder='Select date' + placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm' + showTime /> ) : ( setEditTagForm({ ...editTagForm, value })} - placeholder='Select date' + placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm' + showTime /> ) : ( - Tags + Document tags )} @@ -864,10 +864,7 @@ export function Document({ {chunk.chunkIndex} - + setIsCreateChunkModalOpen(true) : undefined } + onViewTags={() => setShowTagsModal(true)} disableToggleEnabled={!userPermissions.canEdit} disableDelete={!userPermissions.canEdit} disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 15d1d36d2..6a1cebc88 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -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(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) + const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false) + const [documentForTags, setDocumentForTags] = useState(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((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 )} @@ -1221,17 +1219,9 @@ export function KnowledgeBase({ const IconComponent = getDocumentIcon(doc.mimeType, doc.filename) return })()} - - - - - - - {doc.filename} - + + +
@@ -1556,6 +1546,22 @@ export function KnowledgeBase({ /> )} + {/* Document Tags Modal */} + {documentForTags && ( + { + Object.entries(updates).forEach(([key, value]) => { + updateDocument(documentForTags.id, { [key]: value }) + }) + }} + /> + )} + 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 } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx index 282a85622..d990cb456 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal.tsx @@ -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 = { 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(null) const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false) - const [tagUsageData, setTagUsageData] = useState([]) 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 ( <> - +
Tags @@ -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)]' > - + + Delete Tag
@@ -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 )} @@ -415,7 +404,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM {/* Delete Tag Confirmation Dialog */} - + Delete Tag
@@ -458,7 +447,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM {/* View Documents Dialog */} - + Documents using "{selectedTag?.displayName}"
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx index 4ae936af7..2fd1203d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-header/knowledge-header.tsx @@ -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([]) - 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( + () => + 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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 15f5007b6..121acb7e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -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 - ensureKnowledgeLoaded: () => Promise + ensureKnowledgeLoaded: () => void ensureBlocksLoaded: () => Promise - ensureTemplatesLoaded: () => Promise - ensureLogsLoaded: () => Promise + ensureTemplatesLoaded: () => void + ensureLogsLoaded: () => void } /** @@ -128,8 +131,20 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { const [pastChats, setPastChats] = useState([]) const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) - const [knowledgeBases, setKnowledgeBases] = useState([]) - const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false) + const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false) + const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery( + workspaceId, + { enabled: shouldLoadKnowledge } + ) + + const knowledgeBases = useMemo(() => { + 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([]) const [isLoadingBlocks, setIsLoadingBlocks] = useState(false) @@ -138,11 +153,39 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { setBlocksList([]) }, [config.allowedIntegrations]) - const [templatesList, setTemplatesList] = useState([]) - 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([]) - const [isLoadingLogs, setIsLoadingLogs] = useState(false) + const templatesList = useMemo(() => { + 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( + () => + 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([]) 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, diff --git a/apps/sim/components/emcn/components/checkbox/checkbox.tsx b/apps/sim/components/emcn/components/checkbox/checkbox.tsx index a6939e629..c3c1f5134 100644 --- a/apps/sim/components/emcn/components/checkbox/checkbox.tsx +++ b/apps/sim/components/emcn/components/checkbox/checkbox.tsx @@ -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: { diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index 49f464640..0535f5f97 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -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( 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((props, ref const { value: _value, onChange: _onChange, + showTime: _showTime, startDate: _startDate, endDate: _endDate, onRangeChange: _onRangeChange, @@ -507,6 +591,7 @@ const DatePicker = React.forwardRef((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((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(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(initialStart) @@ -566,17 +682,186 @@ const DatePicker = React.forwardRef((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) => { + 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) => { + 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) => { + 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) => { + 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((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((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((props, ref onNextMonth={goToNextMonth} /> - {/* Today Button */} -
- -
+ {/* Time Picker (when showTime is enabled) */} + {showTime && ( +
+ Time: + { + handleHourChange(e) + if (!timeWasSet) setTimeWasSet(true) + }} + onBlur={handleHourBlur} + onFocus={(e) => e.target.select()} + onKeyDown={handleHourKeyDown} + type='text' + inputMode='numeric' + maxLength={2} + autoComplete='off' + /> + : + { + handleMinuteChange(e) + if (!timeWasSet) setTimeWasSet(true) + }} + onBlur={handleMinuteBlur} + onFocus={(e) => e.target.select()} + onKeyDown={handleMinuteKeyDown} + type='text' + inputMode='numeric' + maxLength={2} + autoComplete='off' + /> +
+ {(['AM', 'PM'] as const).map((period) => ( + + ))} +
+
+ )} + + {/* Today Button (only shown when time picker is not enabled) */} + {!showTime && ( +
+ +
+ )} ) diff --git a/apps/sim/hooks/queries/knowledge.ts b/apps/sim/hooks/queries/knowledge.ts index 2d5edb7a7..a35afd754 100644 --- a/apps/sim/hooks/queries/knowledge.ts +++ b/apps/sim/hooks/queries/knowledge.ts @@ -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 { + 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), + }) }, }) } diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index 39f0cffe6..45c0390a8 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -15,6 +15,8 @@ export const logKeys = { lists: () => [...logKeys.all, 'list'] as const, list: (workspaceId: string | undefined, filters: Omit) => [...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 { + 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, + }) +} diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 3ed4b5e4e..0d7a0dd27 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -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' } diff --git a/apps/sim/lib/knowledge/tags/utils.test.ts b/apps/sim/lib/knowledge/tags/utils.test.ts new file mode 100644 index 000000000..01707357b --- /dev/null +++ b/apps/sim/lib/knowledge/tags/utils.test.ts @@ -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() + }) + }) +}) diff --git a/apps/sim/lib/knowledge/tags/utils.ts b/apps/sim/lib/knowledge/tags/utils.ts index 713a04cd4..ce0b9982a 100644 --- a/apps/sim/lib/knowledge/tags/utils.ts +++ b/apps/sim/lib/knowledge/tags/utils.ts @@ -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) } /**