pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/simstudioai/sim/pull/3097.patch

e` + : 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` + } + // 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}::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 bd5e6b7b5c..1fb542a143 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 4586b3306f..4509f810bd 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 13c01e2233..253cb4ee71 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 15d1d36d26..6a1cebc881 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 282a85622b..d990cb4562 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 4ae936af73..2fd1203d60 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 15f5007b6b..121acb7e9d 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 a6939e6291..c3c1f51340 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 49f4646402..0535f5f97c 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)) + 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, + 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') + }) } }, - [isRangeMode, viewYear, viewMonth, props.onChange, setOpen] + [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 2d5edb7a7b..a35afd7546 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 39f0cffe6e..45c0390a80 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 3ed4b5e4e4..0d7a0dd271 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 0000000000..01707357b6 --- /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 713a04cd41..ce0b9982a2 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) } /** pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy