mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-31 09:48:06 -05:00
Compare commits
6 Commits
fix/invite
...
fix/kbtags
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ec8ef2a6 | ||
|
|
cf2f1abcaf | ||
|
|
4109feecf6 | ||
|
|
37d5e01f5f | ||
|
|
2d799b3272 | ||
|
|
92403e0594 |
@@ -202,7 +202,6 @@ describe('Knowledge Search Utils', () => {
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -233,7 +232,6 @@ describe('Knowledge Search Utils', () => {
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -262,7 +260,6 @@ describe('Knowledge Search Utils', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -292,7 +289,6 @@ describe('Knowledge Search Utils', () => {
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -325,7 +321,6 @@ describe('Knowledge Search Utils', () => {
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -346,7 +341,6 @@ describe('Knowledge Search Utils', () => {
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -380,7 +374,6 @@ describe('Knowledge Search Utils', () => {
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
@@ -413,7 +406,6 @@ describe('Knowledge Search Utils', () => {
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
})
|
||||
@@ -427,4 +419,97 @@ describe('Knowledge Search Utils', () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Date Filter Format Handling', () => {
|
||||
it('should accept date-only format (YYYY-MM-DD) in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'eq',
|
||||
value: '2024-01-15',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp format in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'eq',
|
||||
value: '2024-01-15T14:30:00',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp with UTC timezone in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'gte',
|
||||
value: '2024-01-15T14:30:00Z',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should accept ISO 8601 timestamp with timezone offset in structured filters', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'lt',
|
||||
value: '2024-01-15T14:30:00+05:00',
|
||||
}
|
||||
|
||||
expect(filter.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/)
|
||||
expect(filter.fieldType).toBe('date')
|
||||
})
|
||||
|
||||
it('should support all date comparison operators', () => {
|
||||
const operators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']
|
||||
const validDateValue = '2024-01-15'
|
||||
|
||||
for (const operator of operators) {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator,
|
||||
value: validDateValue,
|
||||
}
|
||||
expect(filter.operator).toBe(operator)
|
||||
}
|
||||
})
|
||||
|
||||
it('should support between operator with date range', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'between',
|
||||
value: '2024-01-01',
|
||||
valueTo: '2024-12-31',
|
||||
}
|
||||
|
||||
expect(filter.operator).toBe('between')
|
||||
expect(filter.value).toBe('2024-01-01')
|
||||
expect(filter.valueTo).toBe('2024-12-31')
|
||||
})
|
||||
|
||||
it('should support between operator with timestamp range', () => {
|
||||
const filter = {
|
||||
tagSlot: 'date1',
|
||||
fieldType: 'date',
|
||||
operator: 'between',
|
||||
value: '2024-01-01T00:00:00',
|
||||
valueTo: '2024-12-31T23:59:59',
|
||||
}
|
||||
|
||||
expect(filter.operator).toBe('between')
|
||||
expect(filter.value).toMatch(/T\d{2}:\d{2}:\d{2}$/)
|
||||
expect(filter.valueTo).toMatch(/T\d{2}:\d{2}:\d{2}$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,39 +203,74 @@ function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date operators - expects YYYY-MM-DD format from frontend
|
||||
// Handle date operators - accepts YYYY-MM-DD or ISO 8601 timestamp
|
||||
if (fieldType === 'date') {
|
||||
const dateStr = String(value)
|
||||
// Validate YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
|
||||
const dateOnlyRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
const datetimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
|
||||
|
||||
// Validate format - accept date-only or timestamp
|
||||
const isDateOnly = dateOnlyRegex.test(dateStr)
|
||||
const isTimestamp = datetimeRegex.test(dateStr)
|
||||
|
||||
if (!isDateOnly && !isTimestamp) {
|
||||
logger.debug(
|
||||
`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use date comparison for date-only values, timestamp comparison for timestamps
|
||||
const castType = isDateOnly ? '::date' : '::timestamp'
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
case 'neq':
|
||||
return sql`${column}::date != ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date != ${dateStr}::date`
|
||||
: sql`${column}::timestamp != ${dateStr}::timestamp`
|
||||
case 'gt':
|
||||
return sql`${column}::date > ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date > ${dateStr}::date`
|
||||
: sql`${column}::timestamp > ${dateStr}::timestamp`
|
||||
case 'gte':
|
||||
return sql`${column}::date >= ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date >= ${dateStr}::date`
|
||||
: sql`${column}::timestamp >= ${dateStr}::timestamp`
|
||||
case 'lt':
|
||||
return sql`${column}::date < ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date < ${dateStr}::date`
|
||||
: sql`${column}::timestamp < ${dateStr}::timestamp`
|
||||
case 'lte':
|
||||
return sql`${column}::date <= ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date <= ${dateStr}::date`
|
||||
: sql`${column}::timestamp <= ${dateStr}::timestamp`
|
||||
case 'between':
|
||||
if (valueTo !== undefined) {
|
||||
const dateStrTo = String(valueTo)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
const isToDateOnly = dateOnlyRegex.test(dateStrTo)
|
||||
const isToTimestamp = datetimeRegex.test(dateStrTo)
|
||||
if (!isToDateOnly && !isToTimestamp) {
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
}
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
// Use date comparison if both are date-only, otherwise use timestamp
|
||||
if (isDateOnly && isToDateOnly) {
|
||||
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
|
||||
}
|
||||
return sql`${column}::timestamp >= ${dateStr}::timestamp AND ${column}::timestamp <= ${dateStrTo}::timestamp`
|
||||
}
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
default:
|
||||
return sql`${column}::date = ${dateStr}::date`
|
||||
return isDateOnly
|
||||
? sql`${column}::date = ${dateStr}::date`
|
||||
: sql`${column}::timestamp = ${dateStr}::timestamp`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ async function handleToolsCall(
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
|
||||
signal: AbortSignal.timeout(300000), // 5 minute timeout
|
||||
signal: AbortSignal.timeout(600000), // 10 minute timeout
|
||||
})
|
||||
|
||||
const executeResult = await response.json()
|
||||
|
||||
@@ -146,6 +146,8 @@ export default function PlaygroundPage() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [buttonGroupValue, setButtonGroupValue] = useState('curl')
|
||||
const [dateValue, setDateValue] = useState('')
|
||||
const [dateTimeValue, setDateTimeValue] = useState('')
|
||||
const [dateTimePreset, setDateTimePreset] = useState('2025-01-30T14:30:00')
|
||||
const [dateRangeStart, setDateRangeStart] = useState('')
|
||||
const [dateRangeEnd, setDateRangeEnd] = useState('')
|
||||
const [tagItems, setTagItems] = useState<TagItem[]>([
|
||||
@@ -708,6 +710,30 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{dateValue || 'No date'}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='with time (empty)'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
value={dateTimeValue}
|
||||
onChange={setDateTimeValue}
|
||||
placeholder='Select date and time'
|
||||
showTime
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>
|
||||
{dateTimeValue || 'No value'}
|
||||
</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='with time (preset)'>
|
||||
<div className='w-72'>
|
||||
<DatePicker
|
||||
value={dateTimePreset}
|
||||
onChange={setDateTimePreset}
|
||||
placeholder='Select date and time'
|
||||
showTime
|
||||
/>
|
||||
</div>
|
||||
<span className='text-[var(--text-secondary)] text-sm'>{dateTimePreset}</span>
|
||||
</VariantRow>
|
||||
<VariantRow label='size sm'>
|
||||
<div className='w-56'>
|
||||
<DatePicker placeholder='Small size' size='sm' onChange={() => {}} />
|
||||
|
||||
@@ -25,6 +25,10 @@ interface ChunkContextMenuProps {
|
||||
* Empty space action (shown when right-clicking on empty space)
|
||||
*/
|
||||
onAddChunk?: () => void
|
||||
/**
|
||||
* View document tags (shown when right-clicking on empty space)
|
||||
*/
|
||||
onViewTags?: () => void
|
||||
/**
|
||||
* Whether the chunk is currently enabled
|
||||
*/
|
||||
@@ -75,6 +79,7 @@ export function ChunkContextMenu({
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
onAddChunk,
|
||||
onViewTags,
|
||||
isChunkEnabled = true,
|
||||
hasChunk,
|
||||
disableToggleEnabled = false,
|
||||
@@ -181,17 +186,29 @@ export function ChunkContextMenu({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onAddChunk && (
|
||||
<PopoverItem
|
||||
disabled={disableAddChunk}
|
||||
onClick={() => {
|
||||
onAddChunk()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create chunk
|
||||
</PopoverItem>
|
||||
)
|
||||
<>
|
||||
{onAddChunk && (
|
||||
<PopoverItem
|
||||
disabled={disableAddChunk}
|
||||
onClick={() => {
|
||||
onAddChunk()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Create chunk
|
||||
</PopoverItem>
|
||||
)}
|
||||
{onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewTags()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View tags
|
||||
</PopoverItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Combobox,
|
||||
DatePicker,
|
||||
@@ -384,7 +385,7 @@ export function DocumentTagsModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Document Tags</span>
|
||||
@@ -405,9 +406,9 @@ export function DocumentTagsModal({
|
||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||
{tag.displayName}
|
||||
</span>
|
||||
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
|
||||
<Badge variant='type' size='sm'>
|
||||
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
|
||||
</span>
|
||||
</Badge>
|
||||
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||
@@ -419,9 +420,9 @@ export function DocumentTagsModal({
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,7 +527,8 @@ export function DocumentTagsModal({
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
showTime
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -679,7 +681,8 @@ export function DocumentTagsModal({
|
||||
<DatePicker
|
||||
value={editTagForm.value || undefined}
|
||||
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||
placeholder='Select date'
|
||||
placeholder='YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
showTime
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
|
||||
@@ -641,7 +641,7 @@ export function Document({
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Tags
|
||||
Document tags
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
@@ -864,10 +864,7 @@ export function Document({
|
||||
{chunk.chunkIndex}
|
||||
</TableCell>
|
||||
<TableCell className='px-[12px] py-[8px]'>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={chunk.content}
|
||||
>
|
||||
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
|
||||
<SearchHighlight
|
||||
text={truncateContent(chunk.content, 150, searchQuery)}
|
||||
searchQuery={searchQuery}
|
||||
@@ -1152,6 +1149,7 @@ export function Document({
|
||||
? () => setIsCreateChunkModalOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onViewTags={() => setShowTagsModal(true)}
|
||||
disableToggleEnabled={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableAddChunk={!userPermissions.canEdit || documentData?.processingStatus === 'failed'}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowl
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
|
||||
import {
|
||||
ActionBar,
|
||||
AddDocumentsModal,
|
||||
@@ -367,6 +368,8 @@ export function KnowledgeBase({
|
||||
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
|
||||
const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
|
||||
const [documentForTags, setDocumentForTags] = useState<DocumentData | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
@@ -525,7 +528,6 @@ export function KnowledgeBase({
|
||||
|
||||
const newEnabled = !document.enabled
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(docId, { enabled: newEnabled })
|
||||
|
||||
updateDocumentMutation(
|
||||
@@ -536,7 +538,6 @@ export function KnowledgeBase({
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
// Rollback on error
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
},
|
||||
}
|
||||
@@ -547,7 +548,6 @@ export function KnowledgeBase({
|
||||
* Handles retrying a failed document processing
|
||||
*/
|
||||
const handleRetryDocument = (docId: string) => {
|
||||
// Optimistic update
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
@@ -593,7 +593,6 @@ export function KnowledgeBase({
|
||||
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||
const previousName = currentDoc?.filename
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(documentId, { filename: newName })
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -609,7 +608,6 @@ export function KnowledgeBase({
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
// Rollback on error
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
}
|
||||
@@ -973,7 +971,7 @@ export function KnowledgeBase({
|
||||
variant='default'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Tags
|
||||
Tag definitions
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
@@ -1221,17 +1219,9 @@ export function KnowledgeBase({
|
||||
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
|
||||
return <IconComponent className='h-6 w-5 flex-shrink-0' />
|
||||
})()}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'
|
||||
title={doc.filename}
|
||||
>
|
||||
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{doc.filename}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span className='block min-w-0 truncate text-[14px] text-[var(--text-primary)]'>
|
||||
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
|
||||
@@ -1556,6 +1546,22 @@ export function KnowledgeBase({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Tags Modal */}
|
||||
{documentForTags && (
|
||||
<DocumentTagsModal
|
||||
open={showDocumentTagsModal}
|
||||
onOpenChange={setShowDocumentTagsModal}
|
||||
knowledgeBaseId={id}
|
||||
documentId={documentForTags.id}
|
||||
documentData={documentForTags}
|
||||
onDocumentUpdate={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
updateDocument(documentForTags.id, { [key]: value })
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionBar
|
||||
selectedCount={selectedDocuments.size}
|
||||
onEnable={disabledCount > 0 ? handleBulkEnable : undefined}
|
||||
@@ -1624,13 +1630,8 @@ export function KnowledgeBase({
|
||||
onViewTags={
|
||||
contextMenuDocument && selectedDocuments.size === 1
|
||||
? () => {
|
||||
const urlParams = new URLSearchParams({
|
||||
kbName: knowledgeBaseName,
|
||||
docName: contextMenuDocument.filename || 'Document',
|
||||
})
|
||||
router.push(
|
||||
`/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
|
||||
)
|
||||
setDocumentForTags(contextMenuDocument)
|
||||
setShowDocumentTagsModal(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
Button,
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
|
||||
import {
|
||||
type TagUsageData,
|
||||
useCreateTagDefinition,
|
||||
useDeleteTagDefinition,
|
||||
useTagUsageQuery,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
@@ -33,13 +38,6 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
boolean: 'Boolean',
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
totalCount: number
|
||||
@@ -91,45 +89,23 @@ interface BaseTagsModalProps {
|
||||
}
|
||||
|
||||
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { tagDefinitions: kbTagDefinitions } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const createTagMutation = useCreateTagDefinition()
|
||||
const deleteTagMutation = useDeleteTagDefinition()
|
||||
const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(
|
||||
open ? knowledgeBaseId : null
|
||||
)
|
||||
|
||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [createTagForm, setCreateTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
|
||||
const fetchTagUsage = useCallback(async () => {
|
||||
if (!knowledgeBaseId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tag usage')
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setTagUsageData(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tag usage:', error)
|
||||
}
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchTagUsage()
|
||||
}
|
||||
}, [open, fetchTagUsage])
|
||||
|
||||
const getTagUsage = (tagSlot: string): TagUsageData => {
|
||||
return (
|
||||
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
|
||||
@@ -143,13 +119,29 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
const handleDeleteTagClick = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
setDeleteTagDialogOpen(true)
|
||||
|
||||
const { data: freshTagUsage } = await refetchTagUsage()
|
||||
const tagUsage = freshTagUsage?.find((usage) => usage.tagSlot === tag.tagSlot)
|
||||
const documentCount = tagUsage?.documentCount ?? 0
|
||||
|
||||
if (documentCount === 0) {
|
||||
try {
|
||||
await deleteTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
tagDefinitionId: tag.id,
|
||||
})
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tag definition:', error)
|
||||
}
|
||||
} else {
|
||||
setDeleteTagDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDocuments = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
await fetchTagUsage()
|
||||
await refetchTagUsage()
|
||||
setViewDocumentsDialogOpen(true)
|
||||
}
|
||||
|
||||
@@ -219,8 +211,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
fieldType: createTagForm.fieldType,
|
||||
})
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setCreateTagForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
@@ -240,8 +230,6 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
tagDefinitionId: selectedTag.id,
|
||||
})
|
||||
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
setDeleteTagDialogOpen(false)
|
||||
setSelectedTag(null)
|
||||
} catch (error) {
|
||||
@@ -265,7 +253,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>Tags</span>
|
||||
@@ -315,9 +303,10 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
e.stopPropagation()
|
||||
handleDeleteTagClick(tag)
|
||||
}}
|
||||
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Tag</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
|
||||
className='w-full'
|
||||
>
|
||||
Add Tag
|
||||
Add tag definition
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -415,7 +404,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{/* Delete Tag Confirmation Dialog */}
|
||||
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Delete Tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
@@ -458,7 +447,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{/* View Documents Dialog */}
|
||||
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalContent size='md'>
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='space-y-[8px]'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('KnowledgeHeader')
|
||||
|
||||
@@ -55,43 +56,23 @@ interface Workspace {
|
||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
|
||||
const updateKnowledgeBase = useUpdateKnowledgeBase()
|
||||
const { data: allWorkspaces = [], isLoading: isLoadingWorkspaces } = useWorkspacesQuery(
|
||||
!!options?.knowledgeBaseId
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!options?.knowledgeBaseId) return
|
||||
|
||||
const fetchWorkspaces = async () => {
|
||||
try {
|
||||
setIsLoadingWorkspaces(true)
|
||||
|
||||
const response = await fetch('/api/workspaces')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions,
|
||||
}))
|
||||
|
||||
setWorkspaces(availableWorkspaces)
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsLoadingWorkspaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchWorkspaces()
|
||||
}, [options?.knowledgeBaseId])
|
||||
const workspaces = useMemo<Workspace[]>(
|
||||
() =>
|
||||
allWorkspaces
|
||||
.filter((ws) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws) => ({
|
||||
id: ws.id,
|
||||
name: ws.name,
|
||||
permissions: ws.permissions as 'admin' | 'write' | 'read',
|
||||
})),
|
||||
[allWorkspaces]
|
||||
)
|
||||
|
||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
|
||||
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
useTestNotification,
|
||||
useUpdateNotification,
|
||||
} from '@/hooks/queries/notifications'
|
||||
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
|
||||
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
|
||||
import { useConnectedAccounts, useConnectOAuthService } from '@/hooks/queries/oauth-connections'
|
||||
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
|
||||
import { SlackChannelSelector } from './components/slack-channel-selector'
|
||||
import { WorkflowSelector } from './components/workflow-selector'
|
||||
@@ -167,7 +166,8 @@ export function NotificationSettings({
|
||||
const deleteNotification = useDeleteNotification()
|
||||
const testNotification = useTestNotification()
|
||||
|
||||
const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts()
|
||||
const { data: slackAccounts = [], isLoading: isLoadingSlackAccounts } =
|
||||
useConnectedAccounts('slack')
|
||||
const connectSlack = useConnectOAuthService()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -530,7 +530,7 @@ export function NotificationSettings({
|
||||
message:
|
||||
result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'),
|
||||
})
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setTestStatus({ id, success: false, message: 'Failed to send test' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import {
|
||||
useWorkspacePermissions,
|
||||
useWorkspacePermissionsQuery,
|
||||
type WorkspacePermissions,
|
||||
} from '@/hooks/use-workspace-permissions'
|
||||
workspaceKeys,
|
||||
} from '@/hooks/queries/workspace'
|
||||
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
|
||||
const logger = createLogger('WorkspacePermissionsProvider')
|
||||
|
||||
interface WorkspacePermissionsContextType {
|
||||
// Raw workspace permissions data
|
||||
workspacePermissions: WorkspacePermissions | null
|
||||
permissionsLoading: boolean
|
||||
permissionsError: string | null
|
||||
updatePermissions: (newPermissions: WorkspacePermissions) => void
|
||||
refetchPermissions: () => Promise<void>
|
||||
|
||||
// Computed user permissions (connection-aware)
|
||||
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
|
||||
|
||||
// Connection state management
|
||||
setOfflineMode: (isOffline: boolean) => void
|
||||
}
|
||||
|
||||
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
|
||||
@@ -43,7 +39,6 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
setOfflineMode: () => {},
|
||||
})
|
||||
|
||||
interface WorkspacePermissionsProviderProps {
|
||||
@@ -51,35 +46,20 @@ interface WorkspacePermissionsProviderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that manages workspace permissions and user access
|
||||
* Also provides connection-aware permissions that enforce read-only mode when offline
|
||||
* Provides workspace permissions and connection-aware user access throughout the app.
|
||||
* Enforces read-only mode when offline to prevent data loss.
|
||||
*/
|
||||
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Manage offline mode state locally
|
||||
const [isOfflineMode, setIsOfflineMode] = useState(false)
|
||||
|
||||
// Track whether we've already surfaced an offline notification to avoid duplicates
|
||||
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
|
||||
|
||||
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
|
||||
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
// Set offline mode when there are operation errors
|
||||
useEffect(() => {
|
||||
if (hasOperationError) {
|
||||
setIsOfflineMode(true)
|
||||
}
|
||||
}, [hasOperationError])
|
||||
const isOfflineMode = hasOperationError
|
||||
|
||||
/**
|
||||
* Surface a global notification when entering offline mode.
|
||||
* Uses the shared notifications system instead of bespoke UI in individual components.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isOfflineMode || hasShownOfflineNotification) {
|
||||
return
|
||||
@@ -89,7 +69,6 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: 'Connection unavailable',
|
||||
// Global notification (no workflowId) so it is visible regardless of the active workflow
|
||||
action: {
|
||||
type: 'refresh',
|
||||
message: '',
|
||||
@@ -101,40 +80,44 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
}
|
||||
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
|
||||
|
||||
// Fetch workspace permissions and loading state
|
||||
const {
|
||||
permissions: workspacePermissions,
|
||||
loading: permissionsLoading,
|
||||
error: permissionsError,
|
||||
updatePermissions,
|
||||
refetch: refetchPermissions,
|
||||
} = useWorkspacePermissions(workspaceId)
|
||||
data: workspacePermissions,
|
||||
isLoading: permissionsLoading,
|
||||
error: permissionsErrorObj,
|
||||
refetch,
|
||||
} = useWorkspacePermissionsQuery(workspaceId)
|
||||
|
||||
const permissionsError = permissionsErrorObj?.message ?? null
|
||||
|
||||
const updatePermissions = useCallback(
|
||||
(newPermissions: WorkspacePermissions) => {
|
||||
if (!workspaceId) return
|
||||
queryClient.setQueryData(workspaceKeys.permissions(workspaceId), newPermissions)
|
||||
},
|
||||
[workspaceId, queryClient]
|
||||
)
|
||||
|
||||
const refetchPermissions = useCallback(async () => {
|
||||
await refetch()
|
||||
}, [refetch])
|
||||
|
||||
// Get base user permissions from workspace permissions
|
||||
const baseUserPermissions = useUserPermissions(
|
||||
workspacePermissions,
|
||||
workspacePermissions ?? null,
|
||||
permissionsLoading,
|
||||
permissionsError
|
||||
)
|
||||
|
||||
// Note: Connection-based error detection removed - only rely on operation timeouts
|
||||
// The 5-second operation timeout system will handle all error cases
|
||||
|
||||
// Create connection-aware permissions that override user permissions when offline
|
||||
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
|
||||
if (isOfflineMode) {
|
||||
// In offline mode, force read-only permissions regardless of actual user permissions
|
||||
return {
|
||||
...baseUserPermissions,
|
||||
canEdit: false,
|
||||
canAdmin: false,
|
||||
// Keep canRead true so users can still view content
|
||||
canRead: baseUserPermissions.canRead,
|
||||
isOfflineMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
// When online, use normal permissions
|
||||
return {
|
||||
...baseUserPermissions,
|
||||
isOfflineMode: false,
|
||||
@@ -143,13 +126,12 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
workspacePermissions,
|
||||
workspacePermissions: workspacePermissions ?? null,
|
||||
permissionsLoading,
|
||||
permissionsError,
|
||||
updatePermissions,
|
||||
refetchPermissions,
|
||||
userPermissions,
|
||||
setOfflineMode: setIsOfflineMode,
|
||||
}),
|
||||
[
|
||||
workspacePermissions,
|
||||
@@ -169,8 +151,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access workspace permissions and data from context
|
||||
* This provides both raw workspace permissions and computed user permissions
|
||||
* Accesses workspace permissions data and operations from context.
|
||||
* Must be used within a WorkspacePermissionsProvider.
|
||||
*/
|
||||
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
|
||||
const context = useContext(WorkspacePermissionsContext)
|
||||
@@ -183,8 +165,8 @@ export function useWorkspacePermissionsContext(): WorkspacePermissionsContextTyp
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access user permissions from context
|
||||
* This replaces individual useUserPermissions calls and includes connection-aware permissions
|
||||
* Accesses the current user's computed permissions including offline mode status.
|
||||
* Convenience hook that extracts userPermissions from the context.
|
||||
*/
|
||||
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
|
||||
isOfflineMode?: boolean
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useKnowledgeBasesQuery } from '@/hooks/queries/knowledge'
|
||||
import { useRecentLogs } from '@/hooks/queries/logs'
|
||||
import { useTemplates } from '@/hooks/queries/templates'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -107,10 +110,10 @@ export interface MentionDataReturn {
|
||||
|
||||
// Ensure loaded functions
|
||||
ensurePastChatsLoaded: () => Promise<void>
|
||||
ensureKnowledgeLoaded: () => Promise<void>
|
||||
ensureKnowledgeLoaded: () => void
|
||||
ensureBlocksLoaded: () => Promise<void>
|
||||
ensureTemplatesLoaded: () => Promise<void>
|
||||
ensureLogsLoaded: () => Promise<void>
|
||||
ensureTemplatesLoaded: () => void
|
||||
ensureLogsLoaded: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,8 +131,20 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
const [pastChats, setPastChats] = useState<PastChat[]>([])
|
||||
const [isLoadingPastChats, setIsLoadingPastChats] = useState(false)
|
||||
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeItem[]>([])
|
||||
const [isLoadingKnowledge, setIsLoadingKnowledge] = useState(false)
|
||||
const [shouldLoadKnowledge, setShouldLoadKnowledge] = useState(false)
|
||||
const { data: knowledgeData = [], isLoading: isLoadingKnowledge } = useKnowledgeBasesQuery(
|
||||
workspaceId,
|
||||
{ enabled: shouldLoadKnowledge }
|
||||
)
|
||||
|
||||
const knowledgeBases = useMemo<KnowledgeItem[]>(() => {
|
||||
const sorted = [...knowledgeData].sort((a, b) => {
|
||||
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
})
|
||||
return sorted.map((k) => ({ id: k.id, name: k.name || 'Untitled' }))
|
||||
}, [knowledgeData])
|
||||
|
||||
const [blocksList, setBlocksList] = useState<BlockItem[]>([])
|
||||
const [isLoadingBlocks, setIsLoadingBlocks] = useState(false)
|
||||
@@ -138,11 +153,39 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
setBlocksList([])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const [templatesList, setTemplatesList] = useState<TemplateItem[]>([])
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false)
|
||||
const [shouldLoadTemplates, setShouldLoadTemplates] = useState(false)
|
||||
const { data: templatesData, isLoading: isLoadingTemplates } = useTemplates(
|
||||
{ limit: 50, offset: 0 },
|
||||
{ enabled: shouldLoadTemplates }
|
||||
)
|
||||
|
||||
const [logsList, setLogsList] = useState<LogItem[]>([])
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
|
||||
const templatesList = useMemo<TemplateItem[]>(() => {
|
||||
const items = templatesData?.data ?? []
|
||||
return items
|
||||
.map((t) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
}, [templatesData])
|
||||
|
||||
const [shouldLoadLogs, setShouldLoadLogs] = useState(false)
|
||||
const { data: logsData = [], isLoading: isLoadingLogs } = useRecentLogs(workspaceId, 50, {
|
||||
enabled: shouldLoadLogs,
|
||||
})
|
||||
|
||||
const logsList = useMemo<LogItem[]>(
|
||||
() =>
|
||||
logsData.map((l) => ({
|
||||
id: l.id,
|
||||
executionId: l.executionId || l.id,
|
||||
level: l.level,
|
||||
trigger: l.trigger || null,
|
||||
createdAt: l.createdAt,
|
||||
workflowName:
|
||||
(l.workflow && (l.workflow.name || l.workflow.title)) ||
|
||||
l.workflowName ||
|
||||
'Untitled Workflow',
|
||||
})),
|
||||
[logsData]
|
||||
)
|
||||
|
||||
const [workflowBlocks, setWorkflowBlocks] = useState<WorkflowBlockItem[]>([])
|
||||
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
|
||||
@@ -191,7 +234,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch current blocks from store
|
||||
const workflowStoreBlocks = useWorkflowStore.getState().blocks
|
||||
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
@@ -248,25 +290,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
/**
|
||||
* Ensures knowledge bases are loaded
|
||||
*/
|
||||
const ensureKnowledgeLoaded = useCallback(async () => {
|
||||
if (isLoadingKnowledge || knowledgeBases.length > 0) return
|
||||
try {
|
||||
setIsLoadingKnowledge(true)
|
||||
const resp = await fetch(`/api/knowledge?workspaceId=${workspaceId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load knowledge bases: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const sorted = [...items].sort((a: any, b: any) => {
|
||||
const ta = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const tb = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
return tb - ta
|
||||
})
|
||||
setKnowledgeBases(sorted.map((k: any) => ({ id: k.id, name: k.name || 'Untitled' })))
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingKnowledge(false)
|
||||
const ensureKnowledgeLoaded = useCallback(() => {
|
||||
if (!shouldLoadKnowledge) {
|
||||
setShouldLoadKnowledge(true)
|
||||
}
|
||||
}, [isLoadingKnowledge, knowledgeBases.length, workspaceId])
|
||||
}, [shouldLoadKnowledge])
|
||||
|
||||
/**
|
||||
* Ensures blocks are loaded
|
||||
@@ -319,55 +347,22 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
/**
|
||||
* Ensures templates are loaded
|
||||
*/
|
||||
const ensureTemplatesLoaded = useCallback(async () => {
|
||||
if (isLoadingTemplates || templatesList.length > 0) return
|
||||
try {
|
||||
setIsLoadingTemplates(true)
|
||||
const resp = await fetch('/api/templates?limit=50&offset=0')
|
||||
if (!resp.ok) throw new Error(`Failed to load templates: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const mapped = items
|
||||
.map((t: any) => ({ id: t.id, name: t.name || 'Untitled Template', stars: t.stars || 0 }))
|
||||
.sort((a: any, b: any) => b.stars - a.stars)
|
||||
setTemplatesList(mapped)
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingTemplates(false)
|
||||
const ensureTemplatesLoaded = useCallback(() => {
|
||||
if (!shouldLoadTemplates) {
|
||||
setShouldLoadTemplates(true)
|
||||
}
|
||||
}, [isLoadingTemplates, templatesList.length])
|
||||
}, [shouldLoadTemplates])
|
||||
|
||||
/**
|
||||
* Ensures logs are loaded
|
||||
*/
|
||||
const ensureLogsLoaded = useCallback(async () => {
|
||||
if (isLoadingLogs || logsList.length > 0) return
|
||||
try {
|
||||
setIsLoadingLogs(true)
|
||||
const resp = await fetch(`/api/logs?workspaceId=${workspaceId}&limit=50&details=full`)
|
||||
if (!resp.ok) throw new Error(`Failed to load logs: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
const mapped = items.map((l: any) => ({
|
||||
id: l.id,
|
||||
executionId: l.executionId || l.id,
|
||||
level: l.level,
|
||||
trigger: l.trigger || null,
|
||||
createdAt: l.createdAt,
|
||||
workflowName:
|
||||
(l.workflow && (l.workflow.name || l.workflow.title)) ||
|
||||
l.workflowName ||
|
||||
'Untitled Workflow',
|
||||
}))
|
||||
setLogsList(mapped)
|
||||
} catch {
|
||||
} finally {
|
||||
setIsLoadingLogs(false)
|
||||
const ensureLogsLoaded = useCallback(() => {
|
||||
if (!shouldLoadLogs) {
|
||||
setShouldLoadLogs(true)
|
||||
}
|
||||
}, [isLoadingLogs, logsList.length, workspaceId])
|
||||
}, [shouldLoadLogs])
|
||||
|
||||
return {
|
||||
// State
|
||||
pastChats,
|
||||
isLoadingPastChats,
|
||||
workflows,
|
||||
@@ -382,8 +377,6 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn {
|
||||
isLoadingLogs,
|
||||
workflowBlocks,
|
||||
isLoadingWorkflowBlocks,
|
||||
|
||||
// Operations
|
||||
ensurePastChatsLoaded,
|
||||
ensureKnowledgeLoaded,
|
||||
ensureBlocksLoaded,
|
||||
|
||||
@@ -150,7 +150,9 @@ export function Editor() {
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
)
|
||||
const displayAdvancedOptions = advancedMode || advancedValuesPresent
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
: advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
|
||||
@@ -21,14 +21,13 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
useDeleteWorkspaceFile,
|
||||
useStorageInfo,
|
||||
useUploadWorkspaceFile,
|
||||
useWorkspaceFiles,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import { useUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
|
||||
const logger = createLogger('FileUploadsSettings')
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
@@ -94,9 +93,7 @@ export function Files() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { permissions: workspacePermissions, loading: permissionsLoading } =
|
||||
useWorkspacePermissions(workspaceId)
|
||||
const userPermissions = useUserPermissions(workspacePermissions, permissionsLoading)
|
||||
const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext()
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export const PermissionsTableSkeleton = React.memo(() => (
|
||||
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
|
||||
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center'>
|
||||
<div className='inline-flex gap-[2px]'>
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'
|
||||
@@ -1,20 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, RotateCw, X } from 'lucide-react'
|
||||
import { Badge, Button, Tooltip } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
|
||||
import { PermissionSelector } from './permission-selector'
|
||||
import { PermissionsTableSkeleton } from './permissions-table-skeleton'
|
||||
import type { UserPermissions } from './types'
|
||||
|
||||
const PermissionsTableSkeleton = () => (
|
||||
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
|
||||
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center'>
|
||||
<div className='inline-flex gap-[2px]'>
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface PermissionsTableProps {
|
||||
userPermissions: UserPermissions[]
|
||||
onPermissionChange: (userId: string, permissionType: PermissionType) => void
|
||||
onRemoveMember?: (userId: string, email: string) => void
|
||||
onRemoveInvitation?: (invitationId: string, email: string) => void
|
||||
onResendInvitation?: (invitationId: string, email: string) => void
|
||||
onResendInvitation?: (invitationId: string) => void
|
||||
disabled?: boolean
|
||||
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
|
||||
isSaving?: boolean
|
||||
@@ -143,7 +162,6 @@ export const PermissionsTable = ({
|
||||
<div>
|
||||
{allUsers.map((user) => {
|
||||
const isCurrentUser = user.isCurrentUser === true
|
||||
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
|
||||
const isPendingInvitation = user.isPendingInvitation === true
|
||||
const userIdentifier = user.userId || user.email
|
||||
const originalPermission = workspacePermissions?.users?.find(
|
||||
@@ -205,7 +223,7 @@ export const PermissionsTable = ({
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onResendInvitation(user.invitationId!, user.email)}
|
||||
onClick={() => onResendInvitation(user.invitationId!)}
|
||||
disabled={
|
||||
disabled ||
|
||||
isSaving ||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { PermissionSelector } from './components/permission-selector'
|
||||
export { PermissionsTable } from './components/permissions-table'
|
||||
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
|
||||
export type { PermissionType, UserPermissions } from './components/types'
|
||||
export { InviteModal } from './invite-modal'
|
||||
|
||||
@@ -19,7 +19,14 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
import {
|
||||
useBatchSendWorkspaceInvitations,
|
||||
useCancelWorkspaceInvitation,
|
||||
usePendingInvitations,
|
||||
useRemoveWorkspaceMember,
|
||||
useResendWorkspaceInvitation,
|
||||
useUpdateWorkspacePermissions,
|
||||
} from '@/hooks/queries/invitations'
|
||||
import type { PermissionType, UserPermissions } from './components/types'
|
||||
|
||||
const logger = createLogger('InviteModal')
|
||||
@@ -30,40 +37,25 @@ interface InviteModalProps {
|
||||
workspaceName?: string
|
||||
}
|
||||
|
||||
interface PendingInvitation {
|
||||
id: string
|
||||
workspaceId: string
|
||||
email: string
|
||||
permissions: PermissionType
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [emailItems, setEmailItems] = useState<TagItem[]>([])
|
||||
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
|
||||
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
|
||||
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
|
||||
const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState<
|
||||
Record<string, Partial<UserPermissions>>
|
||||
>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const cooldownIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>(
|
||||
null
|
||||
)
|
||||
const [isRemovingMember, setIsRemovingMember] = useState(false)
|
||||
const [invitationToRemove, setInvitationToRemove] = useState<{
|
||||
invitationId: string
|
||||
email: string
|
||||
} | null>(null)
|
||||
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
|
||||
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
|
||||
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
|
||||
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
|
||||
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -72,50 +64,26 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
workspacePermissions,
|
||||
permissionsLoading,
|
||||
updatePermissions,
|
||||
refetchPermissions,
|
||||
userPermissions: userPerms,
|
||||
} = useWorkspacePermissionsContext()
|
||||
|
||||
const { data: pendingInvitations = [], isLoading: isPendingInvitationsLoading } =
|
||||
usePendingInvitations(open ? workspaceId : undefined)
|
||||
|
||||
const batchSendInvitations = useBatchSendWorkspaceInvitations()
|
||||
const cancelInvitation = useCancelWorkspaceInvitation()
|
||||
const resendInvitation = useResendWorkspaceInvitation()
|
||||
const removeMember = useRemoveWorkspaceMember()
|
||||
const updatePermissionsMutation = useUpdateWorkspacePermissions()
|
||||
|
||||
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
|
||||
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
|
||||
const hasNewInvites = validEmails.length > 0
|
||||
|
||||
const fetchPendingInvitations = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
setIsPendingInvitationsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces/invitations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const workspacePendingInvitations =
|
||||
data.invitations
|
||||
?.filter(
|
||||
(inv: PendingInvitation) =>
|
||||
inv.status === 'pending' && inv.workspaceId === workspaceId
|
||||
)
|
||||
.map((inv: PendingInvitation) => ({
|
||||
email: inv.email,
|
||||
permissionType: inv.permissions,
|
||||
isPendingInvitation: true,
|
||||
invitationId: inv.id,
|
||||
})) || []
|
||||
|
||||
setPendingInvitations(workspacePendingInvitations)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching pending invitations:', error)
|
||||
} finally {
|
||||
setIsPendingInvitationsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && workspaceId) {
|
||||
fetchPendingInvitations()
|
||||
refetchPermissions()
|
||||
}
|
||||
}, [open, workspaceId, fetchPendingInvitations, refetchPermissions])
|
||||
const isSubmitting = batchSendInvitations.isPending
|
||||
const isSaving = updatePermissionsMutation.isPending
|
||||
const isRemovingMember = removeMember.isPending
|
||||
const isRemovingInvitation = cancelInvitation.isPending
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -180,16 +148,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
|
||||
)
|
||||
|
||||
const removeEmailItem = useCallback(
|
||||
(_value: string, index: number, isValid?: boolean) => {
|
||||
const itemToRemove = emailItems[index]
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (isValid ?? itemToRemove?.isValid) {
|
||||
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
|
||||
}
|
||||
},
|
||||
[emailItems]
|
||||
)
|
||||
const removeEmailItem = useCallback((value: string, index: number, isValid?: boolean) => {
|
||||
setEmailItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (isValid) {
|
||||
setUserPermissions((prev) => prev.filter((user) => user.email !== value))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fileInputOptions: FileInputOptions = useMemo(
|
||||
() => ({
|
||||
@@ -198,7 +162,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
extractValues: (text: string) => {
|
||||
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
|
||||
const matches = text.match(emailRegex) || []
|
||||
return [...new Set(matches.map((e) => e.toLowerCase()))]
|
||||
const uniqueEmails = [...new Set(matches.map((e) => e.toLowerCase()))]
|
||||
return uniqueEmails.filter((email) => quickValidateEmail(email).isValid)
|
||||
},
|
||||
tooltip: 'Upload emails',
|
||||
}),
|
||||
@@ -230,53 +195,38 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
[workspacePermissions?.users]
|
||||
)
|
||||
|
||||
const handleSaveChanges = useCallback(async () => {
|
||||
const handleSaveChanges = useCallback(() => {
|
||||
if (!userPerms.canAdmin || !hasPendingChanges || !workspaceId) return
|
||||
|
||||
setIsSaving(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
|
||||
userId,
|
||||
permissions: changes.permissionType || 'read',
|
||||
}))
|
||||
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
|
||||
userId,
|
||||
permissions: (changes.permissionType || 'read') as 'admin' | 'write' | 'read',
|
||||
}))
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
updatePermissionsMutation.mutate(
|
||||
{ workspaceId, updates },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.users && data.total !== undefined) {
|
||||
updatePermissions({ users: data.users, total: data.total })
|
||||
}
|
||||
setExistingUserPermissionChanges({})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Error saving permission changes:', error)
|
||||
setErrorMessage(error.message || 'Failed to save permission changes. Please try again.')
|
||||
},
|
||||
body: JSON.stringify({ updates }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update permissions')
|
||||
}
|
||||
|
||||
if (data.users && data.total !== undefined) {
|
||||
updatePermissions({ users: data.users, total: data.total })
|
||||
}
|
||||
|
||||
setExistingUserPermissionChanges({})
|
||||
} catch (error) {
|
||||
logger.error('Error saving permission changes:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to save permission changes. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
)
|
||||
}, [
|
||||
userPerms.canAdmin,
|
||||
hasPendingChanges,
|
||||
workspaceId,
|
||||
existingUserPermissionChanges,
|
||||
updatePermissions,
|
||||
updatePermissionsMutation,
|
||||
])
|
||||
|
||||
const handleRestoreChanges = useCallback(() => {
|
||||
@@ -289,62 +239,57 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
setMemberToRemove({ userId, email })
|
||||
}, [])
|
||||
|
||||
const handleRemoveMemberConfirm = useCallback(async () => {
|
||||
const handleRemoveMemberConfirm = useCallback(() => {
|
||||
if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
setIsRemovingMember(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const userRecord = workspacePermissions?.users?.find(
|
||||
(user) => user.userId === memberToRemove.userId
|
||||
)
|
||||
const userRecord = workspacePermissions?.users?.find(
|
||||
(user) => user.userId === memberToRemove.userId
|
||||
)
|
||||
|
||||
if (!userRecord) {
|
||||
throw new Error('User is not a member of this workspace')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
if (workspacePermissions) {
|
||||
const updatedUsers = workspacePermissions.users.filter(
|
||||
(user) => user.userId !== memberToRemove.userId
|
||||
)
|
||||
updatePermissions({
|
||||
users: updatedUsers,
|
||||
total: workspacePermissions.total - 1,
|
||||
})
|
||||
}
|
||||
|
||||
setExistingUserPermissionChanges((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[memberToRemove.userId]
|
||||
return updated
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error removing member:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to remove member. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setIsRemovingMember(false)
|
||||
if (!userRecord) {
|
||||
setErrorMessage('User is not a member of this workspace')
|
||||
setMemberToRemove(null)
|
||||
return
|
||||
}
|
||||
}, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions])
|
||||
|
||||
removeMember.mutate(
|
||||
{ userId: memberToRemove.userId, workspaceId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (workspacePermissions) {
|
||||
const updatedUsers = workspacePermissions.users.filter(
|
||||
(user) => user.userId !== memberToRemove.userId
|
||||
)
|
||||
updatePermissions({
|
||||
users: updatedUsers,
|
||||
total: workspacePermissions.total - 1,
|
||||
})
|
||||
}
|
||||
|
||||
setExistingUserPermissionChanges((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[memberToRemove.userId]
|
||||
return updated
|
||||
})
|
||||
setMemberToRemove(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Error removing member:', error)
|
||||
setErrorMessage(error.message || 'Failed to remove member. Please try again.')
|
||||
setMemberToRemove(null)
|
||||
},
|
||||
}
|
||||
)
|
||||
}, [
|
||||
memberToRemove,
|
||||
workspaceId,
|
||||
userPerms.canAdmin,
|
||||
workspacePermissions,
|
||||
updatePermissions,
|
||||
removeMember,
|
||||
])
|
||||
|
||||
const handleRemoveMemberCancel = useCallback(() => {
|
||||
setMemberToRemove(null)
|
||||
@@ -354,120 +299,101 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
setInvitationToRemove({ invitationId, email })
|
||||
}, [])
|
||||
|
||||
const handleRemoveInvitationConfirm = useCallback(async () => {
|
||||
const handleRemoveInvitationConfirm = useCallback(() => {
|
||||
if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
setIsRemovingInvitation(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/workspaces/invitations/${invitationToRemove.invitationId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to cancel invitation')
|
||||
cancelInvitation.mutate(
|
||||
{ invitationId: invitationToRemove.invitationId, workspaceId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setInvitationToRemove(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Error cancelling invitation:', error)
|
||||
setErrorMessage(error.message || 'Failed to cancel invitation. Please try again.')
|
||||
setInvitationToRemove(null)
|
||||
},
|
||||
}
|
||||
|
||||
setPendingInvitations((prev) =>
|
||||
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error cancelling invitation:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setIsRemovingInvitation(false)
|
||||
setInvitationToRemove(null)
|
||||
}
|
||||
}, [invitationToRemove, workspaceId, userPerms.canAdmin])
|
||||
)
|
||||
}, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation])
|
||||
|
||||
const handleRemoveInvitationCancel = useCallback(() => {
|
||||
setInvitationToRemove(null)
|
||||
}, [])
|
||||
|
||||
const handleResendInvitation = useCallback(
|
||||
async (invitationId: string, email: string) => {
|
||||
(invitationId: string) => {
|
||||
if (!workspaceId || !userPerms.canAdmin) return
|
||||
|
||||
const secondsLeft = resendCooldowns[invitationId]
|
||||
if (secondsLeft && secondsLeft > 0) return
|
||||
|
||||
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
if (resendingInvitationIds[invitationId]) return
|
||||
|
||||
setErrorMessage(null)
|
||||
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to resend invitation')
|
||||
}
|
||||
|
||||
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
setTimeout(() => {
|
||||
setResentInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
}, 4000)
|
||||
} catch (error) {
|
||||
logger.error('Error resending invitation:', error)
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
|
||||
setErrorMessage(errorMsg)
|
||||
} finally {
|
||||
setResendingInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
|
||||
|
||||
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval)
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setResendCooldowns((prev) => {
|
||||
const current = prev[invitationId]
|
||||
if (current === undefined) return prev
|
||||
if (current <= 1) {
|
||||
resendInvitation.mutate(
|
||||
{ invitationId, workspaceId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setResendingInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
clearInterval(interval)
|
||||
cooldownIntervalsRef.current.delete(invitationId)
|
||||
return next
|
||||
}
|
||||
return { ...prev, [invitationId]: current - 1 }
|
||||
})
|
||||
}, 1000)
|
||||
})
|
||||
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
|
||||
setTimeout(() => {
|
||||
setResentInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
}, 4000)
|
||||
|
||||
cooldownIntervalsRef.current.set(invitationId, interval)
|
||||
}
|
||||
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
|
||||
|
||||
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval)
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setResendCooldowns((prev) => {
|
||||
const current = prev[invitationId]
|
||||
if (current === undefined) return prev
|
||||
if (current <= 1) {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
clearInterval(interval)
|
||||
cooldownIntervalsRef.current.delete(invitationId)
|
||||
return next
|
||||
}
|
||||
return { ...prev, [invitationId]: current - 1 }
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
cooldownIntervalsRef.current.set(invitationId, interval)
|
||||
},
|
||||
onError: (error) => {
|
||||
setResendingInvitationIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[invitationId]
|
||||
return next
|
||||
})
|
||||
logger.error('Error resending invitation:', error)
|
||||
setErrorMessage(error.message || 'Failed to resend invitation. Please try again.')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[workspaceId, userPerms.canAdmin, resendCooldowns]
|
||||
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
setErrorMessage(null)
|
||||
@@ -476,122 +402,65 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const failedInvites: string[] = []
|
||||
|
||||
const results = await Promise.all(
|
||||
validEmails.map(async (email) => {
|
||||
try {
|
||||
const userPermission = userPermissions.find((up) => up.email === email)
|
||||
const permissionType = userPermission?.permissionType || 'read'
|
||||
|
||||
const response = await fetch('/api/workspaces/invitations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
email: email,
|
||||
role: 'member',
|
||||
permission: permissionType,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
failedInvites.push(email)
|
||||
|
||||
if (data.error) {
|
||||
setErrorMessage(data.error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
failedInvites.push(email)
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = results.filter(Boolean).length
|
||||
const successfulEmails = validEmails.filter((_, index) => results[index])
|
||||
|
||||
if (successCount > 0) {
|
||||
if (successfulEmails.length > 0) {
|
||||
const newPendingInvitations: UserPermissions[] = successfulEmails.map((email) => {
|
||||
const userPermission = userPermissions.find((up) => up.email === email)
|
||||
const permissionType = userPermission?.permissionType || 'read'
|
||||
|
||||
return {
|
||||
email,
|
||||
permissionType,
|
||||
isPendingInvitation: true,
|
||||
}
|
||||
})
|
||||
|
||||
setPendingInvitations((prev) => {
|
||||
const existingEmails = new Set(prev.map((inv) => inv.email))
|
||||
const merged = [...prev]
|
||||
|
||||
newPendingInvitations.forEach((inv) => {
|
||||
if (!existingEmails.has(inv.email)) {
|
||||
merged.push(inv)
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
fetchPendingInvitations()
|
||||
|
||||
if (failedInvites.length > 0) {
|
||||
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
|
||||
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
|
||||
} else {
|
||||
setEmailItems([])
|
||||
setUserPermissions([])
|
||||
}
|
||||
const invitations = validEmails.map((email) => {
|
||||
const userPermission = userPermissions.find((up) => up.email === email)
|
||||
return {
|
||||
email,
|
||||
permission: (userPermission?.permissionType || 'read') as 'admin' | 'write' | 'read',
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error inviting members:', err)
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.'
|
||||
setErrorMessage(errorMessage)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
})
|
||||
|
||||
batchSendInvitations.mutate(
|
||||
{ workspaceId, invitations },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.failed.length > 0) {
|
||||
setEmailItems(result.failed.map((f) => ({ value: f.email, isValid: true })))
|
||||
setUserPermissions((prev) =>
|
||||
prev.filter((user) => result.failed.some((f) => f.email === user.email))
|
||||
)
|
||||
setErrorMessage(result.failed[0].error)
|
||||
} else {
|
||||
setEmailItems([])
|
||||
setUserPermissions([])
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Error inviting members:', error)
|
||||
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
|
||||
[validEmails, workspaceId, userPermissions, batchSendInvitations]
|
||||
)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEmailItems([])
|
||||
setUserPermissions([])
|
||||
setPendingInvitations([])
|
||||
setIsPendingInvitationsLoading(false)
|
||||
setExistingUserPermissionChanges({})
|
||||
setIsSubmitting(false)
|
||||
setIsSaving(false)
|
||||
setErrorMessage(null)
|
||||
setMemberToRemove(null)
|
||||
setIsRemovingMember(false)
|
||||
setInvitationToRemove(null)
|
||||
setIsRemovingInvitation(false)
|
||||
setResendCooldowns({})
|
||||
setResentInvitationIds({})
|
||||
setResendingInvitationIds({})
|
||||
|
||||
cooldownIntervalsRef.current.forEach((interval) => clearInterval(interval))
|
||||
cooldownIntervalsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
const pendingInvitationsForTable: UserPermissions[] = useMemo(
|
||||
() =>
|
||||
pendingInvitations.map((inv) => ({
|
||||
email: inv.email,
|
||||
permissionType: inv.permissionType,
|
||||
isPendingInvitation: true,
|
||||
invitationId: inv.invitationId,
|
||||
})),
|
||||
[pendingInvitations]
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -681,7 +550,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
isSaving={isSaving}
|
||||
workspacePermissions={workspacePermissions}
|
||||
permissionsLoading={permissionsLoading}
|
||||
pendingInvitations={pendingInvitations}
|
||||
pendingInvitations={pendingInvitationsForTable}
|
||||
isPendingInvitationsLoading={isPendingInvitationsLoading}
|
||||
resendingInvitationIds={resendingInvitationIds}
|
||||
resentInvitationIds={resentInvitationIds}
|
||||
@@ -691,26 +560,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className='justify-between'>
|
||||
{hasPendingChanges && userPerms.canAdmin && (
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleRestoreChanges}
|
||||
>
|
||||
Restore Changes
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleSaveChanges}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex gap-[8px] ${hasPendingChanges && userPerms.canAdmin ? '' : 'pointer-events-none invisible'}`}
|
||||
aria-hidden={!(hasPendingChanges && userPerms.canAdmin)}
|
||||
>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleRestoreChanges}
|
||||
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
|
||||
>
|
||||
Restore Changes
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
disabled={isSaving || isSubmitting}
|
||||
onClick={handleSaveChanges}
|
||||
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
|
||||
@@ -14,4 +14,4 @@ export {
|
||||
export { useSidebarResize } from './use-sidebar-resize'
|
||||
export { useWorkflowOperations } from './use-workflow-operations'
|
||||
export { useWorkflowSelection } from './use-workflow-selection'
|
||||
export { useWorkspaceManagement } from './use-workspace-management'
|
||||
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { generateWorkspaceName } from '@/lib/workspaces/naming'
|
||||
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
|
||||
import {
|
||||
useCreateWorkspace,
|
||||
useDeleteWorkspace,
|
||||
useUpdateWorkspaceName,
|
||||
useWorkspacesQuery,
|
||||
type Workspace,
|
||||
workspaceKeys,
|
||||
} from '@/hooks/queries/workspace'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('useWorkspaceManagement')
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
membershipId?: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
interface UseWorkspaceManagementProps {
|
||||
workspaceId: string
|
||||
sessionUserId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
|
||||
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
|
||||
* Handles workspace validation and URL synchronization.
|
||||
*
|
||||
* @param props - Configuration object containing workspaceId and sessionUserId
|
||||
* @returns Workspace management state and operations
|
||||
* @param props.workspaceId - The current workspace ID from the URL
|
||||
* @param props.sessionUserId - The current user's session ID
|
||||
* @returns Workspace state and operations
|
||||
*/
|
||||
export function useWorkspaceManagement({
|
||||
workspaceId,
|
||||
@@ -33,140 +35,68 @@ export function useWorkspaceManagement({
|
||||
}: UseWorkspaceManagementProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const queryClient = useQueryClient()
|
||||
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
|
||||
|
||||
// Workspace management state
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
|
||||
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
|
||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
const {
|
||||
data: workspaces = [],
|
||||
isLoading: isWorkspacesLoading,
|
||||
refetch: refetchWorkspaces,
|
||||
} = useWorkspacesQuery(Boolean(sessionUserId))
|
||||
|
||||
const leaveWorkspaceMutation = useLeaveWorkspace()
|
||||
const createWorkspaceMutation = useCreateWorkspace()
|
||||
const deleteWorkspaceMutation = useDeleteWorkspace()
|
||||
const updateWorkspaceNameMutation = useUpdateWorkspaceName()
|
||||
|
||||
// Refs to avoid dependency issues
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
|
||||
const pathnameRef = useRef<string | null>(pathname || null)
|
||||
const activeWorkspaceRef = useRef<Workspace | null>(null)
|
||||
const isInitializedRef = useRef<boolean>(false)
|
||||
const hasValidatedRef = useRef<boolean>(false)
|
||||
|
||||
// Update refs when values change
|
||||
workspaceIdRef.current = workspaceId
|
||||
routerRef.current = router
|
||||
pathnameRef.current = pathname || null
|
||||
|
||||
const activeWorkspace = useMemo(() => {
|
||||
if (!workspaces.length) return null
|
||||
return workspaces.find((w) => w.id === workspaceId) ?? null
|
||||
}, [workspaces, workspaceId])
|
||||
|
||||
const activeWorkspaceRef = useRef<Workspace | null>(activeWorkspace)
|
||||
activeWorkspaceRef.current = activeWorkspace
|
||||
|
||||
/**
|
||||
* Refresh workspace list without validation logic - used for non-current workspace operations
|
||||
*/
|
||||
const refreshWorkspaceList = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.workspaces && Array.isArray(data.workspaces)) {
|
||||
const fetchedWorkspaces = data.workspaces as Workspace[]
|
||||
setWorkspaces(fetchedWorkspaces)
|
||||
|
||||
// Only update activeWorkspace if it still exists in the fetched workspaces
|
||||
// Use functional update to avoid dependency on activeWorkspace
|
||||
setActiveWorkspace((currentActive) => {
|
||||
if (!currentActive) {
|
||||
return currentActive
|
||||
}
|
||||
|
||||
const matchingWorkspace = fetchedWorkspaces.find(
|
||||
(workspace) => workspace.id === currentActive.id
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
return matchingWorkspace
|
||||
}
|
||||
|
||||
// Active workspace was deleted, clear it
|
||||
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
|
||||
return null
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error refreshing workspace list:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
useEffect(() => {
|
||||
if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentWorkspaceId = workspaceIdRef.current
|
||||
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
|
||||
|
||||
if (!matchingWorkspace) {
|
||||
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
|
||||
const fallbackWorkspace = workspaces[0]
|
||||
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
|
||||
routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/w`)
|
||||
}
|
||||
|
||||
hasValidatedRef.current = true
|
||||
}, [workspaces, isWorkspacesLoading])
|
||||
|
||||
const refreshWorkspaceList = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
}, [queryClient])
|
||||
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
setIsWorkspacesLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/workspaces')
|
||||
const data = await response.json()
|
||||
hasValidatedRef.current = false
|
||||
await refetchWorkspaces()
|
||||
}, [refetchWorkspaces])
|
||||
|
||||
if (data.workspaces && Array.isArray(data.workspaces)) {
|
||||
const fetchedWorkspaces = data.workspaces as Workspace[]
|
||||
setWorkspaces(fetchedWorkspaces)
|
||||
|
||||
// Handle active workspace selection with URL validation using refs
|
||||
const currentWorkspaceId = workspaceIdRef.current
|
||||
const currentRouter = routerRef.current
|
||||
|
||||
if (currentWorkspaceId) {
|
||||
const matchingWorkspace = fetchedWorkspaces.find(
|
||||
(workspace) => workspace.id === currentWorkspaceId
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
setActiveWorkspace(matchingWorkspace)
|
||||
} else {
|
||||
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
|
||||
|
||||
// Fallback to first workspace if current not found
|
||||
if (fetchedWorkspaces.length > 0) {
|
||||
const fallbackWorkspace = fetchedWorkspaces[0]
|
||||
setActiveWorkspace(fallbackWorkspace)
|
||||
|
||||
// Update URL to match the fallback workspace
|
||||
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
|
||||
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
|
||||
} else {
|
||||
logger.error('No workspaces available for user')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching workspaces:', err)
|
||||
} finally {
|
||||
setIsWorkspacesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Update workspace name both in API and local state
|
||||
*/
|
||||
const updateWorkspaceName = useCallback(
|
||||
async (workspaceId: string, newName: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update workspace name')
|
||||
}
|
||||
|
||||
// Update local state immediately after successful API call
|
||||
// Only update activeWorkspace if it's the one being renamed
|
||||
setActiveWorkspace((prev) =>
|
||||
prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev
|
||||
)
|
||||
setWorkspaces((prev) =>
|
||||
prev.map((workspace) =>
|
||||
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
|
||||
)
|
||||
)
|
||||
|
||||
await updateWorkspaceNameMutation.mutateAsync({ workspaceId, name: newName })
|
||||
logger.info('Successfully updated workspace name to:', newName.trim())
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -174,21 +104,18 @@ export function useWorkspaceManagement({
|
||||
return false
|
||||
}
|
||||
},
|
||||
[]
|
||||
[updateWorkspaceNameMutation]
|
||||
)
|
||||
|
||||
const switchWorkspace = useCallback(
|
||||
async (workspace: Workspace) => {
|
||||
// If already on this workspace, return
|
||||
if (activeWorkspaceRef.current?.id === workspace.id) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch workspace and update URL
|
||||
await switchToWorkspace(workspace.id)
|
||||
const currentPath = pathnameRef.current || ''
|
||||
// Preserve templates route if user is on templates or template detail
|
||||
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
|
||||
if (templateDetailMatch) {
|
||||
const templateId = templateDetailMatch[1]
|
||||
@@ -206,208 +133,122 @@ export function useWorkspaceManagement({
|
||||
[switchToWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle create workspace
|
||||
*/
|
||||
const handleCreateWorkspace = useCallback(async () => {
|
||||
if (isCreatingWorkspace) {
|
||||
if (createWorkspaceMutation.isPending) {
|
||||
logger.info('Workspace creation already in progress, ignoring request')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreatingWorkspace(true)
|
||||
logger.info('Creating new workspace')
|
||||
|
||||
// Generate workspace name using utility function
|
||||
const workspaceName = await generateWorkspaceName()
|
||||
|
||||
logger.info(`Generated workspace name: ${workspaceName}`)
|
||||
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: workspaceName,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create workspace')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const newWorkspace = data.workspace
|
||||
|
||||
const newWorkspace = await createWorkspaceMutation.mutateAsync({ name: workspaceName })
|
||||
logger.info('Created new workspace:', newWorkspace)
|
||||
|
||||
// Refresh workspace list (no URL validation needed for creation)
|
||||
await refreshWorkspaceList()
|
||||
|
||||
// Switch to the new workspace
|
||||
await switchWorkspace(newWorkspace)
|
||||
} catch (error) {
|
||||
logger.error('Error creating workspace:', error)
|
||||
} finally {
|
||||
setIsCreatingWorkspace(false)
|
||||
}
|
||||
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
|
||||
}, [createWorkspaceMutation, switchWorkspace])
|
||||
|
||||
/**
|
||||
* Confirm delete workspace
|
||||
*/
|
||||
const confirmDeleteWorkspace = useCallback(
|
||||
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
logger.info('Deleting workspace:', workspaceToDelete.id)
|
||||
|
||||
const deleteTemplates = templateAction === 'delete'
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ deleteTemplates }),
|
||||
await deleteWorkspaceMutation.mutateAsync({
|
||||
workspaceId: workspaceToDelete.id,
|
||||
deleteTemplates,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to delete workspace')
|
||||
}
|
||||
|
||||
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
|
||||
|
||||
// Check if we're deleting the current workspace (either active or in URL)
|
||||
const isDeletingCurrentWorkspace =
|
||||
workspaceIdRef.current === workspaceToDelete.id ||
|
||||
activeWorkspaceRef.current?.id === workspaceToDelete.id
|
||||
|
||||
if (isDeletingCurrentWorkspace) {
|
||||
// For current workspace deletion, use full fetchWorkspaces with URL validation
|
||||
logger.info(
|
||||
'Deleting current workspace - using full workspace refresh with URL validation'
|
||||
)
|
||||
await fetchWorkspaces()
|
||||
hasValidatedRef.current = false
|
||||
const { data: updatedWorkspaces } = await refetchWorkspaces()
|
||||
|
||||
// If we deleted the active workspace, switch to the first available workspace
|
||||
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
|
||||
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
const remainingWorkspaces = (updatedWorkspaces || []).filter(
|
||||
(w) => w.id !== workspaceToDelete.id
|
||||
)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
} else {
|
||||
// For non-current workspace deletion, just refresh the list without URL validation
|
||||
logger.info('Deleting non-current workspace - using simple list refresh')
|
||||
await refreshWorkspaceList()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workspace:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
},
|
||||
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
|
||||
[deleteWorkspaceMutation, refetchWorkspaces, switchWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle leave workspace
|
||||
*/
|
||||
const handleLeaveWorkspace = useCallback(
|
||||
async (workspaceToLeave: Workspace) => {
|
||||
setIsLeaving(true)
|
||||
if (!sessionUserId) {
|
||||
logger.error('Cannot leave workspace: no session user ID')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Leaving workspace:', workspaceToLeave.id)
|
||||
|
||||
try {
|
||||
logger.info('Leaving workspace:', workspaceToLeave.id)
|
||||
|
||||
// Use the existing member removal API with current user's ID
|
||||
const response = await fetch(`/api/workspaces/members/${sessionUserId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: workspaceToLeave.id,
|
||||
}),
|
||||
await leaveWorkspaceMutation.mutateAsync({
|
||||
userId: sessionUserId,
|
||||
workspaceId: workspaceToLeave.id,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to leave workspace')
|
||||
}
|
||||
|
||||
logger.info('Left workspace successfully:', workspaceToLeave.id)
|
||||
|
||||
// Check if we're leaving the current workspace (either active or in URL)
|
||||
const isLeavingCurrentWorkspace =
|
||||
workspaceIdRef.current === workspaceToLeave.id ||
|
||||
activeWorkspaceRef.current?.id === workspaceToLeave.id
|
||||
|
||||
if (isLeavingCurrentWorkspace) {
|
||||
// For current workspace leaving, use full fetchWorkspaces with URL validation
|
||||
logger.info(
|
||||
'Leaving current workspace - using full workspace refresh with URL validation'
|
||||
)
|
||||
await fetchWorkspaces()
|
||||
hasValidatedRef.current = false
|
||||
const { data: updatedWorkspaces } = await refetchWorkspaces()
|
||||
|
||||
// If we left the active workspace, switch to the first available workspace
|
||||
if (activeWorkspaceRef.current?.id === workspaceToLeave.id) {
|
||||
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToLeave.id)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
const remainingWorkspaces = (updatedWorkspaces || []).filter(
|
||||
(w) => w.id !== workspaceToLeave.id
|
||||
)
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
await switchWorkspace(remainingWorkspaces[0])
|
||||
}
|
||||
} else {
|
||||
// For non-current workspace leaving, just refresh the list without URL validation
|
||||
logger.info('Leaving non-current workspace - using simple list refresh')
|
||||
await refreshWorkspaceList()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error leaving workspace:', error)
|
||||
} finally {
|
||||
setIsLeaving(false)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace, sessionUserId]
|
||||
[refetchWorkspaces, switchWorkspace, sessionUserId, leaveWorkspaceMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate workspace exists before making API calls
|
||||
*/
|
||||
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Initialize workspace data on mount (uses full validation with URL handling)
|
||||
* fetchWorkspaces is stable (empty deps array), so it's safe to call without including it
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (sessionUserId && !isInitializedRef.current) {
|
||||
isInitializedRef.current = true
|
||||
fetchWorkspaces()
|
||||
}
|
||||
}, [sessionUserId, fetchWorkspaces])
|
||||
const isWorkspaceValid = useCallback(
|
||||
(targetWorkspaceId: string) => {
|
||||
return workspaces.some((w) => w.id === targetWorkspaceId)
|
||||
},
|
||||
[workspaces]
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
workspaces,
|
||||
activeWorkspace,
|
||||
isWorkspacesLoading,
|
||||
isCreatingWorkspace,
|
||||
isDeleting,
|
||||
isLeaving,
|
||||
|
||||
// Operations
|
||||
isCreatingWorkspace: createWorkspaceMutation.isPending,
|
||||
isDeleting: deleteWorkspaceMutation.isPending,
|
||||
isLeaving: leaveWorkspaceMutation.isPending,
|
||||
fetchWorkspaces,
|
||||
refreshWorkspaceList,
|
||||
updateWorkspaceName,
|
||||
@@ -418,3 +259,5 @@ export function useWorkspaceManagement({
|
||||
isWorkspaceValid,
|
||||
}
|
||||
}
|
||||
|
||||
export type { Workspace }
|
||||
|
||||
@@ -28,7 +28,7 @@ const checkboxVariants = cva(
|
||||
'border-[var(--border-1)] bg-transparent',
|
||||
'focus-visible:outline-none',
|
||||
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
|
||||
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Input } from '../input/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
|
||||
|
||||
const comboboxVariants = cva(
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -460,7 +460,7 @@ const Combobox = memo(
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
'w-full pr-[40px] font-medium transition-colors hover:bg-[var(--surface-7)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'w-full pr-[40px] font-medium transition-colors',
|
||||
(overlayContent || SelectedIcon) && 'text-transparent caret-foreground',
|
||||
SelectedIcon && !overlayContent && 'pl-[28px]',
|
||||
className
|
||||
|
||||
@@ -40,7 +40,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
* Matches the combobox and input styling patterns.
|
||||
*/
|
||||
const datePickerVariants = cva(
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-7)] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-7)] dark:hover:bg-[var(--border-1)]',
|
||||
'flex w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -82,10 +82,12 @@ interface DatePickerBaseProps
|
||||
interface DatePickerSingleProps extends DatePickerBaseProps {
|
||||
/** Selection mode */
|
||||
mode?: 'single'
|
||||
/** Current selected date value (YYYY-MM-DD string or Date) */
|
||||
/** Current selected date value (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss string, or Date) */
|
||||
value?: string | Date
|
||||
/** Callback when date changes, returns YYYY-MM-DD format */
|
||||
/** Callback when date changes, returns YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format */
|
||||
onChange?: (value: string) => void
|
||||
/** When true, shows time picker after date selection and outputs ISO 8601 format */
|
||||
showTime?: boolean
|
||||
/** Not used in single mode */
|
||||
startDate?: never
|
||||
/** Not used in single mode */
|
||||
@@ -177,14 +179,91 @@ const MONTHS_SHORT = [
|
||||
|
||||
/**
|
||||
* Formats a date for display in the trigger button.
|
||||
* If time is provided, formats as "Jan 30, 2026 at 2:30 PM"
|
||||
*/
|
||||
function formatDateForDisplay(date: Date | null): string {
|
||||
function formatDateForDisplay(date: Date | null, time?: string | null): string {
|
||||
if (!date) return ''
|
||||
return date.toLocaleDateString('en-US', {
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
if (time) {
|
||||
return `${dateStr} at ${formatDisplayTime(time)}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a 24h time string to 12h display format with AM/PM.
|
||||
*/
|
||||
function formatDisplayTime(time: string): string {
|
||||
if (!time) return ''
|
||||
const [hours, minutes] = time.split(':')
|
||||
const hour = Number.parseInt(hours, 10)
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hour % 12 || 12
|
||||
return `${displayHour}:${minutes} ${ampm}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts 12h time components to 24h format string.
|
||||
*/
|
||||
function formatStorageTime(hour: number, minute: number, ampm: 'AM' | 'PM'): string {
|
||||
const hours24 = ampm === 'PM' ? (hour === 12 ? 12 : hour + 12) : hour === 12 ? 0 : hour
|
||||
return `${hours24.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a 24h time string into 12h components.
|
||||
* Returns default 12:00 PM if no time provided (for UI display only).
|
||||
*/
|
||||
function parseTimeComponents(time: string | null): {
|
||||
hour: string
|
||||
minute: string
|
||||
ampm: 'AM' | 'PM'
|
||||
} {
|
||||
if (!time) return { hour: '12', minute: '00', ampm: 'PM' }
|
||||
const [hours, minutes] = time.split(':')
|
||||
const hour24 = Number.parseInt(hours, 10)
|
||||
const isAM = hour24 < 12
|
||||
return {
|
||||
hour: (hour24 % 12 || 12).toString(),
|
||||
minute: minutes || '00',
|
||||
ampm: isAM ? 'AM' : 'PM',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value contains time information.
|
||||
*/
|
||||
function valueHasTime(value: string | Date | undefined): boolean {
|
||||
if (!value) return false
|
||||
if (value instanceof Date) {
|
||||
// Check if time is not midnight (default)
|
||||
return value.getHours() !== 0 || value.getMinutes() !== 0
|
||||
}
|
||||
// Check for ISO datetime format: YYYY-MM-DDTHH:mm
|
||||
return /T\d{2}:\d{2}/.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts time from a datetime string or Date object.
|
||||
* Returns HH:mm format or null if no time present.
|
||||
*/
|
||||
function extractTimeFromValue(value: string | Date | undefined): string | null {
|
||||
if (!value) return null
|
||||
if (value instanceof Date) {
|
||||
// Only return time if it's not midnight (which could be default)
|
||||
if (value.getHours() === 0 && value.getMinutes() === 0) return null
|
||||
return `${value.getHours().toString().padStart(2, '0')}:${value.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
// Check for ISO datetime format: YYYY-MM-DDTHH:mm:ss
|
||||
const match = value.match(/T(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
return `${match[1]}:${match[2]}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,12 +307,16 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as YYYY-MM-DD string.
|
||||
* Formats a date as YYYY-MM-DD string, optionally with time as YYYY-MM-DDTHH:mm:ss.
|
||||
*/
|
||||
function formatDateAsString(year: number, month: number, day: number): string {
|
||||
function formatDateAsString(year: number, month: number, day: number, time?: string): string {
|
||||
const m = (month + 1).toString().padStart(2, '0')
|
||||
const d = day.toString().padStart(2, '0')
|
||||
return `${year}-${m}-${d}`
|
||||
const dateStr = `${year}-${m}-${d}`
|
||||
if (time) {
|
||||
return `${dateStr}T${time}:00`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,6 +581,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
const {
|
||||
value: _value,
|
||||
onChange: _onChange,
|
||||
showTime: _showTime,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
onRangeChange: _onRangeChange,
|
||||
@@ -507,6 +591,7 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
} = rest as any
|
||||
|
||||
const isRangeMode = props.mode === 'range'
|
||||
const showTime = !isRangeMode && (props as DatePickerSingleProps).showTime === true
|
||||
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const [internalOpen, setInternalOpen] = React.useState(false)
|
||||
@@ -524,6 +609,37 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
|
||||
const selectedDate = !isRangeMode ? parseDate(props.value) : null
|
||||
|
||||
// Time state for showTime mode
|
||||
// Track whether the incoming value has time
|
||||
const valueTimeInfo = React.useMemo(() => {
|
||||
if (!showTime) return { hasTime: false, time: null }
|
||||
const time = extractTimeFromValue(props.value)
|
||||
return { hasTime: time !== null, time }
|
||||
}, [showTime, props.value])
|
||||
|
||||
const parsedTime = React.useMemo(
|
||||
() => parseTimeComponents(valueTimeInfo.time),
|
||||
[valueTimeInfo.time]
|
||||
)
|
||||
const [hour, setHour] = React.useState(parsedTime.hour)
|
||||
const [minute, setMinute] = React.useState(parsedTime.minute)
|
||||
const [ampm, setAmpm] = React.useState<'AM' | 'PM'>(parsedTime.ampm)
|
||||
// Track whether user has explicitly set time (either from value or interaction)
|
||||
const [timeWasSet, setTimeWasSet] = React.useState(valueTimeInfo.hasTime)
|
||||
const hourInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync time state when value changes
|
||||
React.useEffect(() => {
|
||||
if (showTime) {
|
||||
const time = extractTimeFromValue(props.value)
|
||||
const newParsed = parseTimeComponents(time)
|
||||
setHour(newParsed.hour)
|
||||
setMinute(newParsed.minute)
|
||||
setAmpm(newParsed.ampm)
|
||||
setTimeWasSet(time !== null)
|
||||
}
|
||||
}, [showTime, props.value])
|
||||
|
||||
const initialStart = isRangeMode ? parseDate(props.startDate) : null
|
||||
const initialEnd = isRangeMode ? parseDate(props.endDate) : null
|
||||
const [rangeStart, setRangeStart] = React.useState<Date | null>(initialStart)
|
||||
@@ -566,17 +682,186 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [isRangeMode, selectedDate])
|
||||
|
||||
/**
|
||||
* Gets the current time string in 24h format.
|
||||
*/
|
||||
const getCurrentTimeString = React.useCallback(() => {
|
||||
const h = Number.parseInt(hour) || 12
|
||||
const m = Number.parseInt(minute) || 0
|
||||
return formatStorageTime(h, m, ampm)
|
||||
}, [hour, minute, ampm])
|
||||
|
||||
/**
|
||||
* Handles selection of a specific day in single mode.
|
||||
*/
|
||||
const handleSelectDateSingle = React.useCallback(
|
||||
(day: number) => {
|
||||
if (!isRangeMode && props.onChange) {
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day))
|
||||
setOpen(false)
|
||||
if (showTime && timeWasSet) {
|
||||
// Only include time if it was explicitly set
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day, getCurrentTimeString()))
|
||||
} else {
|
||||
props.onChange(formatDateAsString(viewYear, viewMonth, day))
|
||||
if (!showTime) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isRangeMode, viewYear, viewMonth, props.onChange, setOpen]
|
||||
[
|
||||
isRangeMode,
|
||||
viewYear,
|
||||
viewMonth,
|
||||
props.onChange,
|
||||
setOpen,
|
||||
showTime,
|
||||
getCurrentTimeString,
|
||||
timeWasSet,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles hour input change.
|
||||
*/
|
||||
const handleHourChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
|
||||
setHour(val)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles hour input blur - validates and updates value.
|
||||
*/
|
||||
const handleHourBlur = React.useCallback(() => {
|
||||
const numVal = Number.parseInt(hour) || 12
|
||||
const clamped = Math.min(12, Math.max(1, numVal))
|
||||
setHour(clamped.toString())
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(clamped, Number.parseInt(minute) || 0, ampm)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [hour, minute, ampm, selectedDate, props.onChange, showTime])
|
||||
|
||||
/**
|
||||
* Handles minute input change.
|
||||
*/
|
||||
const handleMinuteChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 2)
|
||||
setMinute(val)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles minute input blur - validates and updates value.
|
||||
*/
|
||||
const handleMinuteBlur = React.useCallback(() => {
|
||||
const numVal = Number.parseInt(minute) || 0
|
||||
const clamped = Math.min(59, Math.max(0, numVal))
|
||||
setMinute(clamped.toString().padStart(2, '0'))
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(Number.parseInt(hour) || 12, clamped, ampm)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [minute, hour, ampm, selectedDate, props.onChange, showTime])
|
||||
|
||||
/**
|
||||
* Handles AM/PM toggle.
|
||||
*/
|
||||
const handleAmpmChange = React.useCallback(
|
||||
(newAmpm: 'AM' | 'PM') => {
|
||||
setAmpm(newAmpm)
|
||||
setTimeWasSet(true)
|
||||
if (selectedDate && props.onChange && showTime) {
|
||||
const timeStr = formatStorageTime(
|
||||
Number.parseInt(hour) || 12,
|
||||
Number.parseInt(minute) || 0,
|
||||
newAmpm
|
||||
)
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
selectedDate.getDate(),
|
||||
timeStr
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
[hour, minute, selectedDate, props.onChange, showTime]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation in hour input (Enter, ArrowUp, ArrowDown).
|
||||
*/
|
||||
const handleHourKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
setOpen(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setHour((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 12
|
||||
const next = num >= 12 ? 1 : num + 1
|
||||
return next.toString()
|
||||
})
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setHour((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 12
|
||||
const next = num <= 1 ? 12 : num - 1
|
||||
return next.toString()
|
||||
})
|
||||
}
|
||||
},
|
||||
[setOpen, timeWasSet]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation in minute input (Enter, ArrowUp, ArrowDown).
|
||||
*/
|
||||
const handleMinuteKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
setOpen(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setMinute((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 0
|
||||
const next = num >= 59 ? 0 : num + 1
|
||||
return next.toString().padStart(2, '0')
|
||||
})
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
setMinute((prev) => {
|
||||
const num = Number.parseInt(prev, 10) || 0
|
||||
const next = num <= 0 ? 59 : num - 1
|
||||
return next.toString().padStart(2, '0')
|
||||
})
|
||||
}
|
||||
},
|
||||
[setOpen, timeWasSet]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -640,16 +925,31 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
|
||||
/**
|
||||
* Selects today's date (single mode only).
|
||||
* Preserves existing time if set, otherwise outputs date only.
|
||||
*/
|
||||
const handleSelectToday = React.useCallback(() => {
|
||||
if (!isRangeMode && props.onChange) {
|
||||
const now = new Date()
|
||||
setViewMonth(now.getMonth())
|
||||
setViewYear(now.getFullYear())
|
||||
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
setOpen(false)
|
||||
if (showTime && timeWasSet) {
|
||||
// Only include time if it was explicitly set
|
||||
props.onChange(
|
||||
formatDateAsString(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
getCurrentTimeString()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
if (!showTime) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isRangeMode, props.onChange, setOpen])
|
||||
}, [isRangeMode, props.onChange, setOpen, showTime, getCurrentTimeString, timeWasSet])
|
||||
|
||||
/**
|
||||
* Applies the selected range (range mode only).
|
||||
@@ -710,9 +1010,11 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
}
|
||||
}, [disabled, open, setOpen])
|
||||
|
||||
// Only show time in display if it was explicitly set
|
||||
const displayTime = showTime && timeWasSet ? getCurrentTimeString() : null
|
||||
const displayValue = isRangeMode
|
||||
? formatDateRangeForDisplay(initialStart, initialEnd)
|
||||
: formatDateForDisplay(selectedDate)
|
||||
: formatDateForDisplay(selectedDate, displayTime)
|
||||
|
||||
const calendarContent = isRangeMode ? (
|
||||
<>
|
||||
@@ -783,12 +1085,81 @@ const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>((props, ref
|
||||
onNextMonth={goToNextMonth}
|
||||
/>
|
||||
|
||||
{/* Today Button */}
|
||||
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
{/* Time Picker (when showTime is enabled) */}
|
||||
{showTime && (
|
||||
<div className='flex items-center justify-center gap-[6px] border-[var(--border-1)] border-t px-[12px] py-[10px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-muted)]'>Time:</span>
|
||||
<input
|
||||
ref={hourInputRef}
|
||||
className={cn(
|
||||
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
|
||||
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
value={hour}
|
||||
onChange={(e) => {
|
||||
handleHourChange(e)
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
}}
|
||||
onBlur={handleHourBlur}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onKeyDown={handleHourKeyDown}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
maxLength={2}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<span className='font-medium text-[13px] text-[var(--text-muted)]'>:</span>
|
||||
<input
|
||||
className={cn(
|
||||
'w-[40px] rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[6px] py-[5px] text-center font-medium font-sans text-[13px] outline-none transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-0',
|
||||
timeWasSet ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]'
|
||||
)}
|
||||
value={minute}
|
||||
onChange={(e) => {
|
||||
handleMinuteChange(e)
|
||||
if (!timeWasSet) setTimeWasSet(true)
|
||||
}}
|
||||
onBlur={handleMinuteBlur}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onKeyDown={handleMinuteKeyDown}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
maxLength={2}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-[2px] flex overflow-hidden rounded-[4px] border border-[var(--border-1)]',
|
||||
!timeWasSet && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{(['AM', 'PM'] as const).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
type='button'
|
||||
onClick={() => handleAmpmChange(period)}
|
||||
className={cn(
|
||||
'px-[8px] py-[5px] font-medium font-sans text-[12px] transition-colors',
|
||||
timeWasSet && ampm === period
|
||||
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
|
||||
: 'bg-[var(--surface-5)] text-[var(--text-secondary)] hover:bg-[var(--surface-7)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
)}
|
||||
>
|
||||
{period}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today Button (only shown when time picker is not enabled) */}
|
||||
{!showTime && (
|
||||
<div className='border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -322,7 +322,8 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
|
||||
// collectBlockData is now called with the current node ID for parallel branch context
|
||||
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
|
||||
})
|
||||
|
||||
it('should handle function_execute tool failure', async () => {
|
||||
@@ -620,4 +621,248 @@ describe('ConditionBlockHandler', () => {
|
||||
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parallel branch handling', () => {
|
||||
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
|
||||
// Simulate a condition block inside a parallel branch
|
||||
// Virtual block ID uses subscript notation: blockId₍branchIndex₎
|
||||
const parallelConditionBlock: SerializedBlock = {
|
||||
id: 'cond-block-1₍0₎', // Virtual ID for branch 0
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
}
|
||||
|
||||
// Source block also has a virtual ID in the same branch
|
||||
const sourceBlockVirtualId = 'agent-block-1₍0₎'
|
||||
|
||||
// Set up workflow with connections using BASE block IDs (as they are in the workflow definition)
|
||||
const parallelWorkflow: SerializedWorkflow = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'agent-block-1',
|
||||
metadata: { id: 'agent', name: 'Agent' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'cond-block-1',
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 100, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'target-block-1',
|
||||
metadata: { id: 'api', name: 'Target' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
// Connections use base IDs, not virtual IDs
|
||||
{ source: 'agent-block-1', target: 'cond-block-1' },
|
||||
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
|
||||
],
|
||||
loops: [],
|
||||
parallels: [],
|
||||
}
|
||||
|
||||
// Block states use virtual IDs (as outputs are stored per-branch)
|
||||
const parallelBlockStates = new Map<string, BlockState>([
|
||||
[
|
||||
sourceBlockVirtualId,
|
||||
{ output: { response: 'hello from branch 0', success: true }, executed: true },
|
||||
],
|
||||
])
|
||||
|
||||
const parallelContext: ExecutionContext = {
|
||||
workflowId: 'test-workflow-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
workflow: parallelWorkflow,
|
||||
blockStates: parallelBlockStates,
|
||||
blockLogs: [],
|
||||
completedBlocks: new Set(),
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
environmentVariables: {},
|
||||
workflowVariables: {},
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
|
||||
|
||||
// The condition should evaluate to true because:
|
||||
// 1. Connection lookup uses base ID 'cond-block-1' (extracted from 'cond-block-1₍0₎')
|
||||
// 2. Source block output is found at virtual ID 'agent-block-1₍0₎' (same branch)
|
||||
// 3. The evaluation context contains { response: 'hello from branch 0' }
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
expect((result as any).selectedPath).toEqual({
|
||||
blockId: 'target-block-1',
|
||||
blockType: 'api',
|
||||
blockTitle: 'Target',
|
||||
})
|
||||
})
|
||||
|
||||
it('should find correct source block output in parallel branch context', async () => {
|
||||
// Test that when multiple branches exist, the correct branch output is used
|
||||
const parallelConditionBlock: SerializedBlock = {
|
||||
id: 'cond-block-1₍1₎', // Virtual ID for branch 1
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
}
|
||||
|
||||
const parallelWorkflow: SerializedWorkflow = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'agent-block-1',
|
||||
metadata: { id: 'agent', name: 'Agent' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'cond-block-1',
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 100, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'target-block-1',
|
||||
metadata: { id: 'api', name: 'Target' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'agent-block-1', target: 'cond-block-1' },
|
||||
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
|
||||
],
|
||||
loops: [],
|
||||
parallels: [],
|
||||
}
|
||||
|
||||
// Multiple branches have executed - each has different output
|
||||
const parallelBlockStates = new Map<string, BlockState>([
|
||||
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
|
||||
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }], // Branch 1 has value 25
|
||||
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
|
||||
])
|
||||
|
||||
const parallelContext: ExecutionContext = {
|
||||
workflowId: 'test-workflow-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
workflow: parallelWorkflow,
|
||||
blockStates: parallelBlockStates,
|
||||
blockLogs: [],
|
||||
completedBlocks: new Set(),
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
environmentVariables: {},
|
||||
workflowVariables: {},
|
||||
}
|
||||
|
||||
// Condition checks if value > 20 - should be true for branch 1 (value=25)
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
|
||||
|
||||
// Should evaluate using branch 1's data (value=25), not branch 0 (value=10) or branch 2 (value=5)
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should fall back to else when condition is false in parallel branch', async () => {
|
||||
const parallelConditionBlock: SerializedBlock = {
|
||||
id: 'cond-block-1₍2₎', // Virtual ID for branch 2
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
}
|
||||
|
||||
const parallelWorkflow: SerializedWorkflow = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'agent-block-1',
|
||||
metadata: { id: 'agent', name: 'Agent' },
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'cond-block-1',
|
||||
metadata: { id: 'condition', name: 'Condition' },
|
||||
position: { x: 100, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'target-true',
|
||||
metadata: { id: 'api', name: 'True Path' },
|
||||
position: { x: 200, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'target-false',
|
||||
metadata: { id: 'api', name: 'False Path' },
|
||||
position: { x: 200, y: 100 },
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'agent-block-1', target: 'cond-block-1' },
|
||||
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
|
||||
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
|
||||
],
|
||||
loops: [],
|
||||
parallels: [],
|
||||
}
|
||||
|
||||
const parallelBlockStates = new Map<string, BlockState>([
|
||||
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
|
||||
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
|
||||
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }], // Branch 2 has value 5
|
||||
])
|
||||
|
||||
const parallelContext: ExecutionContext = {
|
||||
workflowId: 'test-workflow-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
workflow: parallelWorkflow,
|
||||
blockStates: parallelBlockStates,
|
||||
blockLogs: [],
|
||||
completedBlocks: new Set(),
|
||||
decisions: {
|
||||
router: new Map(),
|
||||
condition: new Map(),
|
||||
},
|
||||
environmentVariables: {},
|
||||
workflowVariables: {},
|
||||
}
|
||||
|
||||
// Condition checks if value > 20 - should be false for branch 2 (value=5)
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
|
||||
|
||||
// Should fall back to else path because branch 2's value (5) is not > 20
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
expect((result as any).selectedPath.blockId).toBe('target-false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,12 @@ import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
@@ -18,7 +24,8 @@ const CONDITION_TIMEOUT_MS = 5000
|
||||
export async function evaluateConditionExpression(
|
||||
ctx: ExecutionContext,
|
||||
conditionExpression: string,
|
||||
providedEvalContext?: Record<string, any>
|
||||
providedEvalContext?: Record<string, any>,
|
||||
currentNodeId?: string
|
||||
): Promise<boolean> {
|
||||
const evalContext = providedEvalContext || {}
|
||||
|
||||
@@ -26,7 +33,7 @@ export async function evaluateConditionExpression(
|
||||
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
||||
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
|
||||
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
|
||||
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
@@ -83,7 +90,19 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
): Promise<BlockOutput> {
|
||||
const conditions = this.parseConditions(inputs.conditions)
|
||||
|
||||
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
|
||||
const baseBlockId = extractBaseBlockId(block.id)
|
||||
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
|
||||
|
||||
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
|
||||
let sourceBlockId = sourceConnection?.source
|
||||
|
||||
if (sourceBlockId && branchIndex !== null) {
|
||||
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
|
||||
if (ctx.blockStates.has(virtualSourceId)) {
|
||||
sourceBlockId = virtualSourceId
|
||||
}
|
||||
}
|
||||
|
||||
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
|
||||
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
|
||||
|
||||
@@ -91,13 +110,16 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
// thinking this block is pausing (it was already resumed by the HITL block)
|
||||
const sourceOutput = this.filterPauseMetadata(rawSourceOutput)
|
||||
|
||||
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
|
||||
const outgoingConnections = ctx.workflow?.connections.filter(
|
||||
(conn) => conn.source === baseBlockId
|
||||
)
|
||||
|
||||
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
|
||||
conditions,
|
||||
outgoingConnections || [],
|
||||
evalContext,
|
||||
ctx
|
||||
ctx,
|
||||
block.id
|
||||
)
|
||||
|
||||
if (!selectedConnection || !selectedCondition) {
|
||||
@@ -170,7 +192,8 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
conditions: Array<{ id: string; title: string; value: string }>,
|
||||
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
|
||||
evalContext: Record<string, any>,
|
||||
ctx: ExecutionContext
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId?: string
|
||||
): Promise<{
|
||||
selectedConnection: { target: string; sourceHandle?: string } | null
|
||||
selectedCondition: { id: string; title: string; value: string } | null
|
||||
@@ -189,7 +212,8 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
const conditionMet = await evaluateConditionExpression(
|
||||
ctx,
|
||||
conditionValueString,
|
||||
evalContext
|
||||
evalContext,
|
||||
currentNodeId
|
||||
)
|
||||
|
||||
if (conditionMet) {
|
||||
|
||||
@@ -2,6 +2,11 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
import {
|
||||
extractBaseBlockId,
|
||||
extractBranchIndex,
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
@@ -86,14 +91,30 @@ export function getBlockSchema(
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
|
||||
export function collectBlockData(
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId?: string
|
||||
): BlockDataCollection {
|
||||
const blockData: Record<string, unknown> = {}
|
||||
const blockNameMapping: Record<string, string> = {}
|
||||
const blockOutputSchemas: Record<string, OutputSchema> = {}
|
||||
|
||||
const branchIndex =
|
||||
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
|
||||
|
||||
for (const [id, state] of ctx.blockStates.entries()) {
|
||||
if (state.output !== undefined) {
|
||||
blockData[id] = state.output
|
||||
|
||||
if (branchIndex !== null && isBranchNodeId(id)) {
|
||||
const stateBranchIndex = extractBranchIndex(id)
|
||||
if (stateBranchIndex === branchIndex) {
|
||||
const baseId = extractBaseBlockId(id)
|
||||
if (blockData[baseId] === undefined) {
|
||||
blockData[baseId] = state.output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
309
apps/sim/hooks/queries/invitations.ts
Normal file
309
apps/sim/hooks/queries/invitations.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { workspaceKeys } from './workspace'
|
||||
|
||||
/**
|
||||
* Query key factory for invitation-related queries.
|
||||
* Provides hierarchical cache keys for workspace invitations.
|
||||
*/
|
||||
export const invitationKeys = {
|
||||
all: ['invitations'] as const,
|
||||
lists: () => [...invitationKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
|
||||
}
|
||||
|
||||
/** Raw invitation data from the API. */
|
||||
export interface PendingInvitation {
|
||||
id: string
|
||||
workspaceId: string
|
||||
email: string
|
||||
permissions: 'admin' | 'write' | 'read'
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** Normalized invitation for display in the UI. */
|
||||
export interface WorkspaceInvitation {
|
||||
email: string
|
||||
permissionType: 'admin' | 'write' | 'read'
|
||||
isPendingInvitation: boolean
|
||||
invitationId?: string
|
||||
}
|
||||
|
||||
async function fetchPendingInvitations(workspaceId: string): Promise<WorkspaceInvitation[]> {
|
||||
const response = await fetch('/api/workspaces/invitations')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pending invitations')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return (
|
||||
data.invitations
|
||||
?.filter(
|
||||
(inv: PendingInvitation) => inv.status === 'pending' && inv.workspaceId === workspaceId
|
||||
)
|
||||
.map((inv: PendingInvitation) => ({
|
||||
email: inv.email,
|
||||
permissionType: inv.permissions,
|
||||
isPendingInvitation: true,
|
||||
invitationId: inv.id,
|
||||
})) || []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches pending invitations for a workspace.
|
||||
* @param workspaceId - The workspace ID to fetch invitations for
|
||||
*/
|
||||
export function usePendingInvitations(workspaceId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: invitationKeys.list(workspaceId ?? ''),
|
||||
queryFn: () => fetchPendingInvitations(workspaceId as string),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
interface BatchSendInvitationsParams {
|
||||
workspaceId: string
|
||||
invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }>
|
||||
}
|
||||
|
||||
interface BatchInvitationResult {
|
||||
successful: string[]
|
||||
failed: Array<{ email: string; error: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends multiple workspace invitations in parallel.
|
||||
* Returns results for each invitation indicating success or failure.
|
||||
*/
|
||||
export function useBatchSendWorkspaceInvitations() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
invitations,
|
||||
}: BatchSendInvitationsParams): Promise<BatchInvitationResult> => {
|
||||
const results = await Promise.allSettled(
|
||||
invitations.map(async ({ email, permission }) => {
|
||||
const response = await fetch('/api/workspaces/invitations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
email,
|
||||
role: 'member',
|
||||
permission,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to send invitation')
|
||||
}
|
||||
|
||||
return { email, data: await response.json() }
|
||||
})
|
||||
)
|
||||
|
||||
const successful: string[] = []
|
||||
const failed: Array<{ email: string; error: string }> = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const email = invitations[index].email
|
||||
if (result.status === 'fulfilled') {
|
||||
successful.push(email)
|
||||
} else {
|
||||
failed.push({ email, error: result.reason?.message || 'Unknown error' })
|
||||
}
|
||||
})
|
||||
|
||||
return { successful, failed }
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface CancelInvitationParams {
|
||||
invitationId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a pending workspace invitation.
|
||||
* Invalidates the invitation list cache on success.
|
||||
*/
|
||||
export function useCancelWorkspaceInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
|
||||
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to cancel invitation')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface ResendInvitationParams {
|
||||
invitationId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resends a pending workspace invitation email.
|
||||
* Invalidates the invitation list cache on success.
|
||||
*/
|
||||
export function useResendWorkspaceInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
|
||||
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to resend invitation')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: invitationKeys.list(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface RemoveMemberParams {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a member from a workspace.
|
||||
* Invalidates the workspace permissions cache on success.
|
||||
*/
|
||||
export function useRemoveWorkspaceMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, workspaceId }: RemoveMemberParams) => {
|
||||
const response = await fetch(`/api/workspaces/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface LeaveWorkspaceParams {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the current user to leave a workspace.
|
||||
* Invalidates both permissions and workspace list caches on success.
|
||||
*/
|
||||
export function useLeaveWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, workspaceId }: LeaveWorkspaceParams) => {
|
||||
const response = await fetch(`/api/workspaces/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to leave workspace')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.all,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdatePermissionsParams {
|
||||
workspaceId: string
|
||||
updates: Array<{ userId: string; permissions: 'admin' | 'write' | 'read' }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates permissions for one or more workspace members.
|
||||
* Invalidates the workspace permissions cache on success.
|
||||
*/
|
||||
export function useUpdateWorkspacePermissions() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update permissions')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.permissions(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export const knowledgeKeys = {
|
||||
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
|
||||
tagDefinitions: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagDefinitions'] as const,
|
||||
tagUsage: (knowledgeBaseId: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'tagUsage'] as const,
|
||||
documents: (knowledgeBaseId: string, paramsKey: string) =>
|
||||
[...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const,
|
||||
document: (knowledgeBaseId: string, documentId: string) =>
|
||||
@@ -910,6 +912,38 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
export interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
export async function fetchTagUsage(knowledgeBaseId: string): Promise<TagUsageData[]> {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tag usage: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || 'Failed to fetch tag usage')
|
||||
}
|
||||
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
}
|
||||
|
||||
export function useTagUsageQuery(knowledgeBaseId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''),
|
||||
queryFn: () => fetchTagUsage(knowledgeBaseId as string),
|
||||
enabled: Boolean(knowledgeBaseId),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export interface CreateTagDefinitionParams {
|
||||
knowledgeBaseId: string
|
||||
displayName: string
|
||||
@@ -968,6 +1002,9 @@ export function useCreateTagDefinition() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1006,6 +1043,9 @@ export function useDeleteTagDefinition() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.tagUsage(knowledgeBaseId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const logKeys = {
|
||||
lists: () => [...logKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string | undefined, filters: Omit<LogFilters, 'page'>) =>
|
||||
[...logKeys.lists(), workspaceId ?? '', filters] as const,
|
||||
recent: (workspaceId: string | undefined, limit: number) =>
|
||||
[...logKeys.all, 'recent', workspaceId ?? '', limit] as const,
|
||||
details: () => [...logKeys.all, 'detail'] as const,
|
||||
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
|
||||
stats: (workspaceId: string | undefined, filters: object) =>
|
||||
@@ -248,3 +250,57 @@ export function useExecutionSnapshot(executionId: string | undefined) {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - execution snapshots don't change
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple recent logs data for lightweight use cases (e.g., mention suggestions)
|
||||
*/
|
||||
export interface RecentLog {
|
||||
id: string
|
||||
executionId?: string
|
||||
level: string
|
||||
trigger: string | null
|
||||
createdAt: string
|
||||
workflow?: {
|
||||
name?: string
|
||||
title?: string
|
||||
}
|
||||
workflowName?: string
|
||||
}
|
||||
|
||||
async function fetchRecentLogs(workspaceId: string, limit: number): Promise<RecentLog[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('workspaceId', workspaceId)
|
||||
params.set('limit', limit.toString())
|
||||
params.set('details', 'full')
|
||||
|
||||
const response = await fetch(`/api/logs?${params.toString()}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recent logs')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return Array.isArray(data?.data) ? data.data : []
|
||||
}
|
||||
|
||||
interface UseRecentLogsOptions {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching recent logs with minimal filtering.
|
||||
* Useful for lightweight use cases like mention suggestions.
|
||||
*/
|
||||
export function useRecentLogs(
|
||||
workspaceId: string | undefined,
|
||||
limit = 50,
|
||||
options?: UseRecentLogsOptions
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: logKeys.recent(workspaceId, limit),
|
||||
queryFn: () => fetchRecentLogs(workspaceId as string, limit),
|
||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,27 +6,32 @@ import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
|
||||
const logger = createLogger('OAuthConnectionsQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for OAuth connections
|
||||
* Query key factory for OAuth connection queries.
|
||||
* Provides hierarchical cache keys for connections and provider-specific accounts.
|
||||
*/
|
||||
export const oauthConnectionsKeys = {
|
||||
all: ['oauthConnections'] as const,
|
||||
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
|
||||
accounts: (provider: string) => [...oauthConnectionsKeys.all, 'accounts', provider] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Service info type - extends OAuthServiceConfig with connection status and the service key
|
||||
*/
|
||||
/** OAuth service with connection status and linked accounts. */
|
||||
export interface ServiceInfo extends OAuthServiceConfig {
|
||||
/** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */
|
||||
id: string
|
||||
isConnected: boolean
|
||||
lastConnected?: string
|
||||
accounts?: { id: string; name: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Define available services from standardized OAuth providers
|
||||
*/
|
||||
/** OAuth connection data returned from the API. */
|
||||
interface OAuthConnectionResponse {
|
||||
provider: string
|
||||
baseProvider?: string
|
||||
accounts?: { id: string; name: string }[]
|
||||
lastConnected?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
function defineServices(): ServiceInfo[] {
|
||||
const servicesList: ServiceInfo[] = []
|
||||
|
||||
@@ -44,9 +49,6 @@ function defineServices(): ServiceInfo[] {
|
||||
return servicesList
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch OAuth connections and merge with service definitions
|
||||
*/
|
||||
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const serviceDefinitions = defineServices()
|
||||
@@ -65,7 +67,9 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
|
||||
const connections = data.connections || []
|
||||
|
||||
const updatedServices = serviceDefinitions.map((service) => {
|
||||
const connection = connections.find((conn: any) => conn.provider === service.providerId)
|
||||
const connection = connections.find(
|
||||
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
|
||||
)
|
||||
|
||||
if (connection) {
|
||||
return {
|
||||
@@ -76,13 +80,14 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
const connectionWithScopes = connections.find((conn: any) => {
|
||||
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
|
||||
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (conn.scopes && service.scopes) {
|
||||
return service.scopes.every((scope) => conn.scopes.includes(scope))
|
||||
const connScopes = conn.scopes
|
||||
return service.scopes.every((scope) => connScopes.includes(scope))
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -108,26 +113,28 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch OAuth connections
|
||||
* Fetches all OAuth service connections with their status.
|
||||
* Returns service definitions merged with connection data.
|
||||
*/
|
||||
export function useOAuthConnections() {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.connections(),
|
||||
queryFn: fetchOAuthConnections,
|
||||
staleTime: 30 * 1000, // 30 seconds - connections don't change often
|
||||
retry: false, // Don't retry on 404
|
||||
placeholderData: keepPreviousData, // Show cached data immediately
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect OAuth service mutation
|
||||
*/
|
||||
interface ConnectServiceParams {
|
||||
providerId: string
|
||||
callbackURL: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates OAuth connection flow for a service.
|
||||
* Redirects the user to the provider's authorization page.
|
||||
*/
|
||||
export function useConnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -138,7 +145,6 @@ export function useConnectOAuthService() {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Shopify requires a custom OAuth flow with shop domain input
|
||||
if (providerId === 'shopify') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
|
||||
@@ -161,9 +167,6 @@ export function useConnectOAuthService() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect OAuth service mutation
|
||||
*/
|
||||
interface DisconnectServiceParams {
|
||||
provider: string
|
||||
providerId: string
|
||||
@@ -171,6 +174,10 @@ interface DisconnectServiceParams {
|
||||
accountId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects an OAuth service account.
|
||||
* Performs optimistic update and rolls back on failure.
|
||||
*/
|
||||
export function useDisconnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -230,3 +237,38 @@ export function useDisconnectOAuthService() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Connected OAuth account for a specific provider. */
|
||||
export interface ConnectedAccount {
|
||||
id: string
|
||||
accountId: string
|
||||
providerId: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
async function fetchConnectedAccounts(provider: string): Promise<ConnectedAccount[]> {
|
||||
const response = await fetch(`/api/auth/accounts?provider=${provider}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || `Failed to load ${provider} accounts`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.accounts || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches connected accounts for a specific OAuth provider.
|
||||
* @param provider - The provider ID (e.g., 'slack', 'google')
|
||||
* @param options - Query options including enabled flag
|
||||
*/
|
||||
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.accounts(provider),
|
||||
queryFn: () => fetchConnectedAccounts(provider),
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* Query key factories for workspace-related queries
|
||||
* Query key factory for workspace-related queries.
|
||||
* Provides hierarchical cache keys for workspaces, settings, and permissions.
|
||||
*/
|
||||
export const workspaceKeys = {
|
||||
all: ['workspace'] as const,
|
||||
lists: () => [...workspaceKeys.all, 'list'] as const,
|
||||
list: () => [...workspaceKeys.lists(), 'user'] as const,
|
||||
details: () => [...workspaceKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...workspaceKeys.details(), id] as const,
|
||||
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
|
||||
@@ -13,9 +16,186 @@ export const workspaceKeys = {
|
||||
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
|
||||
}
|
||||
|
||||
/** Represents a workspace in the user's workspace list. */
|
||||
export interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
membershipId?: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
async function fetchWorkspaces(): Promise<Workspace[]> {
|
||||
const response = await fetch('/api/workspaces')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.workspaces || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace settings
|
||||
* Fetches the current user's workspaces.
|
||||
* @param enabled - Whether the query should execute (defaults to true)
|
||||
*/
|
||||
export function useWorkspacesQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.list(),
|
||||
queryFn: fetchWorkspaces,
|
||||
enabled,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
interface CreateWorkspaceParams {
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new workspace.
|
||||
* Automatically invalidates the workspace list cache on success.
|
||||
*/
|
||||
export function useCreateWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name }: CreateWorkspaceParams) => {
|
||||
const response = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to create workspace')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.workspace as Workspace
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface DeleteWorkspaceParams {
|
||||
workspaceId: string
|
||||
deleteTemplates?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a workspace.
|
||||
* Automatically invalidates the workspace list cache on success.
|
||||
*/
|
||||
export function useDeleteWorkspace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, deleteTemplates = false }: DeleteWorkspaceParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deleteTemplates }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete workspace')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface UpdateWorkspaceNameParams {
|
||||
workspaceId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a workspace's name.
|
||||
* Invalidates both the workspace list and the specific workspace detail cache.
|
||||
*/
|
||||
export function useUpdateWorkspaceName() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, name }: UpdateWorkspaceNameParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update workspace name')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Represents a user with permissions in a workspace. */
|
||||
export interface WorkspaceUser {
|
||||
userId: string
|
||||
email: string
|
||||
name: string | null
|
||||
image: string | null
|
||||
permissionType: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
/** Workspace permissions data containing all users and their access levels. */
|
||||
export interface WorkspacePermissions {
|
||||
users: WorkspaceUser[]
|
||||
total: number
|
||||
}
|
||||
|
||||
async function fetchWorkspacePermissions(workspaceId: string): Promise<WorkspacePermissions> {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Workspace not found or access denied')
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches permissions for a specific workspace.
|
||||
* @param workspaceId - The workspace ID to fetch permissions for
|
||||
*/
|
||||
export function useWorkspacePermissionsQuery(workspaceId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
|
||||
queryFn: () => fetchWorkspacePermissions(workspaceId as string),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchWorkspaceSettings(workspaceId: string) {
|
||||
const [settingsResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
@@ -38,7 +218,8 @@ async function fetchWorkspaceSettings(workspaceId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace settings
|
||||
* Fetches workspace settings including permissions.
|
||||
* @param workspaceId - The workspace ID to fetch settings for
|
||||
*/
|
||||
export function useWorkspaceSettings(workspaceId: string) {
|
||||
return useQuery({
|
||||
@@ -50,15 +231,16 @@ export function useWorkspaceSettings(workspaceId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace settings mutation
|
||||
*/
|
||||
interface UpdateWorkspaceSettingsParams {
|
||||
workspaceId: string
|
||||
billedAccountUserId?: string
|
||||
billingAccountUserEmail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates workspace settings (e.g., billing configuration).
|
||||
* Invalidates the workspace settings cache on success.
|
||||
*/
|
||||
export function useUpdateWorkspaceSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -85,9 +267,7 @@ export function useUpdateWorkspaceSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace type returned by admin workspaces query
|
||||
*/
|
||||
/** Workspace with admin access metadata. */
|
||||
export interface AdminWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
@@ -96,9 +276,6 @@ export interface AdminWorkspace {
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspaces where user has admin access
|
||||
*/
|
||||
async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> {
|
||||
if (!userId) {
|
||||
return []
|
||||
@@ -121,7 +298,7 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
|
||||
}
|
||||
const permissionData = await permissionResponse.json()
|
||||
return { workspace, permissionData }
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -161,14 +338,15 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspaces where user has admin access
|
||||
* Fetches workspaces where the user has admin access.
|
||||
* @param userId - The user ID to check admin access for
|
||||
*/
|
||||
export function useAdminWorkspaces(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.adminList(userId),
|
||||
queryFn: () => fetchAdminWorkspaces(userId),
|
||||
enabled: Boolean(userId),
|
||||
staleTime: 60 * 1000, // Cache for 60 seconds
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface SlackAccount {
|
||||
id: string
|
||||
accountId: string
|
||||
providerId: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
interface UseSlackAccountsResult {
|
||||
accounts: SlackAccount[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and manages connected Slack accounts for the current user.
|
||||
* @returns Object containing accounts array, loading state, error state, and refetch function
|
||||
*/
|
||||
export function useSlackAccounts(): UseSlackAccountsResult {
|
||||
const [accounts, setAccounts] = useState<SlackAccount[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch('/api/auth/accounts?provider=slack')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAccounts(data.accounts || [])
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
setError(data.error || 'Failed to load Slack accounts')
|
||||
setAccounts([])
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load Slack accounts')
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
return { accounts, isLoading, error, refetch: fetchAccounts }
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { PermissionType, WorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
|
||||
|
||||
export type PermissionType = 'admin' | 'write' | 'read'
|
||||
|
||||
const logger = createLogger('useUserPermissions')
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { permissionTypeEnum } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
|
||||
const logger = createLogger('useWorkspacePermissions')
|
||||
|
||||
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
export interface WorkspaceUser {
|
||||
userId: string
|
||||
email: string
|
||||
name: string | null
|
||||
image: string | null
|
||||
permissionType: PermissionType
|
||||
}
|
||||
|
||||
export interface WorkspacePermissions {
|
||||
users: WorkspaceUser[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface UseWorkspacePermissionsReturn {
|
||||
permissions: WorkspacePermissions | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
updatePermissions: (newPermissions: WorkspacePermissions) => void
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and manage workspace permissions
|
||||
*
|
||||
* @param workspaceId - The workspace ID to fetch permissions for
|
||||
* @returns Object containing permissions data, loading state, error state, and refetch function
|
||||
*/
|
||||
export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn {
|
||||
const [permissions, setPermissions] = useState<WorkspacePermissions | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchPermissions = async (id: string): Promise<void> => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(id))
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Workspace not found or access denied')
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: WorkspacePermissions = await response.json()
|
||||
setPermissions(data)
|
||||
|
||||
logger.info('Workspace permissions loaded', {
|
||||
workspaceId: id,
|
||||
userCount: data.total,
|
||||
users: data.users.map((u) => ({ email: u.email, permissions: u.permissionType })),
|
||||
})
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
logger.error('Failed to fetch workspace permissions', {
|
||||
workspaceId: id,
|
||||
error: errorMessage,
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePermissions = useCallback((newPermissions: WorkspacePermissions): void => {
|
||||
setPermissions(newPermissions)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchPermissions(workspaceId)
|
||||
} else {
|
||||
// Clear state if no workspace ID
|
||||
setPermissions(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
if (workspaceId) {
|
||||
await fetchPermissions(workspaceId)
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
return {
|
||||
permissions,
|
||||
loading,
|
||||
error,
|
||||
updatePermissions,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
ensureOrganizationForTeamSubscription,
|
||||
syncSubscriptionUsageLimits,
|
||||
} from '@/lib/billing/organization'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
|
||||
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
|
||||
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
|
||||
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
|
||||
@@ -2641,29 +2641,42 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
stripeSubscription,
|
||||
subscription,
|
||||
}: {
|
||||
event: Stripe.Event
|
||||
stripeSubscription: Stripe.Subscription
|
||||
subscription: any
|
||||
}) => {
|
||||
const { priceId, planFromStripe, isTeamPlan } =
|
||||
resolvePlanFromStripeSubscription(stripeSubscription)
|
||||
|
||||
logger.info('[onSubscriptionComplete] Subscription created', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
plan: subscription.plan,
|
||||
dbPlan: subscription.plan,
|
||||
planFromStripe,
|
||||
priceId,
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
const subscriptionForOrgCreation = isTeamPlan
|
||||
? { ...subscription, plan: 'team' }
|
||||
: subscription
|
||||
|
||||
let resolvedSubscription = subscription
|
||||
try {
|
||||
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
|
||||
resolvedSubscription = await ensureOrganizationForTeamSubscription(
|
||||
subscriptionForOrgCreation
|
||||
)
|
||||
} catch (orgError) {
|
||||
logger.error(
|
||||
'[onSubscriptionComplete] Failed to ensure organization for team subscription',
|
||||
{
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
plan: subscription.plan,
|
||||
dbPlan: subscription.plan,
|
||||
planFromStripe,
|
||||
error: orgError instanceof Error ? orgError.message : String(orgError),
|
||||
stack: orgError instanceof Error ? orgError.stack : undefined,
|
||||
}
|
||||
@@ -2684,22 +2697,67 @@ export const auth = betterAuth({
|
||||
event: Stripe.Event
|
||||
subscription: any
|
||||
}) => {
|
||||
const stripeSubscription = event.data.object as Stripe.Subscription
|
||||
const { priceId, planFromStripe, isTeamPlan } =
|
||||
resolvePlanFromStripeSubscription(stripeSubscription)
|
||||
|
||||
if (priceId && !planFromStripe) {
|
||||
logger.warn(
|
||||
'[onSubscriptionUpdate] Could not determine plan from Stripe price ID',
|
||||
{
|
||||
subscriptionId: subscription.id,
|
||||
priceId,
|
||||
dbPlan: subscription.plan,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const isUpgradeToTeam =
|
||||
isTeamPlan &&
|
||||
subscription.plan !== 'team' &&
|
||||
!subscription.referenceId.startsWith('org_')
|
||||
|
||||
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
|
||||
|
||||
logger.info('[onSubscriptionUpdate] Subscription updated', {
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
plan: subscription.plan,
|
||||
dbPlan: subscription.plan,
|
||||
planFromStripe,
|
||||
isUpgradeToTeam,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const subscriptionForOrgCreation = isUpgradeToTeam
|
||||
? { ...subscription, plan: 'team' }
|
||||
: subscription
|
||||
|
||||
let resolvedSubscription = subscription
|
||||
try {
|
||||
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
|
||||
resolvedSubscription = await ensureOrganizationForTeamSubscription(
|
||||
subscriptionForOrgCreation
|
||||
)
|
||||
|
||||
if (isUpgradeToTeam) {
|
||||
logger.info(
|
||||
'[onSubscriptionUpdate] Detected Pro -> Team upgrade, ensured organization creation',
|
||||
{
|
||||
subscriptionId: subscription.id,
|
||||
originalPlan: subscription.plan,
|
||||
newPlan: planFromStripe,
|
||||
resolvedReferenceId: resolvedSubscription.referenceId,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (orgError) {
|
||||
logger.error(
|
||||
'[onSubscriptionUpdate] Failed to ensure organization for team subscription',
|
||||
{
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
plan: subscription.plan,
|
||||
dbPlan: subscription.plan,
|
||||
planFromStripe,
|
||||
isUpgradeToTeam,
|
||||
error: orgError instanceof Error ? orgError.message : String(orgError),
|
||||
stack: orgError instanceof Error ? orgError.stack : undefined,
|
||||
}
|
||||
@@ -2717,9 +2775,8 @@ export const auth = betterAuth({
|
||||
})
|
||||
}
|
||||
|
||||
if (resolvedSubscription.plan === 'team') {
|
||||
if (effectivePlanForTeamFeatures === 'team') {
|
||||
try {
|
||||
const stripeSubscription = event.data.object as Stripe.Subscription
|
||||
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
|
||||
|
||||
const result = await syncSeatsFromStripeQuantity(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
getFreeTierLimit,
|
||||
getProTierLimit,
|
||||
@@ -56,6 +57,13 @@ export function getPlanByName(planName: string): BillingPlan | undefined {
|
||||
return getPlans().find((plan) => plan.name === planName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific plan by Stripe price ID
|
||||
*/
|
||||
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
|
||||
return getPlans().find((plan) => plan.priceId === priceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan limits for a given plan name
|
||||
*/
|
||||
@@ -63,3 +71,26 @@ export function getPlanLimits(planName: string): number {
|
||||
const plan = getPlanByName(planName)
|
||||
return plan?.limits.cost ?? getFreeTierLimit()
|
||||
}
|
||||
|
||||
export interface StripePlanResolution {
|
||||
priceId: string | undefined
|
||||
planFromStripe: string | null
|
||||
isTeamPlan: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve plan information from a Stripe subscription object.
|
||||
* Used to get the authoritative plan from Stripe rather than relying on DB state.
|
||||
*/
|
||||
export function resolvePlanFromStripeSubscription(
|
||||
stripeSubscription: Stripe.Subscription
|
||||
): StripePlanResolution {
|
||||
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
|
||||
const plan = priceId ? getPlanByPriceId(priceId) : undefined
|
||||
|
||||
return {
|
||||
priceId,
|
||||
planFromStripe: plan?.name ?? null,
|
||||
isTeamPlan: plan?.name === 'team',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function getPlaceholderForFieldType(fieldType: string): string {
|
||||
case 'number':
|
||||
return 'Enter number'
|
||||
case 'date':
|
||||
return 'YYYY-MM-DD'
|
||||
return 'YYYY-MM-DD or YYYY-MM-DD HH:mm'
|
||||
default:
|
||||
return 'Enter value'
|
||||
}
|
||||
|
||||
187
apps/sim/lib/knowledge/tags/utils.test.ts
Normal file
187
apps/sim/lib/knowledge/tags/utils.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for knowledge tag validation utility functions
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseBooleanValue, parseDateValue, parseNumberValue, validateTagValue } from './utils'
|
||||
|
||||
describe('Knowledge Tag Utils', () => {
|
||||
describe('validateTagValue', () => {
|
||||
describe('boolean validation', () => {
|
||||
it('should accept "true" as valid boolean', () => {
|
||||
expect(validateTagValue('isActive', 'true', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept "false" as valid boolean', () => {
|
||||
expect(validateTagValue('isActive', 'false', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept case-insensitive boolean values', () => {
|
||||
expect(validateTagValue('isActive', 'TRUE', 'boolean')).toBeNull()
|
||||
expect(validateTagValue('isActive', 'FALSE', 'boolean')).toBeNull()
|
||||
expect(validateTagValue('isActive', 'True', 'boolean')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject invalid boolean values', () => {
|
||||
const result = validateTagValue('isActive', 'yes', 'boolean')
|
||||
expect(result).toContain('expects a boolean value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('number validation', () => {
|
||||
it('should accept valid integers', () => {
|
||||
expect(validateTagValue('count', '42', 'number')).toBeNull()
|
||||
expect(validateTagValue('count', '-10', 'number')).toBeNull()
|
||||
expect(validateTagValue('count', '0', 'number')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid decimals', () => {
|
||||
expect(validateTagValue('price', '19.99', 'number')).toBeNull()
|
||||
expect(validateTagValue('price', '-3.14', 'number')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject non-numeric values', () => {
|
||||
const result = validateTagValue('count', 'abc', 'number')
|
||||
expect(result).toContain('expects a number value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('date validation', () => {
|
||||
it('should accept valid YYYY-MM-DD format', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-12-31', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp without timezone', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T00:00:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T23:59:59', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with seconds omitted', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with UTC timezone', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00Z', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with timezone offset', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00+05:00', 'date')).toBeNull()
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00-08:00', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should accept valid ISO 8601 timestamp with milliseconds', () => {
|
||||
expect(validateTagValue('createdAt', '2024-01-15T14:30:00.123Z', 'date')).toBeNull()
|
||||
})
|
||||
|
||||
it('should reject invalid date format', () => {
|
||||
const result = validateTagValue('createdAt', '01/15/2024', 'date')
|
||||
expect(result).toContain('expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format')
|
||||
})
|
||||
|
||||
it('should reject invalid date values like Feb 31', () => {
|
||||
const result = validateTagValue('createdAt', '2024-02-31', 'date')
|
||||
expect(result).toContain('invalid date')
|
||||
})
|
||||
|
||||
it('should reject invalid time values', () => {
|
||||
const result = validateTagValue('createdAt', '2024-01-15T25:00:00', 'date')
|
||||
expect(result).toContain('invalid time')
|
||||
})
|
||||
|
||||
it('should reject invalid minute values', () => {
|
||||
const result = validateTagValue('createdAt', '2024-01-15T12:61:00', 'date')
|
||||
expect(result).toContain('invalid time')
|
||||
})
|
||||
})
|
||||
|
||||
describe('text/default validation', () => {
|
||||
it('should accept any string for text type', () => {
|
||||
expect(validateTagValue('name', 'anything goes', 'text')).toBeNull()
|
||||
expect(validateTagValue('name', '123', 'text')).toBeNull()
|
||||
expect(validateTagValue('name', '', 'text')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDateValue', () => {
|
||||
it('should parse valid YYYY-MM-DD format', () => {
|
||||
const result = parseDateValue('2024-01-15')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
expect(result?.getMonth()).toBe(0) // January is 0
|
||||
expect(result?.getDate()).toBe(15)
|
||||
})
|
||||
|
||||
it('should parse valid ISO 8601 timestamp', () => {
|
||||
const result = parseDateValue('2024-01-15T14:30:00')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
expect(result?.getMonth()).toBe(0)
|
||||
expect(result?.getDate()).toBe(15)
|
||||
expect(result?.getHours()).toBe(14)
|
||||
expect(result?.getMinutes()).toBe(30)
|
||||
})
|
||||
|
||||
it('should parse valid ISO 8601 timestamp with UTC timezone', () => {
|
||||
const result = parseDateValue('2024-01-15T14:30:00Z')
|
||||
expect(result).toBeInstanceOf(Date)
|
||||
expect(result?.getFullYear()).toBe(2024)
|
||||
})
|
||||
|
||||
it('should return null for invalid format', () => {
|
||||
expect(parseDateValue('01/15/2024')).toBeNull()
|
||||
expect(parseDateValue('invalid')).toBeNull()
|
||||
expect(parseDateValue('')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid date values', () => {
|
||||
expect(parseDateValue('2024-02-31')).toBeNull() // Feb 31 doesn't exist
|
||||
expect(parseDateValue('2024-13-01')).toBeNull() // Month 13 doesn't exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNumberValue', () => {
|
||||
it('should parse valid integers', () => {
|
||||
expect(parseNumberValue('42')).toBe(42)
|
||||
expect(parseNumberValue('-10')).toBe(-10)
|
||||
expect(parseNumberValue('0')).toBe(0)
|
||||
})
|
||||
|
||||
it('should parse valid decimals', () => {
|
||||
expect(parseNumberValue('19.99')).toBe(19.99)
|
||||
expect(parseNumberValue('-3.14')).toBeCloseTo(-3.14)
|
||||
})
|
||||
|
||||
it('should return null for non-numeric strings', () => {
|
||||
expect(parseNumberValue('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('should return 0 for empty string (JavaScript Number behavior)', () => {
|
||||
expect(parseNumberValue('')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBooleanValue', () => {
|
||||
it('should parse "true" to true', () => {
|
||||
expect(parseBooleanValue('true')).toBe(true)
|
||||
expect(parseBooleanValue('TRUE')).toBe(true)
|
||||
expect(parseBooleanValue(' true ')).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse "false" to false', () => {
|
||||
expect(parseBooleanValue('false')).toBe(false)
|
||||
expect(parseBooleanValue('FALSE')).toBe(false)
|
||||
expect(parseBooleanValue(' false ')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return null for invalid values', () => {
|
||||
expect(parseBooleanValue('yes')).toBeNull()
|
||||
expect(parseBooleanValue('no')).toBeNull()
|
||||
expect(parseBooleanValue('1')).toBeNull()
|
||||
expect(parseBooleanValue('')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,14 @@
|
||||
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
||||
const ISO_WITH_TZ_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/
|
||||
|
||||
/**
|
||||
* Check if a string is a valid date format (YYYY-MM-DD or ISO 8601 timestamp)
|
||||
*/
|
||||
function isValidDateFormat(value: string): boolean {
|
||||
return DATE_ONLY_REGEX.test(value) || DATETIME_REGEX.test(value) || ISO_WITH_TZ_REGEX.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a tag value against its expected field type
|
||||
* Returns an error message if invalid, or null if valid
|
||||
@@ -21,16 +32,35 @@ export function validateTagValue(tagName: string, value: string, fieldType: stri
|
||||
return null
|
||||
}
|
||||
case 'date': {
|
||||
// Check format first
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
|
||||
// Check format first - accept YYYY-MM-DD or ISO 8601 datetime
|
||||
if (!isValidDateFormat(stringValue)) {
|
||||
return `Tag "${tagName}" expects a date in YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss format, but received "${value}"`
|
||||
}
|
||||
|
||||
// Extract date parts for validation
|
||||
const datePart = stringValue.split('T')[0]
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
|
||||
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||
const [year, month, day] = stringValue.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
return `Tag "${tagName}" has an invalid date: "${value}"`
|
||||
}
|
||||
|
||||
// If timestamp is included, validate time components
|
||||
if (stringValue.includes('T')) {
|
||||
const timePart = stringValue.split('T')[1]
|
||||
// Extract hours and minutes, ignoring timezone
|
||||
const timeMatch = timePart.match(/^(\d{2}):(\d{2})/)
|
||||
if (timeMatch) {
|
||||
const hours = Number.parseInt(timeMatch[1], 10)
|
||||
const minutes = Number.parseInt(timeMatch[2], 10)
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return `Tag "${tagName}" has an invalid time: "${value}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
default:
|
||||
@@ -56,25 +86,44 @@ export function parseNumberValue(value: string): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string to Date with strict YYYY-MM-DD validation
|
||||
* Parse a string to Date with validation for YYYY-MM-DD or ISO 8601 timestamp
|
||||
* Returns null if invalid format or invalid date
|
||||
*/
|
||||
export function parseDateValue(value: string): Date | null {
|
||||
const stringValue = String(value).trim()
|
||||
|
||||
// Must be YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||
// Must be valid date format
|
||||
if (!isValidDateFormat(stringValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract date parts
|
||||
const datePart = stringValue.split('T')[0]
|
||||
const [year, month, day] = datePart.split('-').map(Number)
|
||||
|
||||
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||
const [year, month, day] = stringValue.split('-').map(Number)
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||
// First check date-only validity
|
||||
const testDate = new Date(year, month - 1, day)
|
||||
if (
|
||||
testDate.getFullYear() !== year ||
|
||||
testDate.getMonth() !== month - 1 ||
|
||||
testDate.getDate() !== day
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return date
|
||||
// If timestamp is included, parse with time
|
||||
if (stringValue.includes('T')) {
|
||||
// Use native Date parsing for ISO strings
|
||||
const date = new Date(stringValue)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
// Date-only: return date at midnight local time
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ export function sanitizeHeaders(
|
||||
* Client-safe MCP constants
|
||||
*/
|
||||
export const MCP_CLIENT_CONSTANTS = {
|
||||
CLIENT_TIMEOUT: 60000,
|
||||
CLIENT_TIMEOUT: 600000,
|
||||
MAX_RETRIES: 3,
|
||||
RECONNECT_DELAY: 1000,
|
||||
} as const
|
||||
|
||||
@@ -81,8 +81,8 @@ describe('generateMcpServerId', () => {
|
||||
})
|
||||
|
||||
describe('MCP_CONSTANTS', () => {
|
||||
it.concurrent('has correct execution timeout', () => {
|
||||
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
|
||||
it.concurrent('has correct execution timeout (10 minutes)', () => {
|
||||
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(600000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct cache timeout (5 minutes)', () => {
|
||||
@@ -107,8 +107,8 @@ describe('MCP_CONSTANTS', () => {
|
||||
})
|
||||
|
||||
describe('MCP_CLIENT_CONSTANTS', () => {
|
||||
it.concurrent('has correct client timeout', () => {
|
||||
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
|
||||
it.concurrent('has correct client timeout (10 minutes)', () => {
|
||||
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(600000)
|
||||
})
|
||||
|
||||
it.concurrent('has correct auto refresh interval (5 minutes)', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isMcpTool, MCP } from '@/executor/constants'
|
||||
* MCP-specific constants
|
||||
*/
|
||||
export const MCP_CONSTANTS = {
|
||||
EXECUTION_TIMEOUT: 60000,
|
||||
EXECUTION_TIMEOUT: 600000,
|
||||
CACHE_TIMEOUT: 5 * 60 * 1000,
|
||||
DEFAULT_RETRIES: 3,
|
||||
DEFAULT_CONNECTION_TIMEOUT: 30000,
|
||||
@@ -49,7 +49,7 @@ export function sanitizeHeaders(
|
||||
* Client-safe MCP constants
|
||||
*/
|
||||
export const MCP_CLIENT_CONSTANTS = {
|
||||
CLIENT_TIMEOUT: 60000,
|
||||
CLIENT_TIMEOUT: 600000,
|
||||
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
|
||||
id: 'a2a_cancel_task',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
A2ADeletePushNotificationParams,
|
||||
A2ADeletePushNotificationResponse,
|
||||
} from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aDeletePushNotificationTool: ToolConfig<
|
||||
A2ADeletePushNotificationParams,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
|
||||
id: 'a2a_get_agent_card',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
A2AGetPushNotificationParams,
|
||||
A2AGetPushNotificationResponse,
|
||||
} from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aGetPushNotificationTool: ToolConfig<
|
||||
A2AGetPushNotificationParams,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { A2AGetTaskParams, A2AGetTaskResponse } from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
|
||||
id: 'a2a_get_task',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { A2AResubscribeParams, A2AResubscribeResponse } from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
|
||||
id: 'a2a_resubscribe',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { A2ASendMessageParams, A2ASendMessageResponse } from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
|
||||
id: 'a2a_send_message',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
A2ASetPushNotificationParams,
|
||||
A2ASetPushNotificationResponse,
|
||||
} from '@/tools/a2a/types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
|
||||
import { A2A_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export const a2aSetPushNotificationTool: ToolConfig<
|
||||
A2ASetPushNotificationParams,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { RunActorParams, RunActorResult } from './types'
|
||||
|
||||
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
|
||||
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { RunActorParams, RunActorResult } from './types'
|
||||
|
||||
export const apifyRunActorSyncTool: ToolConfig<RunActorParams, RunActorResult> = {
|
||||
id: 'apify_run_actor_sync',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsAddAliasParams,
|
||||
GoogleGroupsAddAliasResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse } from './types'
|
||||
|
||||
export const addAliasTool: ToolConfig<GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse> = {
|
||||
id: 'google_groups_add_alias',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const addMemberTool: ToolConfig<GoogleGroupsAddMemberParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_add_member',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const createGroupTool: ToolConfig<GoogleGroupsCreateParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_create_group',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const deleteGroupTool: ToolConfig<GoogleGroupsDeleteParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_delete_group',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const getGroupTool: ToolConfig<GoogleGroupsGetParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_get_group',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const getMemberTool: ToolConfig<GoogleGroupsGetMemberParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_get_member',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsGetSettingsParams,
|
||||
GoogleGroupsGetSettingsResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsGetSettingsParams, GoogleGroupsGetSettingsResponse } from './types'
|
||||
|
||||
export const getSettingsTool: ToolConfig<
|
||||
GoogleGroupsGetSettingsParams,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const hasMemberTool: ToolConfig<GoogleGroupsHasMemberParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_has_member',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsListAliasesParams,
|
||||
GoogleGroupsListAliasesResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsListAliasesParams, GoogleGroupsListAliasesResponse } from './types'
|
||||
|
||||
export const listAliasesTool: ToolConfig<
|
||||
GoogleGroupsListAliasesParams,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsListParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsListParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const listGroupsTool: ToolConfig<GoogleGroupsListParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_list_groups',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsListMembersParams,
|
||||
GoogleGroupsResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsListMembersParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const listMembersTool: ToolConfig<GoogleGroupsListMembersParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_list_members',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsRemoveAliasParams,
|
||||
GoogleGroupsRemoveAliasResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsRemoveAliasParams, GoogleGroupsRemoveAliasResponse } from './types'
|
||||
|
||||
export const removeAliasTool: ToolConfig<
|
||||
GoogleGroupsRemoveAliasParams,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsRemoveMemberParams,
|
||||
GoogleGroupsResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsRemoveMemberParams, GoogleGroupsResponse } from './types'
|
||||
|
||||
export const removeMemberTool: ToolConfig<GoogleGroupsRemoveMemberParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_remove_member',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from './types'
|
||||
|
||||
export const updateGroupTool: ToolConfig<GoogleGroupsUpdateParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_update_group',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsResponse,
|
||||
GoogleGroupsUpdateMemberParams,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsResponse, GoogleGroupsUpdateMemberParams } from './types'
|
||||
|
||||
export const updateMemberTool: ToolConfig<GoogleGroupsUpdateMemberParams, GoogleGroupsResponse> = {
|
||||
id: 'google_groups_update_member',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GoogleGroupsUpdateSettingsParams,
|
||||
GoogleGroupsUpdateSettingsResponse,
|
||||
} from '@/tools/google_groups/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { GoogleGroupsUpdateSettingsParams, GoogleGroupsUpdateSettingsResponse } from './types'
|
||||
|
||||
export const updateSettingsTool: ToolConfig<
|
||||
GoogleGroupsUpdateSettingsParams,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { RequestResponse, WebhookRequestParams } from './types'
|
||||
|
||||
/**
|
||||
* Generates HMAC-SHA256 signature for webhook payload
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type {
|
||||
IncidentioIncidentStatusesListParams,
|
||||
IncidentioIncidentStatusesListResponse,
|
||||
} from './types'
|
||||
} from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const incidentStatusesListTool: ToolConfig<
|
||||
IncidentioIncidentStatusesListParams,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type {
|
||||
IncidentioIncidentTypesListParams,
|
||||
IncidentioIncidentTypesListResponse,
|
||||
} from './types'
|
||||
} from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const incidentTypesListTool: ToolConfig<
|
||||
IncidentioIncidentTypesListParams,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
IncidentioSeveritiesListParams,
|
||||
IncidentioSeveritiesListResponse,
|
||||
} from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { IncidentioSeveritiesListParams, IncidentioSeveritiesListResponse } from './types'
|
||||
|
||||
export const severitiesListTool: ToolConfig<
|
||||
IncidentioSeveritiesListParams,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
IncidentioUsersListParams,
|
||||
IncidentioUsersListResponse,
|
||||
} from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { IncidentioUsersListParams, IncidentioUsersListResponse } from './types'
|
||||
|
||||
export const usersListTool: ToolConfig<IncidentioUsersListParams, IncidentioUsersListResponse> = {
|
||||
id: 'incidentio_users_list',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
IncidentioUsersShowParams,
|
||||
IncidentioUsersShowResponse,
|
||||
} from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { IncidentioUsersShowParams, IncidentioUsersShowResponse } from './types'
|
||||
|
||||
export const usersShowTool: ToolConfig<IncidentioUsersShowParams, IncidentioUsersShowResponse> = {
|
||||
id: 'incidentio_users_show',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from './types'
|
||||
|
||||
export const workflowsCreateTool: ToolConfig<WorkflowsCreateParams, WorkflowsCreateResponse> = {
|
||||
id: 'incidentio_workflows_create',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from './types'
|
||||
|
||||
export const workflowsDeleteTool: ToolConfig<WorkflowsDeleteParams, WorkflowsDeleteResponse> = {
|
||||
id: 'incidentio_workflows_delete',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowsListParams, WorkflowsListResponse } from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { WorkflowsListParams, WorkflowsListResponse } from './types'
|
||||
|
||||
export const workflowsListTool: ToolConfig<WorkflowsListParams, WorkflowsListResponse> = {
|
||||
id: 'incidentio_workflows_list',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowsShowParams, WorkflowsShowResponse } from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { WorkflowsShowParams, WorkflowsShowResponse } from './types'
|
||||
|
||||
export const workflowsShowTool: ToolConfig<WorkflowsShowParams, WorkflowsShowResponse> = {
|
||||
id: 'incidentio_workflows_show',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from '@/tools/incidentio/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from './types'
|
||||
|
||||
export const workflowsUpdateTool: ToolConfig<WorkflowsUpdateParams, WorkflowsUpdateResponse> = {
|
||||
id: 'incidentio_workflows_update',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomGetCompany')
|
||||
|
||||
export interface IntercomGetCompanyParams {
|
||||
accessToken: string
|
||||
companyId: string
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomGetConversation')
|
||||
|
||||
export interface IntercomGetConversationParams {
|
||||
accessToken: string
|
||||
conversationId: string
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomListCompanies')
|
||||
|
||||
export interface IntercomListCompaniesParams {
|
||||
accessToken: string
|
||||
per_page?: number
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomListContacts')
|
||||
|
||||
export interface IntercomListContactsParams {
|
||||
accessToken: string
|
||||
per_page?: number
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomListConversations')
|
||||
|
||||
export interface IntercomListConversationsParams {
|
||||
accessToken: string
|
||||
per_page?: number
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomReplyConversation')
|
||||
|
||||
export interface IntercomReplyConversationParams {
|
||||
accessToken: string
|
||||
conversationId: string
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('IntercomSearchContacts')
|
||||
|
||||
export interface IntercomSearchContactsParams {
|
||||
accessToken: string
|
||||
query: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiAuthParams, KalshiOrder } from './types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiAmendOrderParams extends KalshiAuthParams {
|
||||
orderId: string // Order ID to amend (required)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiAuthParams, KalshiOrder } from './types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiCancelOrderParams extends KalshiAuthParams {
|
||||
orderId: string // Order ID to cancel (required)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiAuthParams, KalshiOrder } from './types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiCreateOrderParams extends KalshiAuthParams {
|
||||
ticker: string // Market ticker (required)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiAuthParams } from '@/tools/kalshi/types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiAuthParams } from './types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetBalanceParams extends KalshiAuthParams {}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiCandlestick } from '@/tools/kalshi/types'
|
||||
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiCandlestick } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetCandlesticksParams {
|
||||
seriesTicker: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiEvent } from '@/tools/kalshi/types'
|
||||
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiEvent } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetEventParams {
|
||||
eventTicker: string // Event ticker
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
|
||||
import {
|
||||
buildKalshiUrl,
|
||||
handleKalshiError,
|
||||
KALSHI_EVENT_OUTPUT_PROPERTIES,
|
||||
} from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiEvent, KalshiPaginationParams, KalshiPagingInfo } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError, KALSHI_EVENT_OUTPUT_PROPERTIES } from './types'
|
||||
|
||||
export interface KalshiGetEventsParams extends KalshiPaginationParams {
|
||||
status?: string // open, closed, settled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiExchangeStatus } from '@/tools/kalshi/types'
|
||||
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiExchangeStatus } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export type KalshiGetExchangeStatusParams = Record<string, never>
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type {
|
||||
KalshiAuthParams,
|
||||
KalshiFill,
|
||||
KalshiPaginationParams,
|
||||
KalshiPagingInfo,
|
||||
} from './types'
|
||||
} from '@/tools/kalshi/types'
|
||||
import {
|
||||
buildKalshiAuthHeaders,
|
||||
buildKalshiUrl,
|
||||
handleKalshiError,
|
||||
KALSHI_FILL_OUTPUT_PROPERTIES,
|
||||
} from './types'
|
||||
} from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export interface KalshiGetFillsParams extends KalshiAuthParams, KalshiPaginationParams {
|
||||
ticker?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiMarket } from '@/tools/kalshi/types'
|
||||
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiMarket } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetMarketParams {
|
||||
ticker: string // Market ticker
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from './types'
|
||||
import type { KalshiMarket, KalshiPaginationParams, KalshiPagingInfo } from '@/tools/kalshi/types'
|
||||
import {
|
||||
buildKalshiUrl,
|
||||
handleKalshiError,
|
||||
KALSHI_MARKET_OUTPUT_PROPERTIES,
|
||||
KALSHI_PAGING_OUTPUT_PROPERTIES,
|
||||
} from './types'
|
||||
} from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export interface KalshiGetMarketsParams extends KalshiPaginationParams {
|
||||
status?: string // unopened, open, closed, settled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiAuthParams, KalshiOrder } from '@/tools/kalshi/types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiAuthParams, KalshiOrder } from './types'
|
||||
import { buildKalshiAuthHeaders, buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetOrderParams extends KalshiAuthParams {
|
||||
orderId: string // Order ID to retrieve (required)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KalshiOrderbook } from '@/tools/kalshi/types'
|
||||
import { buildKalshiUrl, handleKalshiError } from '@/tools/kalshi/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { KalshiOrderbook } from './types'
|
||||
import { buildKalshiUrl, handleKalshiError } from './types'
|
||||
|
||||
export interface KalshiGetOrderbookParams {
|
||||
ticker: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user