fix(dialogs): standardized delete modals (#2049)

* standardized delete modals

* fix

* fix(ui): live usage indicator, child trace spans, cancel subscription modal z-index (#2044)

* cleanup

* show trace spans for child blocks that error

* fix z index for cancel subscription popup

* rotating digit live usage indicator

* fix

* remove unused code

* fix type

* fix(billing): fix team upgrade

* fix

* fix tests

---------

Co-authored-by: waleed <walif6@gmail.com>

* remove unused barrel exports

* remove unused components

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
This commit is contained in:
Waleed
2025-11-18 21:17:06 -08:00
committed by GitHub
parent 5e11e5df91
commit 7045c4a47b
69 changed files with 1181 additions and 5980 deletions

View File

@@ -2,20 +2,8 @@
import { useRef, useState } from 'react'
import { AlertCircle, Loader2, X } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button, Modal, ModalContent, ModalTitle, Textarea } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
@@ -123,109 +111,135 @@ export function CreateChunkModal({
return (
<>
<Dialog open={open} onOpenChange={handleCloseAttempt}>
<DialogContent
<Modal open={open} onOpenChange={handleCloseAttempt}>
<ModalContent
className='flex h-[74vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
showClose={false}
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Create Chunk</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={handleCloseAttempt}
>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Create Chunk
</ModalTitle>
<Button variant='ghost' className='h-8 w-8 p-0' onClick={handleCloseAttempt}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
</div>
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex min-h-full flex-col py-4'>
{/* Document Info Section - Fixed at top */}
<div className='flex-shrink-0 space-y-4'>
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>Adding chunk to this document</p>
{/* Modal Body */}
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<form className='flex min-h-0 flex-1 flex-col'>
{/* Scrollable Content */}
<div className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'>
<div className='flex min-h-full flex-col px-6'>
<div className='flex flex-1 flex-col space-y-[12px] pt-0 pb-6'>
{/* Document Info Section */}
<div className='flex-shrink-0 space-y-[8px]'>
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>
Adding chunk to this document
</p>
</div>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
)}
</div>
{/* Content Input Section - Expands to fill space */}
<div className='flex min-h-0 flex-1 flex-col space-y-[8px]'>
<Label
htmlFor='content'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Chunk Content
</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
className='min-h-0 flex-1 resize-none'
disabled={isCreating}
/>
</div>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
)}
</div>
</div>
{/* Content Input Section - Expands to fill remaining space */}
<div className='mt-4 flex flex-1 flex-col'>
<Label htmlFor='content' className='mb-2 font-medium text-sm'>
Chunk Content
</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
className='flex-1 resize-none'
{/* Fixed Footer with Actions */}
<div className='absolute inset-x-0 bottom-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'>
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={handleCloseAttempt}
type='button'
disabled={isCreating}
/>
>
Cancel
</Button>
<Button
variant='primary'
onClick={handleCreateChunk}
type='button'
disabled={!isFormValid || isCreating}
>
{isCreating ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
'Create Chunk'
)}
</Button>
</div>
</div>
</div>
{/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'>
<Button variant='outline' onClick={handleCloseAttempt} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreateChunk}
disabled={!isFormValid || isCreating}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isCreating ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
'Create Chunk'
)}
</Button>
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
</ModalContent>
</Modal>
{/* Unsaved Changes Alert */}
<AlertDialog open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='flex flex-col gap-0 p-0'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Discard changes?
</ModalTitle>
<p className='mt-2 text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to close without saving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowUnsavedChangesAlert(false)}>
</p>
</div>
{/* Modal Footer */}
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={() => setShowUnsavedChangesAlert(false)}
type='button'
>
Keep editing
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDiscard}>Discard changes</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
<Button variant='primary' onClick={handleConfirmDiscard} type='button'>
Discard changes
</Button>
</div>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import { Plus, Search } from 'lucide-react'
import { Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
ChunkTableSkeleton,
KnowledgeHeader,
@@ -63,12 +63,8 @@ export function DocumentLoading({
</div>
</div>
<Button
disabled
size='sm'
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-muted-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-3.5 w-3.5' />
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Create Chunk</span>
</Button>
</div>

View File

@@ -2,21 +2,15 @@
import { useEffect, useState } from 'react'
import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
Button,
Label,
Modal,
ModalContent,
ModalTitle,
Textarea,
Tooltip,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
@@ -59,20 +53,16 @@ export function EditChunkModal({
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
// Check if there are unsaved changes
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
// Update edited content when chunk changes
useEffect(() => {
if (chunk?.content) {
setEditedContent(chunk.content)
}
}, [chunk?.id, chunk?.content])
// Find current chunk index in the current page
const currentChunkIndex = chunk ? allChunks.findIndex((c) => c.id === chunk.id) : -1
// Calculate navigation availability
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
@@ -122,16 +112,13 @@ export function EditChunkModal({
if (direction === 'prev') {
if (currentChunkIndex > 0) {
// Navigate to previous chunk in current page
const prevChunk = allChunks[currentChunkIndex - 1]
onNavigateToChunk?.(prevChunk)
} else if (currentPage > 1) {
// Load previous page and navigate to last chunk
await onNavigateToPage?.(currentPage - 1, 'last')
}
} else {
if (currentChunkIndex < allChunks.length - 1) {
// Navigate to next chunk in current page
const nextChunk = allChunks[currentChunkIndex + 1]
onNavigateToChunk?.(nextChunk)
} else if (currentPage < totalPages) {
@@ -181,15 +168,18 @@ export function EditChunkModal({
return (
<>
<Dialog open={isOpen} onOpenChange={handleCloseAttempt}>
<DialogContent
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
<ModalContent
className='flex h-[74vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
showClose={false}
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<DialogTitle className='font-medium text-lg'>Edit Chunk</DialogTitle>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Edit Chunk
</ModalTitle>
{/* Navigation Controls */}
<div className='flex items-center gap-1'>
@@ -201,7 +191,6 @@ export function EditChunkModal({
>
<Button
variant='ghost'
size='sm'
onClick={() => handleNavigate('prev')}
disabled={!canNavigatePrev || isNavigating || isSaving}
className='h-8 w-8 p-0'
@@ -223,7 +212,6 @@ export function EditChunkModal({
>
<Button
variant='ghost'
size='sm'
onClick={() => handleNavigate('next')}
disabled={!canNavigateNext || isNavigating || isSaving}
className='h-8 w-8 p-0'
@@ -241,125 +229,140 @@ export function EditChunkModal({
</div>
</div>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={handleCloseAttempt}
>
<Button variant='ghost' className='h-8 w-8 p-0' onClick={handleCloseAttempt}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
</div>
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex min-h-full flex-col py-4'>
{/* Document Info Section - Fixed at top */}
<div className='flex-shrink-0 space-y-4'>
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>
Editing chunk #{chunk.chunkIndex} Page {currentPage} of {totalPages}
</p>
{/* Modal Body */}
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<form className='flex min-h-0 flex-1 flex-col'>
{/* Scrollable Content */}
<div className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'>
<div className='flex min-h-full flex-col px-6'>
<div className='flex flex-1 flex-col space-y-[12px] pt-0 pb-6'>
{/* Document Info Section */}
<div className='flex-shrink-0 space-y-[8px]'>
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>
Editing chunk #{chunk.chunkIndex} Page {currentPage} of {totalPages}
</p>
</div>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
)}
</div>
{/* Content Input Section - Expands to fill space */}
<div className='flex min-h-0 flex-1 flex-col space-y-[8px]'>
<Label
htmlFor='content'
className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'
>
Chunk Content
</Label>
<Textarea
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
}
className='min-h-0 flex-1 resize-none'
disabled={isSaving || isNavigating || !userPermissions.canEdit}
readOnly={!userPermissions.canEdit}
/>
</div>
</div>
</div>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
{/* Fixed Footer with Actions */}
<div className='absolute inset-x-0 bottom-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'>
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={handleCloseAttempt}
type='button'
disabled={isSaving || isNavigating}
>
Cancel
</Button>
{userPermissions.canEdit && (
<Button
variant='primary'
onClick={handleSaveContent}
type='button'
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
>
{isSaving ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
)}
</div>
{/* Content Input Section - Expands to fill remaining space */}
<div className='mt-4 flex flex-1 flex-col'>
<Label htmlFor='content' className='mb-2 font-medium text-sm'>
Chunk Content
</Label>
<Textarea
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder={
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
}
className='flex-1 resize-none'
disabled={isSaving || isNavigating || !userPermissions.canEdit}
readOnly={!userPermissions.canEdit}
/>
</div>
</div>
</div>
{/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'>
<Button
variant='outline'
onClick={handleCloseAttempt}
disabled={isSaving || isNavigating}
>
Cancel
</Button>
{userPermissions.canEdit && (
<Button
onClick={handleSaveContent}
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}
className='bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isSaving ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
)}
</div>
</div>
</form>
</div>
</DialogContent>
</Dialog>
</ModalContent>
</Modal>
{/* Unsaved Changes Alert */}
<AlertDialog open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent className='flex flex-col gap-0 p-0'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Unsaved Changes
</ModalTitle>
<p className='mt-2 text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
You have unsaved changes to this chunk content.
{pendingNavigation
? ' Do you want to discard your changes and navigate to the next chunk?'
: ' Are you sure you want to discard your changes and close the editor?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
</p>
</div>
{/* Modal Footer */}
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={() => {
setShowUnsavedChangesAlert(false)
setPendingNavigation(null)
}}
type='button'
>
Keep Editing
</AlertDialogCancel>
<AlertDialogAction
</Button>
<Button
variant='primary'
onClick={handleConfirmDiscard}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
type='button'
className='bg-[var(--text-error)] hover:bg-[var(--text-error)] dark:bg-[var(--text-error)] dark:hover:bg-[var(--text-error)]'
>
Discard Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</div>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -3,9 +3,9 @@
import { Suspense, startTransition, useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Circle, CircleOff, FileText, Plus } from 'lucide-react'
import { useParams, useSearchParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button, Checkbox, SearchHighlight } from '@/components/ui'
import { Checkbox, SearchHighlight } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import {
CreateChunkModal,
@@ -339,7 +339,6 @@ export function Document({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(chunk.id)
@@ -362,7 +361,6 @@ export function Document({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleDeleteChunk(chunk.id)
@@ -679,8 +677,8 @@ export function Document({
<Button
onClick={() => setIsCreateChunkModalOpen(true)}
disabled={documentData?.processingStatus === 'failed' || !userPermissions.canEdit}
size='sm'
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50'
variant='primary'
className='flex items-center gap-1'
>
<Plus className='h-3.5 w-3.5' />
<span>Create Chunk</span>
@@ -781,7 +779,6 @@ export function Document({
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
onClick={prevPage}
disabled={!hasPrevPage}
className='h-8 w-8 p-0'
@@ -822,7 +819,6 @@ export function Document({
<Button
variant='ghost'
size='sm'
onClick={nextPage}
disabled={!hasNextPage}
className='h-8 w-8 p-0'

View File

@@ -16,18 +16,17 @@ import {
RotateCcw,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Checkbox } from '@/components/ui/checkbox'
import { SearchHighlight } from '@/components/ui/search-highlight'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
@@ -40,7 +39,6 @@ import {
import {
getDocumentIcon,
KnowledgeHeader,
PrimaryButton,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -155,6 +153,7 @@ export function KnowledgeBase({
knowledgeBase,
isLoading: isLoadingKnowledgeBase,
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
const {
documents,
@@ -176,12 +175,10 @@ export function KnowledgeBase({
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
const error = knowledgeBaseError || documentsError
// Pagination calculations
const totalPages = Math.ceil(pagination.total / pagination.limit)
const hasNextPage = currentPage < totalPages
const hasPrevPage = currentPage > 1
// Navigation functions
const goToPage = useCallback(
(page: number) => {
if (page >= 1 && page <= totalPages) {
@@ -206,20 +203,16 @@ export function KnowledgeBase({
const handleSort = useCallback(
(field: DocumentSortField) => {
if (sortBy === field) {
// Toggle sort order if same field
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
// Set new field with default desc order
setSortBy(field)
setSortOrder('desc')
}
// Reset to first page when sorting changes
setCurrentPage(1)
},
[sortBy, sortOrder]
)
// Helper function to render sortable header
const renderSortableHeader = (field: DocumentSortField, label: string, className = '') => (
<th className={`px-4 pt-2 pb-3 text-left font-medium ${className}`}>
<button
@@ -238,7 +231,6 @@ export function KnowledgeBase({
</th>
)
// Auto-refresh documents when there are processing documents
useEffect(() => {
const hasProcessingDocuments = documents.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
@@ -248,9 +240,7 @@ export function KnowledgeBase({
const refreshInterval = setInterval(async () => {
try {
// Only refresh if we're not in the middle of other operations
if (!isDeleting) {
// Check for dead processes before refreshing
await checkForDeadProcesses()
await refreshDocuments()
}
@@ -262,7 +252,6 @@ export function KnowledgeBase({
return () => clearInterval(refreshInterval)
}, [documents, refreshDocuments, isDeleting])
// Check for documents stuck in processing due to dead processes
const checkForDeadProcesses = async () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 150 * 1000 // 150 seconds (2.5 minutes)
@@ -280,7 +269,6 @@ export function KnowledgeBase({
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
// Mark stale documents as failed via API to sync with database
const markFailedPromises = staleDocuments.map(async (doc) => {
try {
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
@@ -294,7 +282,6 @@ export function KnowledgeBase({
})
if (!response.ok) {
// If API call fails, log but don't throw to avoid stopping other recoveries
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
logger.error(`Failed to mark document ${doc.id} as failed: ${errorData.error}`)
return
@@ -312,7 +299,6 @@ export function KnowledgeBase({
await Promise.allSettled(markFailedPromises)
}
// Calculate pagination info for display
const totalItems = pagination?.total || 0
const handleToggleEnabled = async (docId: string) => {
@@ -701,6 +687,7 @@ export function KnowledgeBase({
options={{
knowledgeBaseId: id,
currentWorkspaceId: knowledgeBase?.workspaceId || null,
onWorkspaceChange: refreshKnowledgeBase,
onDeleteKnowledgeBase: () => setShowDeleteDialog(true),
}}
/>
@@ -723,13 +710,15 @@ export function KnowledgeBase({
{/* Add Documents Button */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PrimaryButton
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='primary'
className='flex items-center gap-1'
>
<Plus className='h-3.5 w-3.5' />
Add Documents
</PrimaryButton>
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
@@ -1153,28 +1142,38 @@ export function KnowledgeBase({
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Knowledge Base</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete Knowledge Base</ModalTitle>
<ModalDescription>
Are you sure you want to delete "{knowledgeBaseName}"? This will permanently delete
the knowledge base and all {totalItems} document
{totalItems === 1 ? '' : 's'} within it. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
{totalItems === 1 ? '' : 's'} within it.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={handleDeleteKnowledgeBase}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
{isDeleting ? 'Deleting...' : 'Delete Knowledge Base'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Upload Modal */}
<UploadModal

View File

@@ -2,7 +2,7 @@
import { Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
DocumentTableSkeleton,
KnowledgeHeader,
@@ -52,13 +52,9 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
</div>
</div>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
{/* Add Documents Button - disabled state */}
<Button
disabled
size='sm'
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-muted-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Add Documents</span>
</Button>

View File

@@ -3,8 +3,14 @@
import { useRef, useState } from 'react'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Button,
Modal,
ModalContent,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
@@ -149,11 +155,11 @@ export function UploadModal({
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='flex max-h-[95vh] flex-col overflow-hidden sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle>Upload Documents</DialogTitle>
</DialogHeader>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent className='flex max-h-[95vh] flex-col overflow-hidden sm:max-w-[600px]'>
<ModalHeader>
<ModalTitle>Upload Documents</ModalTitle>
</ModalHeader>
<div className='flex-1 space-y-6 overflow-auto'>
{/* File Upload Section */}
@@ -253,7 +259,6 @@ export function UploadModal({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
@@ -286,29 +291,31 @@ export function UploadModal({
</div>
</div>
{/* Footer */}
<div className='flex justify-between border-t pt-4'>
<div className='flex gap-3' />
<div className='flex gap-3'>
<Button variant='outline' onClick={handleClose} disabled={isUploading}>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Uploading...'
: `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ModalFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isUploading}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
<Button
variant='primary'
onClick={handleUpload}
disabled={files.length === 0 || isUploading}
className='h-[32px] px-[12px]'
>
{isUploading
? uploadProgress.stage === 'uploading'
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
: uploadProgress.stage === 'processing'
? 'Processing...'
: 'Uploading...'
: `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -6,13 +6,9 @@ import { AlertCircle, Check, Loader2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Label, Modal, ModalContent, ModalTitle, Textarea } from '@/components/emcn'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console/logger'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
@@ -312,34 +308,27 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className='flex h-[74vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Create Knowledge Base</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => handleClose(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<Modal open={open} onOpenChange={handleClose}>
<ModalContent className='flex h-[78vh] max-h-[95vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[750px]'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Create Knowledge Base
</ModalTitle>
</div>
<div className='flex flex-1 flex-col overflow-hidden'>
<form onSubmit={handleSubmit(onSubmit)} className='flex h-full flex-col'>
{/* Modal Body */}
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex min-h-full flex-col py-4'>
<div
ref={scrollContainerRef}
className='scrollbar-hide min-h-0 flex-1 overflow-y-auto pb-20'
>
<div className='px-6'>
{/* Show upload error first, then submit error only if no upload error */}
{uploadError && (
<Alert variant='destructive' className='mb-6'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Upload Error</AlertTitle>
<AlertDescription>{uploadError.message}</AlertDescription>
@@ -347,16 +336,16 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
)}
{submitStatus && submitStatus.type === 'error' && !uploadError && (
<Alert variant='destructive' className='mb-6'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{submitStatus.message}</AlertDescription>
</Alert>
)}
{/* Form Fields Section - Fixed at top */}
<div className='flex-shrink-0 space-y-4'>
<div className='space-y-2'>
{/* Form Fields Section */}
<div className='space-y-[12px]'>
<div className='space-y-[8px]'>
<Label htmlFor='name'>Name *</Label>
<Input
id='name'
@@ -369,7 +358,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
)}
</div>
<div className='space-y-2'>
<div className='space-y-[8px]'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
@@ -384,12 +373,12 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
{/* Chunk Configuration Section */}
<div className='space-y-4 rounded-lg border p-4'>
<div className='space-y-[12px] rounded-lg border p-5'>
<h3 className='font-medium text-foreground text-sm'>Chunking Configuration</h3>
{/* Min and Max Chunk Size Row */}
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<div className='space-y-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size</Label>
<Input
id='minChunkSize'
@@ -406,7 +395,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
)}
</div>
<div className='space-y-2'>
<div className='space-y-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size</Label>
<Input
id='maxChunkSize'
@@ -425,7 +414,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
{/* Overlap Size */}
<div className='space-y-2'>
<div className='space-y-[8px]'>
<Label htmlFor='overlapSize'>Overlap Size</Label>
<Input
id='overlapSize'
@@ -447,12 +436,10 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
provide more precise retrieval but may lose context.
</p>
</div>
</div>
{/* File Upload Section - Expands to fill remaining space */}
<div className='mt-6 flex flex-1 flex-col'>
<Label className='mb-2'>Upload Documents</Label>
<div className='flex flex-1 flex-col'>
{/* File Upload Section */}
<div className='space-y-[12px]'>
<Label>Upload Documents</Label>
{files.length === 0 ? (
<div
ref={dropZoneRef}
@@ -461,7 +448,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative flex flex-1 cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed py-8 text-center transition-all duration-200 ${
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed py-8 text-center transition-all duration-200 ${
isDragging
? 'border-purple-300 bg-purple-50 shadow-sm'
: 'border-muted-foreground/25 hover:border-muted-foreground/40 hover:bg-muted/10'
@@ -494,7 +481,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
</div>
) : (
<div className='flex flex-1 flex-col space-y-2'>
<div className='space-y-2'>
{/* Compact drop area at top of file list */}
<div
ref={dropZoneRef}
@@ -579,7 +566,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
@@ -593,7 +579,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
)}
{fileError && (
<Alert variant='destructive' className='mt-2'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
@@ -604,16 +590,21 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
</div>
{/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'>
<Button variant='outline' onClick={() => handleClose(false)} type='button'>
{/* Fixed Footer with Actions */}
<div className='absolute inset-x-0 bottom-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'>
<div className='flex w-full items-center justify-between gap-[8px] px-6 py-4'>
<Button
variant='default'
onClick={() => handleClose(false)}
type='button'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
variant='primary'
type='submit'
disabled={isSubmitting || !nameValue?.trim()}
className='bg-[var(--brand-primary-hex)] font-[480] text-primary-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50 disabled:hover:shadow-none'
>
{isSubmitting
? isUploading
@@ -629,7 +620,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
</div>
</form>
</div>
</DialogContent>
</Dialog>
</ModalContent>
</Modal>
)
}

View File

@@ -1,21 +1,12 @@
'use client'
import { useState } from 'react'
import { LibraryBig, MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
interface BreadcrumbItem {
label: string
@@ -46,6 +37,8 @@ interface KnowledgeHeaderProps {
}
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
return (
<div className={HEADER_STYLES.container}>
<div className={HEADER_STYLES.breadcrumbs}>
@@ -85,35 +78,29 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
{/* Actions Menu */}
{options.onDeleteKnowledgeBase && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover open={isActionsPopoverOpen} onOpenChange={setIsActionsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
size='sm'
className={filterButtonClass}
aria-label='Knowledge base actions menu'
>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<div className={`${commandListClass} py-1`}>
<DropdownMenuItem
onClick={options.onDeleteKnowledgeBase}
className='flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-red-600 text-sm hover:bg-secondary/50 focus:bg-secondary/50 focus:text-red-600'
>
<Trash className='h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
options.onDeleteKnowledgeBase?.()
setIsActionsPopoverOpen(false)
}}
className='text-red-600 hover:text-red-600 focus:text-red-600'
>
<Trash className='h-4 w-4' />
<span>Delete Knowledge Base</span>
</PopoverItem>
</PopoverContent>
</Popover>
)}
</div>
)}

View File

@@ -1,21 +1,17 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, Check, ChevronDown } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { AlertTriangle, ChevronDown } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('WorkspaceSelector')
@@ -43,6 +39,7 @@ export function WorkspaceSelector({
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
// Fetch available workspaces
useEffect(() => {
@@ -82,6 +79,7 @@ export function WorkspaceSelector({
try {
setIsUpdating(true)
setIsPopoverOpen(false)
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
method: 'PUT',
@@ -103,11 +101,11 @@ export function WorkspaceSelector({
if (result.success) {
logger.info(`Knowledge base workspace updated: ${knowledgeBaseId} -> ${workspaceId}`)
// Update the store immediately to reflect the change without page reload
updateKnowledgeBase(knowledgeBaseId, { workspaceId: workspaceId || undefined })
// Notify parent component of the change to refresh data
await onWorkspaceChange?.(workspaceId)
// Notify parent component of the change
onWorkspaceChange?.(workspaceId)
// Update the store after refresh to ensure consistency
updateKnowledgeBase(knowledgeBaseId, { workspaceId: workspaceId || undefined })
} else {
throw new Error(result.error || 'Failed to update workspace')
}
@@ -134,11 +132,10 @@ export function WorkspaceSelector({
)}
{/* Workspace selector dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
size='sm'
disabled={disabled || isLoading || isUpdating}
className={filterButtonClass}
>
@@ -151,53 +148,36 @@ export function WorkspaceSelector({
</span>
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<div className={`${commandListClass} py-1`}>
{/* No workspace option */}
<DropdownMenuItem
onClick={() => handleWorkspaceChange(null)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{/* No workspace option */}
<PopoverItem
active={!currentWorkspaceId}
showCheck
onClick={() => handleWorkspaceChange(null)}
>
<span className='text-muted-foreground'>No workspace</span>
</PopoverItem>
{/* Available workspaces */}
{workspaces.map((workspace) => (
<PopoverItem
key={workspace.id}
active={currentWorkspaceId === workspace.id}
showCheck
onClick={() => handleWorkspaceChange(workspace.id)}
>
<span className='text-muted-foreground'>No workspace</span>
{!currentWorkspaceId && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
{workspace.name}
</PopoverItem>
))}
{/* Available workspaces */}
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace.id)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex flex-col'>
<span>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions}
</span>
</div>
{currentWorkspaceId === workspace.id && (
<Check className='h-4 w-4 text-muted-foreground' />
)}
</DropdownMenuItem>
))}
{workspaces.length === 0 && !isLoading && (
<DropdownMenuItem disabled className='px-3 py-2'>
<span className='text-muted-foreground text-xs'>
No workspaces with write access
</span>
</DropdownMenuItem>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
{workspaces.length === 0 && !isLoading && (
<PopoverItem disabled>
<span className='text-muted-foreground text-xs'>No workspaces with write access</span>
</PopoverItem>
)}
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -1,28 +1,25 @@
'use client'
import { useMemo, useState } from 'react'
import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import {
BaseOverview,
CreateModal,
EmptyStateCard,
KnowledgeBaseCardSkeletonGrid,
KnowledgeHeader,
PrimaryButton,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
SORT_OPTIONS,
type SortOption,
@@ -50,6 +47,7 @@ export function Knowledge() {
const [searchQuery, setSearchQuery] = useState('')
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
@@ -61,6 +59,7 @@ export function Knowledge() {
const [field, order] = value.split('-') as [SortOption, SortOrder]
setSortBy(field)
setSortOrder(order)
setIsSortPopoverOpen(false)
}
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
@@ -108,49 +107,39 @@ export function Knowledge() {
<div className='flex items-center gap-2'>
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<div className={`${commandListClass} py-1`}>
{SORT_OPTIONS.map((option, index) => (
<div key={option.value}>
<DropdownMenuItem
onSelect={() => handleSortChange(option.value)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{option.label}</span>
{currentSortValue === option.value && (
<Check className='h-4 w-4 text-muted-foreground' />
)}
</DropdownMenuItem>
{index === 0 && <DropdownMenuSeparator />}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{SORT_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={currentSortValue === option.value}
showCheck
onClick={() => handleSortChange(option.value)}
>
{option.label}
</PopoverItem>
))}
</PopoverContent>
</Popover>
{/* Create Button */}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PrimaryButton
<Button
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
variant='primary'
className='flex items-center gap-1'
>
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</PrimaryButton>
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>

View File

@@ -1,14 +1,20 @@
'use client'
import { Plus, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ChevronDown } from 'lucide-react'
import { Button, Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import {
KnowledgeBaseCardSkeletonGrid,
KnowledgeHeader,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
filterButtonClass,
SORT_OPTIONS,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
export default function KnowledgeLoading() {
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
const currentSortLabel = SORT_OPTIONS[0]?.label || 'Last Updated'
return (
<div className='flex h-screen flex-col pl-64'>
@@ -22,26 +28,37 @@ export default function KnowledgeLoading() {
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
placeholder='Search knowledge bases...'
disabled
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
</div>
<Button
<SearchInput
value=''
onChange={() => {}}
placeholder='Search knowledge bases...'
disabled
size='sm'
className='flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-muted-foreground shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</Button>
/>
<div className='flex items-center gap-2'>
{/* Sort Dropdown */}
<Popover open={false}>
<PopoverAnchor asChild>
<Button variant='outline' className={filterButtonClass} disabled>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverAnchor>
<PopoverContent align='end' side='bottom' sideOffset={4}>
{SORT_OPTIONS.map((option) => (
<PopoverItem key={option.value} disabled>
{option.label}
</PopoverItem>
))}
</PopoverContent>
</Popover>
{/* Create Button */}
<Button disabled variant='primary' className='flex items-center gap-1'>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Create</span>
</Button>
</div>
</div>
{/* Content Area */}

View File

@@ -1,18 +1,14 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/emcn'
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
timelineDropdownListStyle,
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
} from '@/components/emcn'
import { filterButtonClass } from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
import { useFilterStore } from '@/stores/logs/filters/store'
import type { TimeRange } from '@/stores/logs/filters/types'
@@ -22,6 +18,8 @@ type TimelineProps = {
export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
const { timeRange, setTimeRange } = useFilterStore()
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const specificTimeRanges: TimeRange[] = [
'Past 30 minutes',
'Past hour',
@@ -34,52 +32,49 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
'Past 30 days',
]
const handleTimeRangeSelect = (range: TimeRange) => {
setTimeRange(range)
setIsPopoverOpen(false)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='outline' className={filterButtonClass}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
</PopoverTrigger>
<PopoverContent
align={variant === 'header' ? 'end' : 'start'}
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
maxHeight={144}
>
<div
className={`${commandListClass} py-1`}
style={variant === 'header' ? undefined : timelineDropdownListStyle}
>
<DropdownMenuItem
key='all'
onSelect={() => {
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
<PopoverScrollArea>
<PopoverItem
active={timeRange === 'All time'}
showCheck
onClick={() => handleTimeRangeSelect('All time')}
>
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
All time
</PopoverItem>
<DropdownMenuSeparator />
{/* Separator */}
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
{specificTimeRanges.map((range) => (
<DropdownMenuItem
<PopoverItem
key={range}
onSelect={() => {
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
active={timeRange === range}
showCheck
onClick={() => handleTimeRangeSelect(range)}
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
{range}
</PopoverItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</PopoverScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,2 +0,0 @@
export { FrozenCanvas } from './frozen-canvas'
export { FrozenCanvasModal } from './frozen-canvas-modal'

View File

@@ -143,8 +143,9 @@ export function DeploymentInfo({
</Button>
)}
<Button
variant='outline'
disabled={isUndeploying}
className='h-8 bg-red-500 text-white text-xs hover:bg-red-600'
className='h-8 text-xs'
onClick={() => setShowUndeployModal(true)}
>
{isUndeploying ? <Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' /> : null}

View File

@@ -12,4 +12,3 @@ export { TrainingControls } from './training-controls/training-controls'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'
export { WorkflowTextEditorModal } from './workflow-text-editor/workflow-text-editor-modal'

View File

@@ -1,15 +1,15 @@
'use client'
import { Check } from 'lucide-react'
import { Button } from '@/components/emcn/components/button/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -298,15 +298,15 @@ export function OAuthRequiredModal({
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Additional Access Required</DialogTitle>
<DialogDescription>
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Additional Access Required</ModalTitle>
<ModalDescription>
The "{toolName}" tool requires access to your {providerName} account to function
properly.
</DialogDescription>
</DialogHeader>
</ModalDescription>
</ModalHeader>
<div className='flex flex-col gap-4 py-4'>
<div className='flex items-center gap-4'>
<div className='rounded-full bg-muted p-2'>
@@ -345,20 +345,20 @@ export function OAuthRequiredModal({
</div>
)}
</div>
<DialogFooter className='flex flex-col gap-2 sm:flex-row'>
<Button variant='outline' onClick={onClose} className='sm:order-1'>
<ModalFooter>
<Button variant='outline' onClick={onClose} className='h-[32px] px-[12px]'>
Cancel
</Button>
<Button
variant='primary'
type='button'
onClick={handleConnectDirectly}
className='sm:order-3'
className='h-[32px] px-[12px]'
>
Connect Now
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -289,6 +289,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
side={side}
align='start'
collisionPadding={6}
style={{ zIndex: 100000000 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>

View File

@@ -34,4 +34,3 @@ export { TimeInput } from './time-input/time-input'
export { ToolInput } from './tool-input/tool-input'
export { TriggerSave } from './trigger-save/trigger-save'
export { VariablesInput } from './variables-input/variables-input'
export { WebhookConfig } from './webhook/webhook'

View File

@@ -1,18 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn/components'
import {
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn } from '@/lib/utils'
@@ -480,26 +478,35 @@ export function ScheduleSave({ blockId, isPreview = false, disabled = false }: S
</div>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Schedule Configuration</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete schedule?</ModalTitle>
<ModalDescription>
Are you sure you want to delete this schedule configuration? This will stop the
workflow from running automatically. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
workflow from running automatically.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
variant='outline'
onClick={() => setShowDeleteDialog(false)}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -152,7 +152,7 @@ export function CodeEditor({
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-blue-500">${match[0]}</span>`,
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF]">${match[0]}</span>`,
})
}
@@ -166,7 +166,7 @@ export function CodeEditor({
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-blue-500">${escaped}</span>`,
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF]">${escaped}</span>`,
})
}
}
@@ -196,7 +196,7 @@ export function CodeEditor({
highlights.push({
start: match.index,
end: match.index + match[0].length,
replacement: `<span class="text-green-600 font-medium">${match[0]}</span>`,
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF] font-medium">${match[0]}</span>`,
})
}
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Code, FileJson, X } from 'lucide-react'
import { AlertCircle, Code, FileJson, Wand2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button as EmcnButton,
@@ -9,9 +9,14 @@ import {
ModalFooter,
ModalHeader,
ModalTitle,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverScrollArea,
PopoverSection,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -33,7 +38,6 @@ import {
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
import {
useCreateCustomTool,
@@ -88,8 +92,16 @@ export function CustomToolModal({
const [isEditing, setIsEditing] = useState(false)
const [toolId, setToolId] = useState<string | undefined>(undefined)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
const [schemaPromptInput, setSchemaPromptInput] = useState('')
const [schemaPromptSummary, setSchemaPromptSummary] = useState<string | null>(null)
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
const [codePromptInput, setCodePromptInput] = useState('')
const [codePromptSummary, setCodePromptSummary] = useState<string | null>(null)
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
// AI Code Generation Hooks
const schemaGeneration = useWand({
wandConfig: {
enabled: true,
@@ -173,7 +185,6 @@ Example 2:
onStreamChunk: (chunk) => {
setJsonSchema((prev) => {
const newSchema = prev + chunk
// Clear error as soon as streaming starts
if (schemaError) setSchemaError(null)
return newSchema
})
@@ -205,7 +216,7 @@ Example Scenario:
User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable."
Generated Code:
const userId = <block.content>; // Correct: Accessing input parameter without quotes
const userId = userId; // Correct: Accessing userId input parameter without quotes
const apiKey = {{SERVICE_API_KEY}}; // Correct: Accessing environment variable without quotes
const url = \`https://api.example.com/users/\${userId}\`;
@@ -258,7 +269,6 @@ try {
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const codeEditorRef = useRef<HTMLDivElement>(null)
const schemaParamsDropdownRef = useRef<HTMLDivElement>(null)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
// Add state for dropdown positioning
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
@@ -293,21 +303,6 @@ try {
}
}, [open, initialValues])
// Close schema params dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
schemaParamsDropdownRef.current &&
!schemaParamsDropdownRef.current.contains(event.target as Node)
) {
setShowSchemaParams(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const resetForm = () => {
setJsonSchema('')
setFunctionCode('')
@@ -784,6 +779,86 @@ try {
}
}
// Schema inline wand handlers (copied from regular sub-block UX)
const handleSchemaWandClick = () => {
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
setIsSchemaPromptActive(true)
setSchemaPromptInput(schemaPromptSummary ?? '')
setTimeout(() => {
schemaPromptInputRef.current?.focus()
}, 0)
}
const handleSchemaPromptBlur = () => {
if (!schemaPromptInput.trim() && !schemaGeneration.isStreaming) {
setIsSchemaPromptActive(false)
}
}
const handleSchemaPromptChange = (value: string) => {
setSchemaPromptInput(value)
}
const handleSchemaPromptSubmit = () => {
const trimmedPrompt = schemaPromptInput.trim()
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
setSchemaPromptSummary(trimmedPrompt)
schemaGeneration.generateStream({ prompt: trimmedPrompt })
setSchemaPromptInput('')
setIsSchemaPromptActive(false)
}
const handleSchemaPromptKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSchemaPromptSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
setSchemaPromptInput('')
setIsSchemaPromptActive(false)
}
}
// Code inline wand handlers
const handleCodeWandClick = () => {
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
setIsCodePromptActive(true)
setCodePromptInput(codePromptSummary ?? '')
setTimeout(() => {
codePromptInputRef.current?.focus()
}, 0)
}
const handleCodePromptBlur = () => {
if (!codePromptInput.trim() && !codeGeneration.isStreaming) {
setIsCodePromptActive(false)
}
}
const handleCodePromptChange = (value: string) => {
setCodePromptInput(value)
}
const handleCodePromptSubmit = () => {
const trimmedPrompt = codePromptInput.trim()
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
setCodePromptSummary(trimmedPrompt)
codeGeneration.generateStream({ prompt: trimmedPrompt })
setCodePromptInput('')
setIsCodePromptActive(false)
}
const handleCodePromptKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleCodePromptSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
setCodePromptInput('')
setIsCodePromptActive(false)
}
}
const handleDelete = async () => {
if (!toolId || !isEditing) return
@@ -858,11 +933,10 @@ try {
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className='flex h-[80vh] flex-col gap-0 p-0 sm:max-w-[700px]'
className='flex h-[80vh] w-full max-w-[840px] flex-col gap-0 p-0'
style={{ zIndex: 99999999 }}
hideCloseButton
onKeyDown={(e) => {
// Intercept Escape key when dropdowns are open
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
e.preventDefault()
e.stopPropagation()
@@ -877,10 +951,10 @@ try {
<DialogTitle className='font-medium text-lg'>
{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}
</DialogTitle>
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
<EmcnButton variant='ghost' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</EmcnButton>
</div>
<DialogDescription className='mt-1.5'>
Step {activeSection === 'schema' ? '1' : '2'} of 2:{' '}
@@ -888,16 +962,6 @@ try {
</DialogDescription>
</DialogHeader>
{/* Error Alert */}
{schemaError && (
<div className='px-6 pt-4'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{schemaError}</AlertDescription>
</Alert>
</div>
)}
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<div className='flex border-b'>
{navigationItems.map((item) => (
@@ -919,70 +983,63 @@ try {
</div>
<div className='relative flex-1 overflow-auto px-6 pt-6 pb-12'>
{/* Schema Section AI Prompt Bar */}
{activeSection === 'schema' && (
<WandPromptBar
isVisible={schemaGeneration.isPromptVisible}
isLoading={schemaGeneration.isLoading}
isStreaming={schemaGeneration.isStreaming}
promptValue={schemaGeneration.promptInputValue}
onSubmit={(prompt: string) => schemaGeneration.generateStream({ prompt })}
onCancel={
schemaGeneration.isStreaming
? schemaGeneration.cancelGeneration
: schemaGeneration.hidePromptInline
}
onChange={schemaGeneration.updatePromptValue}
placeholder='Describe the JSON schema to generate...'
className='!top-0 relative mb-2'
/>
)}
{/* Code Section AI Prompt Bar */}
{activeSection === 'code' && (
<WandPromptBar
isVisible={codeGeneration.isPromptVisible}
isLoading={codeGeneration.isLoading}
isStreaming={codeGeneration.isStreaming}
promptValue={codeGeneration.promptInputValue}
onSubmit={(prompt: string) => codeGeneration.generateStream({ prompt })}
onCancel={
codeGeneration.isStreaming
? codeGeneration.cancelGeneration
: codeGeneration.hidePromptInline
}
onChange={codeGeneration.updatePromptValue}
placeholder='Describe the JavaScript code to generate...'
className='!top-0 relative mb-2'
/>
)}
<div
className={cn(
'flex h-full flex-1 flex-col',
activeSection === 'schema' ? 'block' : 'hidden'
)}
>
<div className='mb-1 flex min-h-6 items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='mb-1 flex min-h-6 items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2'>
<FileJson className='h-4 w-4' />
<Label htmlFor='json-schema' className='font-medium'>
JSON Schema
</Label>
{schemaError && (
<div className='ml-2 flex min-w-0 items-center gap-1 text-destructive text-xs'>
<AlertCircle className='h-3 w-3 flex-shrink-0' />
<span className='truncate'>{schemaError}</span>
</div>
)}
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isSchemaPromptActive && schemaPromptSummary && (
<span className='text-muted-foreground text-xs italic'>
with {schemaPromptSummary}
</span>
)}
{!isSchemaPromptActive ? (
<button
type='button'
onClick={handleSchemaWandClick}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate schema with AI'
>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
) : (
<input
ref={schemaPromptInputRef}
type='text'
value={schemaGeneration.isStreaming ? 'Generating...' : schemaPromptInput}
onChange={(e) => handleSchemaPromptChange(e.target.value)}
onBlur={handleSchemaPromptBlur}
onKeyDown={handleSchemaPromptKeyDown}
disabled={schemaGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none'
placeholder='Describe schema...'
/>
)}
</div>
</div>
<CodeEditor
value={jsonSchema}
onChange={handleJsonSchemaChange}
language='json'
showWandButton={true}
onWandClick={() => {
schemaGeneration.isPromptVisible
? schemaGeneration.hidePromptInline()
: schemaGeneration.showPromptInline()
}}
wandButtonDisabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
placeholder={`{
<div className='relative'>
<CodeEditor
value={jsonSchema}
onChange={handleJsonSchemaChange}
language='json'
showWandButton={false}
placeholder={`{
"type": "function",
"function": {
"name": "addItemToOrder",
@@ -999,14 +1056,16 @@ try {
}
}
}`}
minHeight='360px'
className={cn(
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming} // Use disabled prop instead of readOnly
onKeyDown={handleKeyDown} // Pass keydown handler
/>
minHeight='360px'
className={cn(
schemaError && 'border-red-500',
(schemaGeneration.isLoading || schemaGeneration.isStreaming) &&
'cursor-not-allowed opacity-50'
)}
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming} // Use disabled prop instead of readOnly
onKeyDown={handleKeyDown} // Pass keydown handler
/>
</div>
<div className='h-6' />
</div>
@@ -1016,13 +1075,43 @@ try {
activeSection === 'code' ? 'block' : 'hidden'
)}
>
<div className='mb-1 flex min-h-6 items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='mb-1 flex min-h-6 items-center justify-between gap-2'>
<div className='flex min-w-0 items-center gap-2'>
<Code className='h-4 w-4' />
<Label htmlFor='function-code' className='font-medium'>
Code (optional)
Code
</Label>
</div>
<div className='flex min-w-0 flex-1 items-center justify-end gap-1 pr-[4px]'>
{!isCodePromptActive && codePromptSummary && (
<span className='text-muted-foreground text-xs italic'>
with {codePromptSummary}
</span>
)}
{!isCodePromptActive ? (
<button
type='button'
onClick={handleCodeWandClick}
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
className='inline-flex h-[16px] w-[16px] items-center justify-center rounded-full hover:bg-transparent disabled:opacity-50'
aria-label='Generate code with AI'
>
<Wand2 className='!h-[12px] !w-[12px] text-[var(--text-secondary)]' />
</button>
) : (
<input
ref={codePromptInputRef}
type='text'
value={codeGeneration.isStreaming ? 'Generating...' : codePromptInput}
onChange={(e) => handleCodePromptChange(e.target.value)}
onBlur={handleCodePromptBlur}
onKeyDown={handleCodePromptKeyDown}
disabled={codeGeneration.isStreaming}
className='h-[16px] w-full border-none bg-transparent py-0 pr-[2px] text-right font-medium text-[12px] text-[var(--text-primary)] leading-[14px] placeholder:text-[#737373] focus:outline-none'
placeholder='Describe code...'
/>
)}
</div>
{codeError &&
!codeGeneration.isStreaming && ( // Hide code error while streaming
<div className='ml-4 break-words text-red-600 text-sm'>{codeError}</div>
@@ -1049,13 +1138,7 @@ try {
value={functionCode}
onChange={handleFunctionCodeChange}
language='javascript'
showWandButton={true}
onWandClick={() => {
codeGeneration.isPromptVisible
? codeGeneration.hidePromptInline()
: codeGeneration.showPromptInline()
}}
wandButtonDisabled={codeGeneration.isLoading || codeGeneration.isStreaming}
showWandButton={false}
placeholder={
'// This code will be executed when the tool is called. You can use environment variables with {{VARIABLE_NAME}}.'
}
@@ -1117,31 +1200,50 @@ try {
{/* Schema parameters dropdown */}
{showSchemaParams && schemaParameters.length > 0 && (
<div
ref={schemaParamsDropdownRef}
className='absolute z-[9999] mt-1 w-64 overflow-visible rounded-md border bg-popover shadow-md'
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
<Popover
open={showSchemaParams}
onOpenChange={(open) => {
if (!open) {
setShowSchemaParams(false)
}
}}
>
<div className='py-1'>
<div className='px-2 pt-2.5 pb-0.5 font-medium text-muted-foreground text-xs'>
Available Parameters
</div>
<div>
<PopoverAnchor asChild>
<div
className='pointer-events-none'
style={{
position: 'absolute',
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: '1px',
height: '1px',
}}
/>
</PopoverAnchor>
<PopoverContent
maxHeight={240}
className='min-w-[260px] max-w-[260px]'
side='bottom'
align='start'
collisionPadding={6}
style={{ zIndex: 100000000 }}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PopoverScrollArea>
<PopoverSection>Available Parameters</PopoverSection>
{schemaParameters.map((param, index) => (
<button
<PopoverItem
key={param.name}
onClick={() => handleSchemaParamSelect(param.name)}
rootOnly
active={index === schemaParamSelectedIndex}
onMouseEnter={() => setSchemaParamSelectedIndex(index)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
index === schemaParamSelectedIndex &&
'bg-accent text-accent-foreground'
)}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleSchemaParamSelect(param.name)
}}
className='flex items-center gap-2'
>
<div
className='flex h-5 w-5 items-center justify-center rounded'
@@ -1151,11 +1253,11 @@ try {
</div>
<span className='flex-1 truncate'>{param.name}</span>
<span className='text-muted-foreground text-xs'>{param.type}</span>
</button>
</PopoverItem>
))}
</div>
</div>
</div>
</PopoverScrollArea>
</PopoverContent>
</Popover>
)}
</div>
<div className='h-6' />
@@ -1166,18 +1268,17 @@ try {
<DialogFooter className='mt-auto border-t px-6 py-4'>
<div className='flex w-full justify-between'>
{isEditing ? (
<Button
variant='destructive'
size='sm'
<EmcnButton
className='h-[32px] gap-1 bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={() => setShowDeleteConfirm(true)}
className='gap-1'
>
<Trash className='h-4 w-4' />
Delete
</Button>
</EmcnButton>
) : (
<Button
<EmcnButton
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => {
if (activeSection === 'code') {
setActiveSection('schema')
@@ -1186,23 +1287,30 @@ try {
disabled={activeSection === 'schema'}
>
Back
</Button>
</EmcnButton>
)}
<div className='flex space-x-2'>
<Button variant='outline' onClick={handleClose}>
<EmcnButton variant='outline' className='h-[32px] px-[12px]' onClick={handleClose}>
Cancel
</Button>
</EmcnButton>
{activeSection === 'schema' ? (
<Button
<EmcnButton
variant='primary'
className='h-[32px] px-[12px]'
onClick={() => setActiveSection('code')}
disabled={!isSchemaValid || !!schemaError}
>
Next
</Button>
</EmcnButton>
) : (
<Button onClick={handleSave} disabled={!isSchemaValid || !!schemaError}>
<EmcnButton
variant='primary'
className='h-[32px] px-[12px]'
onClick={handleSave}
disabled={!isSchemaValid || !!schemaError}
>
{isEditing ? 'Update Tool' : 'Save Tool'}
</Button>
</EmcnButton>
)}
</div>
</div>
@@ -1214,7 +1322,7 @@ try {
<Modal open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<ModalContent>
<ModalHeader>
<ModalTitle>Are you sure you want to delete this tool?</ModalTitle>
<ModalTitle>Delete custom tool?</ModalTitle>
<ModalDescription>
This will permanently delete the tool and remove it from any workflows that are using
it.{' '}
@@ -1224,19 +1332,21 @@ try {
</ModalDescription>
</ModalHeader>
<ModalFooter>
<EmcnButton
<Button
variant='outline'
className='h-[32px] px-[12px]'
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteToolMutation.isPending}
>
Cancel
</EmcnButton>
<EmcnButton
</Button>
<Button
onClick={handleDelete}
disabled={deleteToolMutation.isPending}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
Delete
</EmcnButton>
{deleteToolMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>

View File

@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/emcn/components'
import {
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalTitle,
} from '@/components/emcn/components'
import { Trash } from '@/components/emcn/icons/trash'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -364,7 +361,7 @@ export function TriggerSave({
onClick={handleSave}
disabled={disabled || isProcessing}
className={cn(
'h-9 flex-1 rounded-[8px] transition-all duration-200',
'h-[32px] flex-1 rounded-[8px] px-[12px] transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
@@ -385,7 +382,7 @@ export function TriggerSave({
variant='default'
onClick={handleDeleteClick}
disabled={disabled || isProcessing}
className='h-9 rounded-[8px] px-3'
className='h-[32px] rounded-[8px] px-[12px]'
>
{deleteStatus === 'deleting' ? (
<div className='h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -410,7 +407,7 @@ export function TriggerSave({
variant='outline'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl || isProcessing}
className='h-8 rounded-[8px]'
className='h-[32px] rounded-[8px] px-[12px]'
>
{isGeneratingTestUrl ? (
<>
@@ -454,26 +451,31 @@ export function TriggerSave({
</div>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Trigger Configuration</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this trigger configuration? This will remove the
webhook and stop all incoming triggers. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent>
<ModalTitle>Delete trigger?</ModalTitle>
<ModalDescription>
Are you sure you want to delete this trigger configuration? This will remove the webhook
and stop all incoming triggers.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</ModalDescription>
<ModalFooter>
<Button
variant='outline'
onClick={() => setShowDeleteDialog(false)}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -1,46 +0,0 @@
import type React from 'react'
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
interface ConfigFieldProps {
id: string
label: React.ReactNode // Allow complex labels (e.g., with icons)
description?: string
children: React.ReactNode
className?: string
}
export function ConfigField({ id, label, description, children, className }: ConfigFieldProps) {
return (
<div className={`space-y-2 ${className || ''}`}>
<div className='flex items-center gap-2'>
<Label htmlFor={id}>{label}</Label>
{description && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label={`Learn more about ${label?.toString() || id}`}
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{description}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
{children} {/* The actual input/select/checkbox goes here */}
</div>
)
}

View File

@@ -1,18 +0,0 @@
import type React from 'react'
import { cn } from '@/lib/utils'
interface ConfigSectionProps {
title?: string
children: React.ReactNode
className?: string
}
export function ConfigSection({ title, children, className }: ConfigSectionProps) {
return (
<div
className={cn('space-y-4 rounded-md border border-border bg-card p-4 shadow-sm', className)}
>
{children}
</div>
)
}

View File

@@ -1,82 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
interface DeleteConfirmDialogProps {
open: boolean
setOpen: (open: boolean) => void
onConfirm: () => void
isDeleting: boolean
}
export function DeleteConfirmDialog({
open,
setOpen,
onConfirm,
isDeleting,
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will delete the webhook configuration. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
interface UnsavedChangesDialogProps {
open: boolean
setOpen: (open: boolean) => void
onCancel: () => void
onConfirm: () => void
}
export function UnsavedChangesDialog({
open,
setOpen,
onCancel,
onConfirm,
}: UnsavedChangesDialogProps) {
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to close without saving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
Discard changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,94 +0,0 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface CopyableFieldProps {
id: string
value: string
onChange?: (value: string) => void
placeholder?: string
description?: string
isLoading?: boolean
copied: string | null
copyType: string
copyToClipboard: (text: string, type: string) => void
readOnly?: boolean
isSecret?: boolean
}
export function CopyableField({
id,
value,
onChange,
placeholder,
description,
isLoading = false,
copied,
copyType,
copyToClipboard,
readOnly = false,
isSecret = false,
}: CopyableFieldProps) {
const [showSecret, setShowSecret] = useState(!isSecret)
const toggleShowSecret = () => {
if (isSecret) {
setShowSecret(!showSecret)
}
}
return (
<div className='flex items-center space-x-2'>
{isLoading ? (
<div className='flex h-10 flex-1 items-center rounded-md border border-input bg-background px-3 py-2'>
<Loader2 className='h-4 w-4 animate-spin text-muted-foreground' />
</div>
) : (
<div className='relative flex-1'>
<Input
id={id}
type={isSecret && !showSecret ? 'password' : 'text'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
className={cn(
'flex-1',
isSecret ? 'pr-10' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
readOnly={readOnly}
/>
{isSecret && (
<Button
type='button'
variant='ghost'
size='icon'
className={cn(
'-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 text-muted-foreground',
'transition-colors hover:bg-transparent hover:text-foreground'
)}
onClick={toggleShowSecret}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
<span className='sr-only'>{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
)}
</div>
)}
<Button
type='button'
variant='outline'
size='icon'
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
className={cn('shrink-0', 'transition-colors hover:bg-primary/5')}
aria-label='Copy value'
>
{copied === copyType ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
</Button>
</div>
)
}

View File

@@ -1,9 +0,0 @@
export { ConfigField } from './config-field'
export { ConfigSection } from './config-section'
export { DeleteConfirmDialog, UnsavedChangesDialog } from './confirmation'
export { CopyableField } from './copyable'
export { InstructionsSection } from './instructions-section'
export { TestResultDisplay } from './test-result'
export { WebhookConfigField } from './webhook-config-field'
export { WebhookDialogFooter } from './webhook-footer'
export { WebhookUrlField } from './webhook-url'

View File

@@ -1,25 +0,0 @@
import type React from 'react'
import { cn } from '@/lib/utils'
interface InstructionsSectionProps {
title?: string
children: React.ReactNode
tip?: string
className?: string
}
export function InstructionsSection({
title = 'Setup Instructions',
children,
tip,
className,
}: InstructionsSectionProps) {
return (
<div className={cn('mt-4 rounded-md border border-border bg-card p-4 shadow-sm', className)}>
<h4 className='mb-3 font-medium text-base'>{title}</h4>
<div className='space-y-1 text-muted-foreground text-sm [&_a]:text-foreground [&_a]:underline [&_a]:hover:text-foreground/80 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs'>
{children} {/* Instructions list goes here */}
</div>
</div>
)
}

View File

@@ -1,81 +0,0 @@
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Notice } from '@/components/ui/notice'
import { cn } from '@/lib/utils'
interface TestResultDisplayProps {
testResult: {
success: boolean
message?: string
test?: {
curlCommand?: string
status?: number
contentType?: string
responseText?: string
headers?: Record<string, string>
samplePayload?: Record<string, any>
}
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
showCurlCommand?: boolean
}
export function TestResultDisplay({
testResult,
copied,
copyToClipboard,
showCurlCommand = false,
}: TestResultDisplayProps) {
if (!testResult) return null
return (
<Notice
variant={testResult.success ? 'success' : 'error'}
title={testResult.success ? 'Webhook Test Successful' : 'Webhook Test Failed'}
icon={testResult.success ? null : undefined}
className={cn(
'mb-4',
testResult.success
? 'border-green-200 bg-green-50 dark:border-green-800/50 dark:bg-green-950/20'
: 'border-red-200 bg-red-50 dark:border-red-800/50 dark:bg-red-950/20'
)}
>
<div
className={cn(
'text-sm',
testResult.success
? 'text-green-800 dark:text-green-300'
: 'text-red-800 dark:text-red-300'
)}
>
{testResult.message}
{showCurlCommand && testResult.success && testResult.test?.curlCommand && (
<div className='group relative mt-3 overflow-x-auto rounded border border-border bg-black/10 p-2 font-mono text-xs dark:bg-white/10'>
<span className='absolute top-1 left-2 font-sans text-[10px] text-muted-foreground'>
Example Request:
</span>
<Button
type='button'
variant='ghost'
size='icon'
className='absolute top-1 right-1 h-6 w-6 text-inherit opacity-70 hover:opacity-100'
onClick={() => copyToClipboard(testResult.test?.curlCommand || '', 'curl-command')}
aria-label='Copy cURL command'
>
{copied === 'curl-command' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
<pre className='whitespace-pre-wrap break-all pt-4 pr-8'>
{testResult.test.curlCommand}
</pre>
</div>
)}
</div>
</Notice>
)
}

View File

@@ -1,130 +0,0 @@
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface WebhookConfigFieldProps {
id: string
label: string
value: string
onChange?: (value: string) => void
placeholder?: string
description?: string
isLoading?: boolean
copied: string | null
copyType: string
copyToClipboard: (text: string, type: string) => void
readOnly?: boolean
isSecret?: boolean
className?: string
}
export function WebhookConfigField({
id,
label,
value,
onChange,
placeholder,
description,
isLoading = false,
copied,
copyType,
copyToClipboard,
readOnly = false,
isSecret = false,
className,
}: WebhookConfigFieldProps) {
const [showSecret, setShowSecret] = useState(!isSecret)
const toggleShowSecret = () => {
if (isSecret) {
setShowSecret(!showSecret)
}
}
return (
<div className={cn('mb-4 space-y-1', className)}>
<div className='flex items-center gap-2'>
<Label htmlFor={id} className='font-medium text-sm'>
{label}
</Label>
{description && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label={`Learn more about ${label}`}
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{description}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<div className='flex'>
<div className={cn('relative flex-1')}>
<Input
id={id}
type={isSecret && !showSecret ? 'password' : 'text'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
autoComplete='off'
className={cn(
'h-10 flex-1',
readOnly ? 'cursor-text font-mono text-xs' : '',
isSecret ? 'pr-10' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={readOnly ? (e) => (e.target as HTMLInputElement).select() : undefined}
readOnly={readOnly}
disabled={isLoading}
/>
{isSecret && (
<Button
type='button'
variant='ghost'
size='icon'
className={cn(
'-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 text-muted-foreground',
'transition-colors hover:bg-transparent hover:text-foreground'
)}
onClick={toggleShowSecret}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
<span className='sr-only'>{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
)}
</div>
<Button
type='button'
size='icon'
variant='outline'
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
>
{copied === copyType ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
</div>
)
}

View File

@@ -1,90 +0,0 @@
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface WebhookDialogFooterProps {
webhookId?: string
webhookProvider: string
isSaving: boolean
isDeleting: boolean
isLoadingToken: boolean
isTesting: boolean
isCurrentConfigValid: boolean
onSave: () => void
onDelete: () => void
onTest: () => void
onClose: () => void
}
export function WebhookDialogFooter({
webhookId,
webhookProvider,
isSaving,
isDeleting,
isLoadingToken,
isTesting,
isCurrentConfigValid,
onSave,
onDelete,
onTest,
onClose,
}: WebhookDialogFooterProps) {
return (
<div className='flex w-full justify-between'>
<div>
{webhookId && (
<Button
type='button'
variant='destructive'
onClick={onDelete}
disabled={isDeleting || isSaving || isLoadingToken}
size='default'
className='h-10'
>
{isDeleting ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<Trash2 className='mr-2 h-4 w-4' />
)}
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
<div className='flex gap-2'>
{webhookId && webhookProvider !== 'gmail' && (
<Button
type='button'
variant='outline'
onClick={onTest}
disabled={isTesting || isSaving || isDeleting || isLoadingToken || !webhookId}
className='h-10'
>
{isTesting && (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
)}
{isTesting ? 'Testing...' : 'Test Webhook'}
</Button>
)}
<Button variant='outline' onClick={onClose} size='default' className='h-10'>
Cancel
</Button>
<Button
onClick={onSave}
disabled={isLoadingToken || isSaving || !isCurrentConfigValid}
className={cn(
'h-10',
!isLoadingToken && isCurrentConfigValid ? 'bg-primary hover:bg-primary/90' : '',
isSaving &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20'
)}
size='default'
>
{isSaving && (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
)}
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)
}

View File

@@ -1,77 +0,0 @@
import { Check, Copy, Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface WebhookUrlFieldProps {
webhookUrl: string
isLoadingToken: boolean
copied: string | null
copyToClipboard: (text: string, type: string) => void
}
export function WebhookUrlField({
webhookUrl,
isLoadingToken,
copied,
copyToClipboard,
}: WebhookUrlFieldProps) {
return (
<div className='mb-4 space-y-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='webhook-url' className='font-medium text-sm'>
Webhook URL
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about webhook URL'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>URL that will receive webhook requests</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex'>
<Input
id='webhook-url'
readOnly
value={webhookUrl}
className={cn(
'h-10 flex-1 cursor-text font-mono text-xs',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={(e) => (e.target as HTMLInputElement).select()}
disabled={isLoadingToken}
/>
<Button
type='button'
size='icon'
variant='outline'
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(webhookUrl, 'url')}
disabled={isLoadingToken}
>
{copied === 'url' ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
export {
ConfigField,
ConfigSection,
CopyableField,
DeleteConfirmDialog,
InstructionsSection,
TestResultDisplay,
UnsavedChangesDialog,
WebhookConfigField,
WebhookDialogFooter,
WebhookUrlField,
} from './components'
export {
AirtableConfig,
GenericConfig,
GithubConfig,
GmailConfig,
MicrosoftTeamsConfig,
OutlookConfig,
SlackConfig,
StripeConfig,
TelegramConfig,
WhatsAppConfig,
} from './providers'
export { WebhookModal } from './webhook-modal'

View File

@@ -1,158 +0,0 @@
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button, Input, Label, Skeleton, Switch } from '@/components/ui'
import {
ConfigField,
ConfigSection,
InstructionsSection,
WebhookConfigField,
TestResultDisplay as WebhookTestResult,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface AirtableConfigProps {
baseId: string
setBaseId: (value: string) => void
tableId: string
setTableId: (value: string) => void
includeCellValues: boolean
setIncludeCellValues: (value: boolean) => void
isLoadingToken: boolean
testResult: any // Define a more specific type if possible
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook?: () => void // Optional test function
webhookId?: string // Webhook ID to enable testing
webhookUrl: string // Added webhook URL
}
export function AirtableConfig({
baseId,
setBaseId,
tableId,
setTableId,
includeCellValues,
setIncludeCellValues,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook,
webhookId,
webhookUrl,
}: AirtableConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Airtable Configuration'>
<WebhookConfigField
id='webhook-url'
label='Webhook URL'
value={webhookUrl}
description='This is the URL that will receive webhook requests'
isLoading={isLoadingToken}
copied={copied}
copyType='url'
copyToClipboard={copyToClipboard}
readOnly={true}
/>
<ConfigField
id='airtable-base-id'
label='Base ID *'
description='The ID of the Airtable Base this webhook will monitor.'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='airtable-base-id'
value={baseId}
onChange={(e) => setBaseId(e.target.value)}
placeholder='appXXXXXXXXXXXXXX'
required
/>
)}
</ConfigField>
<ConfigField
id='airtable-table-id'
label='Table ID *'
description='The ID of the table within the Base that the webhook will monitor.'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='airtable-table-id'
value={tableId}
onChange={(e) => setTableId(e.target.value)}
placeholder='tblXXXXXXXXXXXXXX'
required
/>
)}
</ConfigField>
<div className='flex items-center justify-between rounded-lg border border-border bg-background p-3 shadow-sm'>
<div className='flex items-center gap-2'>
<Label htmlFor='include-cell-values' className='font-normal'>
Include Full Record Data
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about including full record data'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>
Enable to receive the complete record data in the payload, not just changes.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingToken ? (
<Skeleton className='h-5 w-9' />
) : (
<Switch
id='include-cell-values'
checked={includeCellValues}
onCheckedChange={setIncludeCellValues}
disabled={isLoadingToken}
/>
)}
</div>
</ConfigSection>
{testResult && (
<WebhookTestResult
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
<InstructionsSection tip='Airtable webhooks monitor changes in your base/table and trigger your workflow.'>
<ol className='list-inside list-decimal space-y-1'>
<li>Ensure you have provided the correct Base ID and Table ID above.</li>
<li>
Sim will automatically configure the webhook in your Airtable account when you save.
</li>
<li>Any changes made to records in the specified table will trigger this workflow.</li>
<li>
If 'Include Full Record Data' is enabled, the entire record will be sent; otherwise,
only the changed fields are sent.
</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,130 +0,0 @@
import { Checkbox, Input, Label } from '@/components/ui'
import {
ConfigField,
ConfigSection,
CopyableField,
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface GenericConfigProps {
requireAuth: boolean
setRequireAuth: (requireAuth: boolean) => void
generalToken: string
setGeneralToken: (token: string) => void
secretHeaderName: string
setSecretHeaderName: (headerName: string) => void
allowedIps: string
setAllowedIps: (ips: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
}
export function GenericConfig({
requireAuth,
setRequireAuth,
generalToken,
setGeneralToken,
secretHeaderName,
setSecretHeaderName,
allowedIps,
setAllowedIps,
isLoadingToken,
testResult,
copied,
copyToClipboard,
}: GenericConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Authentication'>
<div className='flex items-center space-x-2'>
<Checkbox
id='require-auth'
checked={requireAuth}
onCheckedChange={(checked) => setRequireAuth(checked as boolean)}
className='translate-y-[1px]' // Align checkbox better with label
/>
<Label htmlFor='require-auth' className='cursor-pointer font-medium text-sm'>
Require Authentication
</Label>
</div>
{requireAuth && (
<div className='ml-5 space-y-4 border-border border-l-2 pl-4 dark:border-border/50'>
<ConfigField id='auth-token' label='Authentication Token'>
<CopyableField
id='auth-token'
value={generalToken}
onChange={setGeneralToken}
placeholder='Enter an auth token'
description='Used to authenticate requests via Bearer token or custom header.'
isLoading={isLoadingToken}
copied={copied}
copyType='general-token'
copyToClipboard={copyToClipboard}
/>
</ConfigField>
<ConfigField
id='header-name'
label='Secret Header Name (Optional)'
description="Custom HTTP header name for the auth token (e.g., X-Secret-Key). If blank, use 'Authorization: Bearer TOKEN'."
>
<Input
id='header-name'
value={secretHeaderName}
onChange={(e) => setSecretHeaderName(e.target.value)}
placeholder='X-Secret-Key'
/>
</ConfigField>
</div>
)}
</ConfigSection>
<ConfigSection title='Network'>
<ConfigField
id='allowed-ips'
label='Allowed IP Addresses (Optional)'
description='Comma-separated list of IP addresses allowed to access this webhook.'
>
<Input
id='allowed-ips'
value={allowedIps}
onChange={(e) => setAllowedIps(e.target.value)}
placeholder='192.168.1.1, 10.0.0.1'
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
/>
<InstructionsSection tip='The webhook receives HTTP POST requests and passes the data to your workflow.'>
<ol className='list-inside list-decimal space-y-1'>
<li>Copy the Webhook URL provided above.</li>
<li>Configure your external service to send HTTP POST requests to this URL.</li>
{requireAuth && (
<li>
Include your authentication token in requests using either the
{secretHeaderName
? ` "${secretHeaderName}" header`
: ' "Authorization: Bearer YOUR_TOKEN" header'}
.
</li>
)}
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,131 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ConfigField,
ConfigSection,
CopyableField,
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface GithubConfigProps {
contentType: string
setContentType: (contentType: string) => void
webhookSecret: string
setWebhookSecret: (secret: string) => void
sslVerification: string
setSslVerification: (value: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
}
export function GithubConfig({
contentType,
setContentType,
webhookSecret,
setWebhookSecret,
sslVerification,
setSslVerification,
isLoadingToken,
testResult,
copied,
copyToClipboard,
}: GithubConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='GitHub Webhook Settings'>
<ConfigField
id='github-content-type'
label='Content Type'
description='Format GitHub will use when sending the webhook payload.'
>
<Select value={contentType} onValueChange={setContentType} disabled={isLoadingToken}>
<SelectTrigger id='github-content-type'>
<SelectValue placeholder='Select content type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='application/json'>application/json</SelectItem>
<SelectItem value='application/x-www-form-urlencoded'>
application/x-www-form-urlencoded
</SelectItem>
</SelectContent>
</Select>
</ConfigField>
<ConfigField id='webhook-secret' label='Webhook Secret (Recommended)'>
<CopyableField
id='webhook-secret'
value={webhookSecret}
onChange={setWebhookSecret}
placeholder='Generate or enter a strong secret'
description='Validates that webhook deliveries originate from GitHub.'
isLoading={isLoadingToken}
copied={copied}
copyType='github-secret'
copyToClipboard={copyToClipboard}
/>
</ConfigField>
<ConfigField
id='github-ssl-verification'
label='SSL Verification'
description='GitHub verifies SSL certificates when delivering webhooks.'
>
<Select
value={sslVerification}
onValueChange={setSslVerification}
disabled={isLoadingToken}
>
<SelectTrigger id='github-ssl-verification'>
<SelectValue placeholder='Select SSL verification option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='enabled'>Enabled (Recommended)</SelectItem>
<SelectItem value='disabled'>Disabled (Use with caution)</SelectItem>
</SelectContent>
</Select>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true} // GitHub webhooks can be tested
/>
<InstructionsSection tip='GitHub will send a ping event to verify after you add the webhook.'>
<ol className='list-inside list-decimal space-y-1'>
<li>
Go to your GitHub Repository {'>'} Settings {'>'} Webhooks.
</li>
<li>Click "Add webhook".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Payload URL" field.
</li>
<li>Select "{contentType}" as the Content type.</li>
{webhookSecret && (
<li>
Enter the <strong>Webhook Secret</strong> (from above) into the "Secret" field.
</li>
)}
<li>Set SSL verification according to your selection above.</li>
<li>Choose which events should trigger this webhook.</li>
<li>Ensure "Active" is checked and click "Add webhook".</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,296 +0,0 @@
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
Badge,
Button,
Checkbox,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
const logger = createLogger('GmailConfig')
const TOOLTIPS = {
labels: 'Select which email labels to monitor.',
labelFilter: 'Choose whether to include or exclude the selected labels.',
markAsRead: 'Emails will be marked as read after being processed by your workflow.',
includeRawEmail: 'Include the complete, unprocessed email data from Gmail.',
}
const FALLBACK_GMAIL_LABELS = [
{ id: 'INBOX', name: 'Inbox' },
{ id: 'SENT', name: 'Sent' },
{ id: 'IMPORTANT', name: 'Important' },
{ id: 'TRASH', name: 'Trash' },
{ id: 'SPAM', name: 'Spam' },
{ id: 'STARRED', name: 'Starred' },
]
interface GmailLabel {
id: string
name: string
type?: string
messagesTotal?: number
messagesUnread?: number
}
const formatLabelName = (label: GmailLabel): string => {
const formattedName = label.name.replace(/0$/, '')
if (formattedName.startsWith('Category_')) {
return formattedName
.replace('Category_', '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
return formattedName
}
interface GmailConfigProps {
selectedLabels: string[]
setSelectedLabels: (labels: string[]) => void
labelFilterBehavior: 'INCLUDE' | 'EXCLUDE'
setLabelFilterBehavior: (behavior: 'INCLUDE' | 'EXCLUDE') => void
markAsRead?: boolean
setMarkAsRead?: (markAsRead: boolean) => void
includeRawEmail?: boolean
setIncludeRawEmail?: (includeRawEmail: boolean) => void
}
export function GmailConfig({
selectedLabels,
setSelectedLabels,
labelFilterBehavior,
setLabelFilterBehavior,
markAsRead = false,
setMarkAsRead = () => {},
includeRawEmail = false,
setIncludeRawEmail = () => {},
}: GmailConfigProps) {
const [labels, setLabels] = useState<GmailLabel[]>([])
const [isLoadingLabels, setIsLoadingLabels] = useState(false)
const [labelError, setLabelError] = useState<string | null>(null)
// Fetch Gmail labels
useEffect(() => {
let mounted = true
const fetchLabels = async () => {
setIsLoadingLabels(true)
setLabelError(null)
try {
const credentialsResponse = await fetch('/api/auth/oauth/credentials?provider=google-email')
if (!credentialsResponse.ok) {
throw new Error('Failed to get Google credentials')
}
const credentialsData = await credentialsResponse.json()
if (!credentialsData.credentials || !credentialsData.credentials.length) {
throw new Error('No Google credentials found')
}
const credentialId = credentialsData.credentials[0].id
const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`)
if (!response.ok) {
throw new Error('Failed to fetch Gmail labels')
}
const data = await response.json()
if (data.labels && Array.isArray(data.labels)) {
if (mounted) setLabels(data.labels)
} else {
throw new Error('Invalid labels data format')
}
} catch (error) {
logger.error('Error fetching Gmail labels:', error)
if (mounted) {
setLabelError('Could not fetch Gmail labels. Using default labels instead.')
setLabels(FALLBACK_GMAIL_LABELS)
}
} finally {
if (mounted) setIsLoadingLabels(false)
}
}
fetchLabels()
return () => {
mounted = false
}
}, [])
const toggleLabel = (labelId: string) => {
if (selectedLabels.includes(labelId)) {
setSelectedLabels(selectedLabels.filter((id) => id !== labelId))
} else {
setSelectedLabels([...selectedLabels, labelId])
}
}
return (
<div className='space-y-6'>
<ConfigSection>
<div className='mb-3 flex items-center gap-2'>
<h3 className='font-medium text-sm'>Email Labels to Monitor</h3>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about email labels'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{TOOLTIPS.labels}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingLabels ? (
<div className='flex flex-wrap gap-2 py-2'>
{Array(5)
.fill(0)
.map((_, i) => (
<Skeleton key={i} className='h-6 w-16 rounded-full' />
))}
</div>
) : (
<>
{labelError && (
<p className='text-amber-500 text-sm dark:text-amber-400'>{labelError}</p>
)}
<div className='mt-2 flex flex-wrap gap-2'>
{labels.map((label) => (
<Badge
key={label.id}
variant={selectedLabels.includes(label.id) ? 'default' : 'outline'}
className='cursor-pointer'
onClick={() => toggleLabel(label.id)}
>
{formatLabelName(label)}
</Badge>
))}
</div>
</>
)}
<div className='mt-4'>
<div className='flex items-center gap-2'>
<Label htmlFor='label-behavior' className='font-medium text-sm'>
Label Filter Behavior
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about label filter behavior'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{TOOLTIPS.labelFilter}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='mt-1'>
<Select value={labelFilterBehavior} onValueChange={setLabelFilterBehavior}>
<SelectTrigger id='label-behavior' className='w-full'>
<SelectValue placeholder='Select behavior' />
</SelectTrigger>
<SelectContent>
<SelectItem value='INCLUDE'>Include selected labels</SelectItem>
<SelectItem value='EXCLUDE'>Exclude selected labels</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ConfigSection>
<ConfigSection>
<h3 className='mb-3 font-medium text-sm'>Email Processing Options</h3>
<div className='space-y-3'>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='mark-as-read'
checked={markAsRead}
onCheckedChange={(checked) => setMarkAsRead(checked as boolean)}
/>
<Label htmlFor='mark-as-read' className='cursor-pointer font-normal text-sm'>
Mark emails as read after processing
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about marking emails as read'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.markAsRead}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='include-raw-email'
checked={includeRawEmail}
onCheckedChange={(checked) => setIncludeRawEmail(checked as boolean)}
/>
<Label htmlFor='include-raw-email' className='cursor-pointer font-normal text-sm'>
Include raw email data
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about raw email data'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.includeRawEmail}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
</ConfigSection>
</div>
)
}

View File

@@ -1,11 +0,0 @@
export { AirtableConfig } from './airtable'
export { GenericConfig } from './generic'
export { GithubConfig } from './github'
export { GmailConfig } from './gmail'
export { MicrosoftTeamsConfig } from './microsoftteams'
export { OutlookConfig } from './outlook'
export { SlackConfig } from './slack'
export { StripeConfig } from './stripe'
export { TelegramConfig } from './telegram'
export { WebflowConfig } from './webflow'
export { WhatsAppConfig } from './whatsapp'

View File

@@ -1,130 +0,0 @@
import { Shield, Terminal } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle, CodeBlock, Input } from '@/components/ui'
import {
ConfigField,
ConfigSection,
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface MicrosoftTeamsConfigProps {
hmacSecret: string
setHmacSecret: (secret: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
}
const teamsWebhookExample = JSON.stringify(
{
type: 'message',
id: '1234567890',
timestamp: '2023-01-01T00:00:00.000Z',
localTimestamp: '2023-01-01T00:00:00.000Z',
serviceUrl: 'https://smba.trafficmanager.net/amer/',
channelId: 'msteams',
from: {
id: '29:1234567890abcdef',
name: 'John Doe',
},
conversation: {
id: '19:meeting_abcdef@thread.v2',
},
text: 'Hello Sim Bot!',
},
null,
2
)
export function MicrosoftTeamsConfig({
hmacSecret,
setHmacSecret,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook,
}: MicrosoftTeamsConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Microsoft Teams Configuration'>
<ConfigField
id='teams-hmac-secret'
label='HMAC Secret'
description='The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.'
>
<Input
id='teams-hmac-secret'
value={hmacSecret}
onChange={(e) => setHmacSecret(e.target.value)}
placeholder='Enter HMAC secret from Teams'
disabled={isLoadingToken}
type='password'
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
/>
<InstructionsSection
title='Setting up Outgoing Webhook in Microsoft Teams'
tip='Create an outgoing webhook in Teams to receive messages from Teams in Sim.'
>
<ol className='list-inside list-decimal space-y-1'>
<li>Open Microsoft Teams and go to the team where you want to add the webhook.</li>
<li>Click the three dots () next to the team name and select "Manage team".</li>
<li>Go to the "Apps" tab and click "Create an outgoing webhook".</li>
<li>Provide a name, description, and optionally a profile picture.</li>
<li>Set the callback URL to your Sim webhook URL (shown above).</li>
<li>Copy the HMAC security token and paste it into the "HMAC Secret" field above.</li>
<li>Click "Create" to finish setup.</li>
</ol>
</InstructionsSection>
<InstructionsSection title='Receiving Messages from Teams'>
<p>
When users mention your webhook in Teams (using @mention), Teams will send a POST request
to your Sim webhook URL with a payload like this:
</p>
<CodeBlock language='json' code={teamsWebhookExample} className='mt-2 text-sm' />
<ul className='mt-3 list-outside list-disc space-y-1 pl-4'>
<li>Messages are triggered by @mentioning the webhook name in Teams.</li>
<li>Requests include HMAC signature for authentication.</li>
<li>You have 5 seconds to respond to the webhook request.</li>
</ul>
</InstructionsSection>
<Alert>
<Shield className='h-4 w-4' />
<AlertTitle>Security</AlertTitle>
<AlertDescription>
The HMAC secret is used to verify that requests are actually coming from Microsoft Teams.
Keep it secure and never share it publicly.
</AlertDescription>
</Alert>
<Alert>
<Terminal className='h-4 w-4' />
<AlertTitle>Requirements</AlertTitle>
<AlertDescription>
<ul className='mt-1 list-outside list-disc space-y-1 pl-4'>
<li>Your Sim webhook URL must use HTTPS and be publicly accessible.</li>
<li>Self-signed SSL certificates are not supported by Microsoft Teams.</li>
<li>For local testing, use a tunneling service like ngrok or Cloudflare Tunnel.</li>
</ul>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,307 +0,0 @@
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
Badge,
Button,
Checkbox,
Label,
Notice,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
const logger = createLogger('OutlookConfig')
interface OutlookFolder {
id: string
name: string
type: string
messagesTotal: number
messagesUnread: number
}
const TOOLTIPS = {
folders:
'Select which Outlook folders to monitor for new emails. Common folders include Inbox, Sent Items, Drafts, etc.',
folderFilterBehavior:
'Choose whether to include emails from the selected folders or exclude them from monitoring.',
markAsRead: 'Automatically mark emails as read after they are processed by the workflow.',
includeRawEmail:
'Include the complete, unprocessed email data from Outlook in the webhook payload. This provides access to all email metadata and headers.',
}
// Generate example payload for Outlook
interface OutlookConfigProps {
selectedLabels: string[]
setSelectedLabels: (folders: string[]) => void
labelFilterBehavior: 'INCLUDE' | 'EXCLUDE'
setLabelFilterBehavior: (behavior: 'INCLUDE' | 'EXCLUDE') => void
markAsRead?: boolean
setMarkAsRead?: (markAsRead: boolean) => void
includeRawEmail?: boolean
setIncludeRawEmail?: (includeRawEmail: boolean) => void
}
export function OutlookConfig({
selectedLabels: selectedFolders,
setSelectedLabels: setSelectedFolders,
labelFilterBehavior: folderFilterBehavior,
setLabelFilterBehavior: setFolderFilterBehavior,
markAsRead = false,
setMarkAsRead = () => {},
includeRawEmail = false,
setIncludeRawEmail = () => {},
}: OutlookConfigProps) {
const [folders, setFolders] = useState<OutlookFolder[]>([])
const [isLoadingFolders, setIsLoadingFolders] = useState(false)
const [folderError, setFolderError] = useState<string | null>(null)
// Fetch Outlook folders
useEffect(() => {
let mounted = true
const fetchFolders = async () => {
setIsLoadingFolders(true)
setFolderError(null)
try {
const credentialsResponse = await fetch('/api/auth/oauth/credentials?provider=outlook')
if (!credentialsResponse.ok) {
throw new Error('Failed to get Outlook credentials')
}
const credentialsData = await credentialsResponse.json()
if (!credentialsData.credentials || !credentialsData.credentials.length) {
throw new Error('No Outlook credentials found')
}
const credentialId = credentialsData.credentials[0].id
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (!response.ok) {
throw new Error('Failed to fetch Outlook folders')
}
const data = await response.json()
if (data.folders && Array.isArray(data.folders)) {
if (mounted) setFolders(data.folders)
} else {
throw new Error('Invalid folders data format')
}
} catch (error) {
logger.error('Error fetching Outlook folders:', error)
if (mounted) {
setFolderError(error instanceof Error ? error.message : 'Failed to fetch folders')
// Set default folders if API fails
setFolders([
{ id: 'inbox', name: 'Inbox', type: 'folder', messagesTotal: 0, messagesUnread: 0 },
{
id: 'sentitems',
name: 'Sent Items',
type: 'folder',
messagesTotal: 0,
messagesUnread: 0,
},
{ id: 'drafts', name: 'Drafts', type: 'folder', messagesTotal: 0, messagesUnread: 0 },
{
id: 'deleteditems',
name: 'Deleted Items',
type: 'folder',
messagesTotal: 0,
messagesUnread: 0,
},
])
}
} finally {
if (mounted) setIsLoadingFolders(false)
}
}
fetchFolders()
return () => {
mounted = false
}
}, [])
return (
<div className='space-y-6'>
<ConfigSection>
<div className='mb-3 flex items-center gap-2'>
<h3 className='font-medium text-sm'>Email Folders to Monitor</h3>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about email folders'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content
side='right'
align='center'
className='z-[100] max-w-[300px] p-3'
role='tooltip'
>
<p className='text-sm'>{TOOLTIPS.folders}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{isLoadingFolders ? (
<div className='space-y-2'>
<Skeleton className='h-8 w-full' />
<Skeleton className='h-8 w-full' />
</div>
) : folderError ? (
<Notice variant='warning' className='mb-4'>
<div className='flex items-start gap-2'>
<Info className='mt-0.5 h-4 w-4 flex-shrink-0' />
<div>
<p className='font-medium text-sm'>Unable to load Outlook folders</p>
<p className='text-sm'>{folderError}</p>
<p className='mt-1 text-sm'>
Using default folders. You can still configure the webhook.
</p>
</div>
</div>
</Notice>
) : null}
<div className='space-y-3'>
<div className='flex flex-wrap gap-2'>
{folders.map((folder) => {
const isSelected = selectedFolders.includes(folder.id)
return (
<Badge
key={folder.id}
variant={isSelected ? 'default' : 'secondary'}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-primary text-muted-foreground hover:bg-primary/90'
: 'hover:bg-secondary/80'
}`}
onClick={() => {
if (isSelected) {
setSelectedFolders(selectedFolders.filter((id) => id !== folder.id))
} else {
setSelectedFolders([...selectedFolders, folder.id])
}
}}
>
{folder.name}
</Badge>
)
})}
</div>
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Label htmlFor='folder-filter-behavior' className='font-normal text-sm'>
Folder behavior:
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about folder filter behavior'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.folderFilterBehavior}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Select value={folderFilterBehavior} onValueChange={setFolderFilterBehavior}>
<SelectTrigger className='w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='INCLUDE'>Include</SelectItem>
<SelectItem value='EXCLUDE'>Exclude</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ConfigSection>
<ConfigSection>
<div className='mb-3'>
<h3 className='font-medium text-sm'>Email Processing Options</h3>
</div>
<div className='space-y-4'>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='mark-as-read'
checked={markAsRead}
onCheckedChange={(checked) => setMarkAsRead(checked as boolean)}
/>
<Label htmlFor='mark-as-read' className='cursor-pointer font-normal text-sm'>
Mark emails as read after processing
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about marking emails as read'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.markAsRead}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
<div className='flex items-center'>
<div className='flex flex-1 items-center gap-2'>
<Checkbox
id='include-raw-email'
checked={includeRawEmail}
onCheckedChange={(checked) => setIncludeRawEmail(checked as boolean)}
/>
<Label htmlFor='include-raw-email' className='cursor-pointer font-normal text-sm'>
Include raw email data
</Label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-1 text-gray-500'
aria-label='Learn more about raw email data'
>
<Info className='h-4 w-4' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' className='z-[100] max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.includeRawEmail}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
</ConfigSection>
</div>
)
}

View File

@@ -1,128 +0,0 @@
import {
ConfigSection,
InstructionsSection,
TestResultDisplay,
WebhookConfigField,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface SlackConfigProps {
signingSecret: string
setSigningSecret: (secret: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
webhookUrl: string
}
export function SlackConfig({
signingSecret,
setSigningSecret,
isLoadingToken,
testResult,
copied,
copyToClipboard,
webhookUrl,
}: SlackConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Slack Configuration'>
<WebhookConfigField
id='webhook-url'
label='Webhook URL'
value={webhookUrl}
description='This is the URL that will receive webhook requests'
isLoading={isLoadingToken}
copied={copied}
copyType='url'
copyToClipboard={copyToClipboard}
readOnly={true}
/>
<WebhookConfigField
id='slack-signing-secret'
label='Signing Secret'
value={signingSecret}
onChange={setSigningSecret}
placeholder='Enter your Slack app signing secret'
description="Found on your Slack app's Basic Information page. Used to validate requests."
isLoading={isLoadingToken}
copied={copied}
copyType='slack-signing-secret'
copyToClipboard={copyToClipboard}
isSecret={true}
/>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
/>
<InstructionsSection>
<ol className='list-inside list-decimal space-y-2'>
<li>
Go to{' '}
<a
href='https://api.slack.com/apps'
target='_blank'
rel='noopener noreferrer'
className='link text-muted-foreground underline transition-colors hover:text-muted-foreground/80'
onClick={(e) => {
e.stopPropagation()
window.open('https://api.slack.com/apps', '_blank', 'noopener,noreferrer')
e.preventDefault()
}}
>
Slack Apps page
</a>
</li>
<li>
If you don't have an app:
<ol className='mt-1 ml-5 list-disc'>
<li>Create an app from scratch</li>
<li>Give it a name and select your workspace</li>
</ol>
</li>
<li>
Go to "Basic Information", find the "Signing Secret", and paste it in the field above.
</li>
<li>
Go to "OAuth & Permissions" and add bot token scopes:
<ol className='mt-1 ml-5 list-disc'>
<li>
<code>app_mentions:read</code> - For viewing messages that tag your bot with an @
</li>
<li>
<code>chat:write</code> - To send messages to channels your bot is a part of
</li>
</ol>
</li>
<li>
Go to "Event Subscriptions":
<ol className='mt-1 ml-5 list-disc'>
<li>Enable events</li>
<li>
Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages
that mention your bot
</li>
<li>Paste the Webhook URL (from above) into the "Request URL" field</li>
</ol>
</li>
<li>
Go to <strong>Install App</strong> in the left sidebar and install the app into your
desired Slack workspace and channel.
</li>
<li>Save changes in both Slack and here.</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,66 +0,0 @@
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface StripeConfigProps {
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
}
export function StripeConfig({ testResult, copied, copyToClipboard }: StripeConfigProps) {
return (
<div className='space-y-4'>
{/* No specific config fields for Stripe, just instructions */}
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={false} // Stripe requires signed requests, curl test not applicable here
/>
<InstructionsSection tip='Stripe will send a test event to verify your webhook endpoint after adding it.'>
<ol className='list-inside list-decimal space-y-1'>
<li>
Go to your{' '}
<a
href='https://dashboard.stripe.com/'
target='_blank'
rel='noopener noreferrer'
className='link'
>
Stripe Dashboard
</a>
.
</li>
<li>Navigate to Developers {'>'} Webhooks.</li>
<li>Click "Add endpoint".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Endpoint URL" field.
</li>
<li>Select the events you want to listen to (e.g., `charge.succeeded`).</li>
<li>Click "Add endpoint".</li>
</ol>
</InstructionsSection>
<Alert>
<ShieldCheck className='h-4 w-4' />
<AlertTitle>Webhook Signing</AlertTitle>
<AlertDescription>
For production use, it's highly recommended to verify Stripe webhook signatures to ensure
requests are genuinely from Stripe. Sim handles this automatically if you provide the
signing secret during setup (coming soon).
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,90 +0,0 @@
import { Input, Skeleton } from '@/components/ui'
import {
ConfigField,
ConfigSection,
InstructionsSection,
TestResultDisplay as WebhookTestResult,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface TelegramConfigProps {
botToken: string
setBotToken: (value: string) => void
isLoadingToken: boolean
testResult: any
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook?: () => void // Optional test function
webhookId?: string // Webhook ID to enable testing
webhookUrl: string // Added webhook URL
}
export function TelegramConfig({
botToken,
setBotToken,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook,
webhookId,
webhookUrl,
}: TelegramConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Telegram Configuration'>
<ConfigField
id='telegram-bot-token'
label='Bot Token *'
description='Your Telegram Bot Token from BotFather'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='telegram-bot-token'
value={botToken}
onChange={(e) => {
setBotToken(e.target.value)
}}
placeholder='123456789:ABCdefGHIjklMNOpqrsTUVwxyz'
type='password'
required
/>
)}
</ConfigField>
</ConfigSection>
{testResult && (
<WebhookTestResult
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
<InstructionsSection>
<ol className='list-inside list-decimal space-y-2'>
<li>
Message "/newbot" to{' '}
<a
href='https://t.me/BotFather'
target='_blank'
rel='noopener noreferrer'
className='link text-muted-foreground underline transition-colors hover:text-muted-foreground/80'
onClick={(e) => {
e.stopPropagation()
window.open('https://t.me/BotFather', '_blank', 'noopener,noreferrer')
e.preventDefault()
}}
>
@BotFather
</a>{' '}
in Telegram to create a bot and copy its token.
</li>
<li>Enter your Bot Token above.</li>
<li>Save settings and any message sent to your bot will trigger the workflow.</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,145 +0,0 @@
import { Input, Skeleton } from '@/components/ui'
import {
ConfigField,
ConfigSection,
InstructionsSection,
TestResultDisplay as WebhookTestResult,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface WebflowConfigProps {
siteId: string
setSiteId: (value: string) => void
collectionId?: string
setCollectionId?: (value: string) => void
formId?: string
setFormId?: (value: string) => void
isLoadingToken: boolean
testResult: any
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook?: () => void
webhookId?: string
triggerType?: string // The selected trigger type to show relevant fields
}
export function WebflowConfig({
siteId,
setSiteId,
collectionId,
setCollectionId,
formId,
setFormId,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook,
webhookId,
triggerType,
}: WebflowConfigProps) {
const isCollectionTrigger = triggerType?.includes('collection_item') || !triggerType
const isFormTrigger = triggerType?.includes('form_submission')
return (
<div className='space-y-4'>
<ConfigSection title='Webflow Configuration'>
<ConfigField
id='webflow-site-id'
label='Site ID *'
description='The ID of the Webflow site to monitor (found in site settings or URL)'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='webflow-site-id'
value={siteId}
onChange={(e) => setSiteId(e.target.value)}
placeholder='6c3592'
required
/>
)}
</ConfigField>
{isCollectionTrigger && setCollectionId && (
<ConfigField
id='webflow-collection-id'
label='Collection ID'
description='The ID of the collection to monitor (optional - leave empty to monitor all collections)'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='webflow-collection-id'
value={collectionId || ''}
onChange={(e) => setCollectionId(e.target.value)}
placeholder='68f9666257aa8abaa9b0b6d6'
/>
)}
</ConfigField>
)}
{isFormTrigger && setFormId && (
<ConfigField
id='webflow-form-id'
label='Form ID'
description='The ID of the specific form to monitor (optional - leave empty to monitor all forms)'
>
{isLoadingToken ? (
<Skeleton className='h-10 w-full' />
) : (
<Input
id='webflow-form-id'
value={formId || ''}
onChange={(e) => setFormId(e.target.value)}
placeholder='form-contact'
/>
)}
</ConfigField>
)}
</ConfigSection>
{testResult && (
<WebhookTestResult
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
<InstructionsSection tip='Webflow webhooks monitor changes in your CMS and trigger your workflow automatically.'>
<ol className='list-inside list-decimal space-y-1'>
<li>Connect your Webflow account using the credential selector above.</li>
<li>Enter your Webflow Site ID (found in the site URL or site settings).</li>
{isCollectionTrigger && (
<>
<li>
Optionally enter a Collection ID to monitor only specific collections (leave empty
to monitor all).
</li>
<li>
The webhook will trigger when items are created, changed, or deleted in the
specified collection(s).
</li>
</>
)}
{isFormTrigger && (
<>
<li>
Optionally enter a Form ID to monitor only a specific form (leave empty to monitor
all forms).
</li>
<li>The webhook will trigger whenever a form is submitted on your site.</li>
</>
)}
<li>
Sim will automatically register the webhook with Webflow when you save this
configuration.
</li>
<li>Make sure your Webflow account has appropriate permissions for the site.</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,102 +0,0 @@
import { Network } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui'
import {
ConfigField,
ConfigSection,
CopyableField,
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
interface WhatsAppConfigProps {
verificationToken: string
setVerificationToken: (token: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
}
export function WhatsAppConfig({
verificationToken,
setVerificationToken,
isLoadingToken,
testResult,
copied,
copyToClipboard,
}: WhatsAppConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='WhatsApp Configuration'>
<ConfigField
id='whatsapp-verification-token'
label='Verification Token'
description="Enter any secure token here. You'll need to provide the same token in your WhatsApp Business Platform dashboard."
>
<CopyableField
id='whatsapp-verification-token'
value={verificationToken}
onChange={setVerificationToken}
placeholder='Generate or enter a verification token'
isLoading={isLoadingToken}
copied={copied}
copyType='whatsapp-token'
copyToClipboard={copyToClipboard}
isSecret // Treat as secret
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={false} // WhatsApp uses GET for verification, not simple POST
/>
<InstructionsSection tip="After saving, click 'Verify and save' in WhatsApp and subscribe to the 'messages' webhook field.">
<ol className='list-inside list-decimal space-y-1'>
<li>
Go to your{' '}
<a
href='https://developers.facebook.com/apps/'
target='_blank'
rel='noopener noreferrer'
className='link'
>
Meta for Developers Apps
</a>{' '}
page.
</li>
<li>Select your App, then navigate to WhatsApp {'>'} Configuration.</li>
<li>Find the Webhooks section and click "Edit".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Callback URL" field.
</li>
<li>
Paste the <strong>Verification Token</strong> (from above) into the "Verify token"
field.
</li>
<li>Click "Verify and save".</li>
<li>Click "Manage" next to Webhook fields and subscribe to `messages`.</li>
</ol>
</InstructionsSection>
<Alert>
<Network className='h-4 w-4' />
<AlertTitle>Requirements</AlertTitle>
<AlertDescription>
<ul className='mt-1 list-outside list-disc space-y-1 pl-4'>
<li>Your Sim webhook URL must use HTTPS and be publicly accessible.</li>
<li>Self-signed SSL certificates are not supported by WhatsApp.</li>
<li>For local testing, use a tunneling service like ngrok or Cloudflare Tunnel.</li>
</ul>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,947 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Copy, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import {
AirtableConfig,
DeleteConfirmDialog,
GenericConfig,
GithubConfig,
GmailConfig,
MicrosoftTeamsConfig,
OutlookConfig,
SlackConfig,
StripeConfig,
TelegramConfig,
UnsavedChangesDialog,
WebhookDialogFooter,
WebhookUrlField,
WhatsAppConfig,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
import {
type ProviderConfig,
WEBHOOK_PROVIDERS,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/webhook'
const logger = createLogger('WebhookModal')
interface WebhookModalProps {
isOpen: boolean
onClose: () => void
webhookPath: string
webhookProvider: string
onSave?: (path: string, providerConfig: ProviderConfig) => Promise<boolean>
onDelete?: () => Promise<boolean>
webhookId?: string
}
export function WebhookModal({
isOpen,
onClose,
webhookPath,
webhookProvider,
onSave,
onDelete,
webhookId,
}: WebhookModalProps) {
const [copied, setCopied] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isLoadingToken, setIsLoadingToken] = useState(false)
const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false)
const [testResult, setTestResult] = useState<{
success: boolean
message?: string
test?: {
curlCommand?: string
status?: number
contentType?: string
responseText?: string
headers?: Record<string, string>
samplePayload?: Record<string, any>
}
} | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false)
const [isCurrentConfigValid, setIsCurrentConfigValid] = useState(true)
const [testUrl, setTestUrl] = useState<string>('')
const [testUrlExpiresAt, setTestUrlExpiresAt] = useState<string>('')
// Generic webhook state
const [generalToken, setGeneralToken] = useState('')
const [secretHeaderName, setSecretHeaderName] = useState('')
const [requireAuth, setRequireAuth] = useState(false)
const [allowedIps, setAllowedIps] = useState('')
// Provider-specific state
const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('')
const [githubContentType, setGithubContentType] = useState('application/json')
const [slackSigningSecret, setSlackSigningSecret] = useState('')
const [telegramBotToken, setTelegramBotToken] = useState('')
// Microsoft Teams-specific state
const [microsoftTeamsHmacSecret, setMicrosoftTeamsHmacSecret] = useState('')
// Airtable-specific state
const [airtableWebhookSecret, _setAirtableWebhookSecret] = useState('')
const [airtableBaseId, setAirtableBaseId] = useState('')
const [airtableTableId, setAirtableTableId] = useState('')
const [airtableIncludeCellValues, setAirtableIncludeCellValues] = useState(false)
// State for storing initial values to detect changes
const [originalValues, setOriginalValues] = useState({
webhookProvider,
webhookPath,
slackSigningSecret: '',
whatsappVerificationToken: '',
githubContentType: 'application/json',
generalToken: '',
secretHeaderName: '',
requireAuth: false,
allowedIps: '',
airtableWebhookSecret: '',
airtableBaseId: '',
airtableTableId: '',
airtableIncludeCellValues: false,
telegramBotToken: '',
microsoftTeamsHmacSecret: '',
selectedLabels: ['INBOX'] as string[],
labelFilterBehavior: 'INCLUDE',
markAsRead: false,
includeRawEmail: false,
})
const [selectedLabels, setSelectedLabels] = useState<string[]>(['INBOX'])
const [labelFilterBehavior, setLabelFilterBehavior] = useState<'INCLUDE' | 'EXCLUDE'>('INCLUDE')
const [markAsRead, setMarkAsRead] = useState<boolean>(false)
const [includeRawEmail, setIncludeRawEmail] = useState<boolean>(false)
// Get the current provider configuration
// const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
// Generate a random verification token if none exists
useEffect(() => {
if (
webhookProvider === 'whatsapp' &&
!whatsappVerificationToken &&
!webhookId &&
!isLoadingToken
) {
const randomToken = Math.random().toString(36).substring(2, 10)
setWhatsappVerificationToken(randomToken)
setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: randomToken }))
}
// Generate a random token for general webhook if none exists and auth is required
if (
webhookProvider === 'generic' &&
!generalToken &&
!webhookId &&
!isLoadingToken &&
requireAuth
) {
const randomToken = crypto.randomUUID()
setGeneralToken(randomToken)
setOriginalValues((prev) => ({ ...prev, generalToken: randomToken }))
}
}, [
webhookProvider,
whatsappVerificationToken,
generalToken,
webhookId,
isLoadingToken,
requireAuth,
])
// Load existing configuration values
useEffect(() => {
if (webhookId) {
// If we have a webhook ID, try to fetch the existing configuration
const fetchWebhookConfig = async () => {
try {
setIsLoadingToken(true)
const response = await fetch(`/api/webhooks/${webhookId}`)
if (response.ok) {
const data = await response.json()
if (data.webhook?.webhook?.providerConfig) {
const config = data.webhook.webhook.providerConfig
// Check provider type and set appropriate state
if (webhookProvider === 'whatsapp' && 'verificationToken' in config) {
const token = config.verificationToken || ''
setWhatsappVerificationToken(token)
setOriginalValues((prev) => ({ ...prev, whatsappVerificationToken: token }))
} else if (webhookProvider === 'github' && 'contentType' in config) {
const contentType = config.contentType || 'application/json'
setGithubContentType(contentType)
setOriginalValues((prev) => ({ ...prev, githubContentType: contentType }))
} else if (webhookProvider === 'generic') {
// Set general webhook configuration
const token = config.token || ''
const headerName = config.secretHeaderName || ''
const auth = !!config.requireAuth
const ips = Array.isArray(config.allowedIps)
? config.allowedIps.join(', ')
: config.allowedIps || ''
setGeneralToken(token)
setSecretHeaderName(headerName)
setRequireAuth(auth)
setAllowedIps(ips)
setOriginalValues((prev) => ({
...prev,
generalToken: token,
secretHeaderName: headerName,
requireAuth: auth,
allowedIps: ips,
}))
} else if (webhookProvider === 'slack' && 'signingSecret' in config) {
const signingSecret = config.signingSecret || ''
setSlackSigningSecret(signingSecret)
setOriginalValues((prev) => ({ ...prev, slackSigningSecret: signingSecret }))
} else if (webhookProvider === 'airtable') {
const baseIdVal = config.baseId || ''
const tableIdVal = config.tableId || ''
const includeCells = config.includeCellValuesInFieldIds === 'all'
setAirtableBaseId(baseIdVal)
setAirtableTableId(tableIdVal)
setAirtableIncludeCellValues(includeCells)
setOriginalValues((prev) => ({
...prev,
airtableBaseId: baseIdVal,
airtableTableId: tableIdVal,
airtableIncludeCellValues: includeCells,
}))
} else if (webhookProvider === 'telegram') {
const botToken = config.botToken || ''
setTelegramBotToken(botToken)
setOriginalValues((prev) => ({
...prev,
telegramBotToken: botToken,
}))
} else if (webhookProvider === 'gmail') {
const labelIds = config.labelIds || []
const labelFilterBehavior = config.labelFilterBehavior || 'INCLUDE'
setSelectedLabels(labelIds)
setLabelFilterBehavior(labelFilterBehavior)
setOriginalValues((prev) => ({
...prev,
selectedLabels: labelIds,
labelFilterBehavior,
}))
if (config.markAsRead !== undefined) {
setMarkAsRead(config.markAsRead)
setOriginalValues((prev) => ({ ...prev, markAsRead: config.markAsRead }))
}
if (config.includeRawEmail !== undefined) {
setIncludeRawEmail(config.includeRawEmail)
setOriginalValues((prev) => ({
...prev,
includeRawEmail: config.includeRawEmail,
}))
}
} else if (webhookProvider === 'outlook') {
const folderIds = config.folderIds || []
const folderFilterBehavior = config.folderFilterBehavior || 'INCLUDE'
setSelectedLabels(folderIds) // Reuse selectedLabels for folder IDs
setLabelFilterBehavior(folderFilterBehavior) // Reuse labelFilterBehavior for folders
setOriginalValues((prev) => ({
...prev,
selectedLabels: folderIds,
labelFilterBehavior: folderFilterBehavior,
}))
if (config.markAsRead !== undefined) {
setMarkAsRead(config.markAsRead)
setOriginalValues((prev) => ({ ...prev, markAsRead: config.markAsRead }))
}
if (config.includeRawEmail !== undefined) {
setIncludeRawEmail(config.includeRawEmail)
setOriginalValues((prev) => ({
...prev,
includeRawEmail: config.includeRawEmail,
}))
}
} else if (webhookProvider === 'microsoftteams') {
const hmacSecret = config.hmacSecret || ''
setMicrosoftTeamsHmacSecret(hmacSecret)
setOriginalValues((prev) => ({
...prev,
microsoftTeamsHmacSecret: hmacSecret,
}))
}
}
}
} catch (error) {
logger.error('Error fetching webhook config:', { error })
} finally {
setIsLoadingToken(false)
}
}
fetchWebhookConfig()
} else {
// If we don't have a webhook ID, we're creating a new one
// Reset the loading state
setIsLoadingToken(false)
}
}, [webhookId, webhookProvider])
// Check for unsaved changes
useEffect(() => {
const hasChanges =
(webhookProvider === 'whatsapp' &&
whatsappVerificationToken !== originalValues.whatsappVerificationToken) ||
(webhookProvider === 'github' && githubContentType !== originalValues.githubContentType) ||
(webhookProvider === 'generic' &&
(generalToken !== originalValues.generalToken ||
secretHeaderName !== originalValues.secretHeaderName ||
requireAuth !== originalValues.requireAuth ||
allowedIps !== originalValues.allowedIps)) ||
(webhookProvider === 'slack' && slackSigningSecret !== originalValues.slackSigningSecret) ||
(webhookProvider === 'airtable' &&
(airtableWebhookSecret !== originalValues.airtableWebhookSecret ||
airtableBaseId !== originalValues.airtableBaseId ||
airtableTableId !== originalValues.airtableTableId ||
airtableIncludeCellValues !== originalValues.airtableIncludeCellValues)) ||
(webhookProvider === 'telegram' && telegramBotToken !== originalValues.telegramBotToken) ||
(webhookProvider === 'gmail' &&
(!selectedLabels.every((label) => originalValues.selectedLabels.includes(label)) ||
!originalValues.selectedLabels.every((label) => selectedLabels.includes(label)) ||
labelFilterBehavior !== originalValues.labelFilterBehavior ||
markAsRead !== originalValues.markAsRead ||
includeRawEmail !== originalValues.includeRawEmail)) ||
(webhookProvider === 'microsoftteams' &&
microsoftTeamsHmacSecret !== originalValues.microsoftTeamsHmacSecret)
setHasUnsavedChanges(hasChanges)
}, [
webhookProvider,
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
originalValues,
slackSigningSecret,
airtableWebhookSecret,
airtableBaseId,
airtableTableId,
airtableIncludeCellValues,
telegramBotToken,
selectedLabels,
labelFilterBehavior,
markAsRead,
includeRawEmail,
microsoftTeamsHmacSecret,
])
// Validate required fields for current provider
useEffect(() => {
let isValid = true
switch (webhookProvider) {
case 'airtable':
isValid = airtableBaseId.trim() !== '' && airtableTableId.trim() !== ''
break
case 'slack':
isValid = slackSigningSecret.trim() !== ''
break
case 'whatsapp':
isValid = whatsappVerificationToken.trim() !== ''
break
case 'github':
isValid = generalToken.trim() !== ''
break
case 'telegram':
isValid = telegramBotToken.trim() !== ''
break
case 'gmail':
isValid = selectedLabels.length > 0
break
case 'microsoftteams':
isValid = microsoftTeamsHmacSecret.trim() !== ''
break
}
setIsCurrentConfigValid(isValid)
}, [
webhookProvider,
airtableBaseId,
airtableTableId,
slackSigningSecret,
whatsappVerificationToken,
telegramBotToken,
selectedLabels,
microsoftTeamsHmacSecret,
])
const formattedPath = useMemo(() => {
return webhookPath && webhookPath.trim() !== '' ? webhookPath : crypto.randomUUID()
}, [webhookPath])
// Construct the full webhook URL
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${formattedPath}`
const generateTestUrl = async () => {
if (!webhookId) return
try {
setIsGeneratingTestUrl(true)
const res = await fetch(`/api/webhooks/${webhookId}/test-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err?.error || 'Failed to generate test URL')
}
const json = await res.json()
setTestUrl(json.url)
setTestUrlExpiresAt(json.expiresAt)
} catch (e) {
logger.error('Failed to generate test webhook URL', { error: e })
} finally {
setIsGeneratingTestUrl(false)
}
}
const copyToClipboard = (text: string, type: string): void => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
const getProviderConfig = (): ProviderConfig => {
switch (webhookProvider) {
case 'whatsapp':
return { verificationToken: whatsappVerificationToken }
case 'github':
return { contentType: githubContentType }
case 'stripe':
return {}
case 'gmail':
return {
labelIds: selectedLabels,
labelFilterBehavior,
markAsRead,
includeRawEmail,
maxEmailsPerPoll: 25,
}
case 'outlook':
return {
folderIds: selectedLabels, // Reuse selectedLabels for folder IDs
folderFilterBehavior: labelFilterBehavior, // Reuse labelFilterBehavior for folders
markAsRead,
includeRawEmail,
maxEmailsPerPoll: 25,
}
case 'generic': {
// Parse the allowed IPs into an array
const parsedIps = allowedIps
? allowedIps
.split(',')
.map((ip) => ip.trim())
.filter((ip) => ip)
: []
return {
token: generalToken || undefined,
secretHeaderName: secretHeaderName || undefined,
requireAuth,
allowedIps: parsedIps.length > 0 ? parsedIps : undefined,
}
}
case 'slack':
return { signingSecret: slackSigningSecret }
case 'airtable':
return {
webhookSecret: airtableWebhookSecret || undefined,
baseId: airtableBaseId,
tableId: airtableTableId,
includeCellValuesInFieldIds: airtableIncludeCellValues ? 'all' : undefined,
}
case 'telegram':
return {
botToken: telegramBotToken || undefined,
}
case 'microsoftteams':
return {
hmacSecret: microsoftTeamsHmacSecret,
}
default:
return {}
}
}
const handleSave = async () => {
logger.debug('Saving webhook...')
if (!isCurrentConfigValid) {
logger.warn('Attempted to save with invalid configuration')
// Add user feedback for invalid configuration
setTestResult({
success: false,
message: 'Cannot save: Please fill in all required fields for the selected provider.',
})
return
}
setIsSaving(true)
try {
// Call the onSave callback with the path and provider-specific config
if (onSave) {
const providerConfig = getProviderConfig()
// Always save the path without the leading slash to match how it's queried in the API
const pathToSave = formattedPath.startsWith('/')
? formattedPath.substring(1)
: formattedPath
await new Promise((resolve) => setTimeout(resolve, 100))
const saveSuccessful = await onSave(pathToSave, providerConfig)
await new Promise((resolve) => setTimeout(resolve, 100))
if (saveSuccessful) {
setOriginalValues({
webhookProvider,
webhookPath,
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
slackSigningSecret,
airtableWebhookSecret,
airtableBaseId,
airtableTableId,
airtableIncludeCellValues,
telegramBotToken,
microsoftTeamsHmacSecret,
selectedLabels,
labelFilterBehavior,
markAsRead,
includeRawEmail,
})
setHasUnsavedChanges(false)
setTestResult({
success: true,
message: 'Webhook configuration saved successfully.',
})
} else {
setTestResult({
success: false,
message: 'Failed to save webhook configuration. Please try again.',
})
}
}
} catch (error: any) {
logger.error('Error saving webhook:', error)
setTestResult({
success: false,
message:
error instanceof Error ? error.message : 'An error occurred while saving the webhook',
})
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
if (onDelete) {
await onDelete()
setShowDeleteConfirm(false)
}
} catch (error) {
logger.error('Error deleting webhook:', { error })
} finally {
setIsDeleting(false)
}
}
const handleClose = () => {
if (hasUnsavedChanges) {
setShowUnsavedChangesConfirm(true)
} else {
onClose()
}
}
const handleCancelClose = () => {
setShowUnsavedChangesConfirm(false)
}
const handleConfirmClose = () => {
setShowUnsavedChangesConfirm(false)
onClose()
}
// Test the webhook configuration
const testWebhook = async () => {
if (!webhookId) return
try {
setIsTesting(true)
setTestResult(null)
// Use the consolidated test endpoint
const testEndpoint = `/api/webhooks/test?id=${webhookId}`
const response = await fetch(testEndpoint)
// Check if response is ok before trying to parse JSON
if (!response.ok) {
const errorText = await response.text()
let errorMessage = 'Failed to test webhook'
try {
// Try to parse as JSON, but handle case where it's not valid JSON
const errorData = JSON.parse(errorText)
errorMessage = errorData.message || errorData.error || errorMessage
} catch (_parseError) {
// If JSON parsing fails, use the raw text if it exists
errorMessage = errorText || errorMessage
}
throw new Error(errorMessage)
}
// Parse JSON only after confirming response is ok
const data = await response.json()
// If the test was successful, show a success message
if (data.success) {
setTestResult({
success: true,
message: data.message || 'Webhook configuration is valid.',
test: data.test,
})
} else {
// For Telegram, provide more specific error messages
if (webhookProvider === 'telegram') {
const errorMessage = data.message || data.error || 'Webhook test failed'
if (errorMessage.includes('SSL')) {
setTestResult({
success: false,
message:
'Telegram webhooks require HTTPS. Please ensure your domain has a valid SSL certificate.',
})
} else {
setTestResult({
success: false,
message: `Telegram webhook test failed: ${errorMessage}`,
})
}
} else {
setTestResult({
success: false,
message: data.message || data.error || 'Webhook test failed with success=false',
})
}
}
} catch (error: any) {
logger.error('Error testing webhook:', { error })
setTestResult({
success: false,
message: error.message || 'An error occurred while testing the webhook',
})
} finally {
setIsTesting(false)
}
}
// Provider-specific component rendering
const renderProviderContent = () => {
switch (webhookProvider) {
case 'whatsapp':
return (
<WhatsAppConfig
verificationToken={whatsappVerificationToken}
setVerificationToken={setWhatsappVerificationToken}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)
case 'github':
return (
<GithubConfig
contentType={githubContentType}
setContentType={setGithubContentType}
webhookSecret={generalToken}
setWebhookSecret={setGeneralToken}
sslVerification={requireAuth ? 'enabled' : 'disabled'}
setSslVerification={(value) => setRequireAuth(value === 'enabled')}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
/>
)
case 'gmail':
return (
<GmailConfig
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
labelFilterBehavior={labelFilterBehavior}
setLabelFilterBehavior={setLabelFilterBehavior}
markAsRead={markAsRead}
setMarkAsRead={setMarkAsRead}
includeRawEmail={includeRawEmail}
setIncludeRawEmail={setIncludeRawEmail}
/>
)
case 'outlook':
return (
<OutlookConfig
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
labelFilterBehavior={labelFilterBehavior}
setLabelFilterBehavior={setLabelFilterBehavior}
markAsRead={markAsRead}
setMarkAsRead={setMarkAsRead}
includeRawEmail={includeRawEmail}
setIncludeRawEmail={setIncludeRawEmail}
/>
)
case 'stripe':
return (
<StripeConfig
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)
case 'slack':
return (
<SlackConfig
signingSecret={slackSigningSecret}
setSigningSecret={setSlackSigningSecret}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookUrl={webhookUrl}
/>
)
case 'airtable':
return (
<AirtableConfig
baseId={airtableBaseId}
setBaseId={setAirtableBaseId}
tableId={airtableTableId}
setTableId={setAirtableTableId}
includeCellValues={airtableIncludeCellValues}
setIncludeCellValues={setAirtableIncludeCellValues}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookId={webhookId}
webhookUrl={webhookUrl}
/>
)
case 'telegram':
return (
<TelegramConfig
botToken={telegramBotToken}
setBotToken={setTelegramBotToken}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookId={webhookId}
webhookUrl={webhookUrl}
/>
)
case 'microsoftteams':
return (
<MicrosoftTeamsConfig
hmacSecret={microsoftTeamsHmacSecret}
setHmacSecret={setMicrosoftTeamsHmacSecret}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
/>
)
default:
return (
<GenericConfig
requireAuth={requireAuth}
setRequireAuth={setRequireAuth}
generalToken={generalToken}
setGeneralToken={setGeneralToken}
secretHeaderName={secretHeaderName}
setSecretHeaderName={setSecretHeaderName}
allowedIps={allowedIps}
setAllowedIps={setAllowedIps}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
/>
)
}
}
// Get provider name for the title
const getProviderTitle = () => {
const provider = WEBHOOK_PROVIDERS[webhookProvider] || WEBHOOK_PROVIDERS.generic
return provider.name || 'Webhook'
}
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>
{webhookId ? 'Edit' : 'Configure'} {getProviderTitle()} Webhook
</DialogTitle>
<Button variant='ghost' size='icon' className='h-8 w-8 p-0' onClick={handleClose}>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='flex-grow overflow-y-auto px-6 pt-4 pb-6'>
{webhookProvider !== 'slack' && webhookProvider !== 'airtable' && (
<WebhookUrlField
webhookUrl={webhookUrl}
isLoadingToken={isLoadingToken}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
{/* Test Webhook URL */}
{webhookId && (
<div className='mb-4 space-y-1'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Test Webhook URL</span>
</div>
<Button
variant='outline'
size='sm'
onClick={generateTestUrl}
disabled={isGeneratingTestUrl}
>
{isGeneratingTestUrl ? 'Generating…' : testUrl ? 'Regenerate' : 'Generate'}
</Button>
</div>
{testUrl ? (
<div className='flex items-center gap-2'>
<input
readOnly
value={testUrl}
className='h-9 flex-1 font-mono text-xs'
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
(e.target as HTMLInputElement).select()
}
/>
<Button
type='button'
size='icon'
variant='outline'
className='h-9 w-9'
onClick={() => copyToClipboard(testUrl, 'testUrl')}
>
{copied === 'testUrl' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
) : (
<p className='text-muted-foreground text-xs'>
Generate a temporary URL that executes this webhook against the live
(undeployed) workflow state.
</p>
)}
{testUrlExpiresAt && (
<p className='text-muted-foreground text-xs'>
Expires: {new Date(testUrlExpiresAt).toLocaleString()}
</p>
)}
</div>
)}
{renderProviderContent()}
</div>
<DialogFooter className='w-full border-t px-6 pt-0 pt-4 pb-6'>
<WebhookDialogFooter
webhookId={webhookId}
webhookProvider={webhookProvider}
isSaving={isSaving}
isDeleting={isDeleting}
isLoadingToken={isLoadingToken}
isTesting={isTesting}
isCurrentConfigValid={isCurrentConfigValid}
onSave={handleSave}
onDelete={() => setShowDeleteConfirm(true)}
onTest={testWebhook}
onClose={handleClose}
/>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={showDeleteConfirm}
setOpen={setShowDeleteConfirm}
onConfirm={handleDelete}
isDeleting={isDeleting}
/>
<UnsavedChangesDialog
open={showUnsavedChangesConfirm}
setOpen={setShowUnsavedChangesConfirm}
onCancel={handleCancelClose}
onConfirm={handleConfirmClose}
/>
</>
)
}

View File

@@ -1,861 +0,0 @@
import { useEffect, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AirtableIcon,
GithubIcon,
GmailIcon,
MicrosoftTeamsIcon,
OutlookIcon,
SlackIcon,
StripeIcon,
TelegramIcon,
WhatsAppIcon,
} from '@/components/icons'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { WebhookModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/webhook/components'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('WebhookConfig')
export interface WebhookProvider {
id: string
name: string
icon: (props: { className?: string }) => React.ReactNode
configFields: {
[key: string]: {
type: 'string' | 'boolean' | 'select'
label: string
placeholder?: string
options?: string[]
defaultValue?: string | boolean
description?: string
}
}
}
// Define provider-specific configuration types
export interface WhatsAppConfig {
verificationToken: string
}
export interface GitHubConfig {
contentType: string
}
export type StripeConfig = Record<string, never>
export interface GeneralWebhookConfig {
token?: string
secretHeaderName?: string
requireAuth?: boolean
allowedIps?: string[]
}
export interface SlackConfig {
signingSecret: string
}
export interface GmailConfig {
credentialId?: string
labelIds?: string[]
labelFilterBehavior?: 'INCLUDE' | 'EXCLUDE'
markAsRead?: boolean
includeRawEmail?: boolean
maxEmailsPerPoll?: number
}
export interface OutlookConfig {
credentialId?: string
folderIds?: string[]
folderFilterBehavior?: 'INCLUDE' | 'EXCLUDE'
markAsRead?: boolean
includeRawEmail?: boolean
maxEmailsPerPoll?: number
}
// Define Airtable-specific configuration type
export interface AirtableWebhookConfig {
baseId: string
tableId: string
externalId?: string // To store the ID returned by Airtable
includeCellValuesInFieldIds?: 'all' | undefined
webhookSecret?: string
}
export interface TelegramConfig {
botToken?: string
}
export interface MicrosoftTeamsConfig {
hmacSecret: string
}
// Union type for all provider configurations
export type ProviderConfig =
| WhatsAppConfig
| GitHubConfig
| StripeConfig
| GeneralWebhookConfig
| SlackConfig
| AirtableWebhookConfig
| TelegramConfig
| GmailConfig
| OutlookConfig
| MicrosoftTeamsConfig
| Record<string, never>
// Define available webhook providers
export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
whatsapp: {
id: 'whatsapp',
name: 'WhatsApp',
icon: (props) => <WhatsAppIcon {...props} />,
configFields: {
verificationToken: {
type: 'string',
label: 'Verification Token',
placeholder: 'Enter a verification token for WhatsApp',
description: 'This token will be used to verify your webhook with WhatsApp.',
},
},
},
github: {
id: 'github',
name: 'GitHub',
icon: (props) => <GithubIcon {...props} />,
configFields: {
contentType: {
type: 'string',
label: 'Content Type',
placeholder: 'application/json',
defaultValue: 'application/json',
description: 'The content type for GitHub webhook payloads.',
},
},
},
gmail: {
id: 'gmail',
name: 'Gmail',
icon: (props) => <GmailIcon {...props} />,
configFields: {
labelFilterBehavior: {
type: 'select',
label: 'Label Filter Behavior',
options: ['INCLUDE', 'EXCLUDE'],
defaultValue: 'INCLUDE',
description: 'Whether to include or exclude the selected labels.',
},
markAsRead: {
type: 'boolean',
label: 'Mark As Read',
defaultValue: false,
description: 'Mark emails as read after processing.',
},
includeRawEmail: {
type: 'boolean',
label: 'Include Raw Email Data',
defaultValue: false,
description: 'Include the complete, unprocessed email data from Gmail.',
},
maxEmailsPerPoll: {
type: 'string',
label: 'Max Emails Per Poll',
defaultValue: '10',
description: 'Maximum number of emails to process in each check.',
},
pollingInterval: {
type: 'string',
label: 'Polling Interval (minutes)',
defaultValue: '5',
description: 'How often to check for new emails.',
},
},
},
outlook: {
id: 'outlook',
name: 'Outlook',
icon: (props) => <OutlookIcon {...props} />,
configFields: {
folderFilterBehavior: {
type: 'select',
label: 'Folder Filter Behavior',
options: ['INCLUDE', 'EXCLUDE'],
defaultValue: 'INCLUDE',
description: 'Whether to include or exclude emails from specified folders.',
},
markAsRead: {
type: 'boolean',
label: 'Mark as Read',
defaultValue: false,
description: 'Automatically mark processed emails as read.',
},
includeRawEmail: {
type: 'boolean',
label: 'Include Raw Email Data',
defaultValue: false,
description: 'Include the complete, unprocessed email data from Outlook.',
},
maxEmailsPerPoll: {
type: 'string',
label: 'Max Emails Per Poll',
defaultValue: '10',
description: 'Maximum number of emails to process in each check.',
},
pollingInterval: {
type: 'string',
label: 'Polling Interval (minutes)',
defaultValue: '5',
description: 'How often to check for new emails.',
},
},
},
stripe: {
id: 'stripe',
name: 'Stripe',
icon: (props) => <StripeIcon {...props} />,
configFields: {},
},
generic: {
id: 'generic',
name: 'General',
icon: (props) => (
<div
className={`flex items-center justify-center rounded ${props.className || ''}`}
style={{
backgroundColor: 'var(--brand-primary-hover-hex)',
minWidth: '28px',
padding: '0 4px',
}}
>
<span className='font-medium text-white text-xs'>Sim</span>
</div>
),
configFields: {
token: {
type: 'string',
label: 'Authentication Token',
placeholder: 'Enter an auth token (optional)',
description:
'This token will be used to authenticate webhook requests via Bearer token authentication.',
},
secretHeaderName: {
type: 'string',
label: 'Secret Header Name',
placeholder: 'X-Secret-Key',
description: 'Custom HTTP header name for authentication (optional).',
},
requireAuth: {
type: 'boolean',
label: 'Require Authentication',
defaultValue: false,
description: 'Require authentication for all webhook requests.',
},
allowedIps: {
type: 'string',
label: 'Allowed IP Addresses',
placeholder: '10.0.0.1, 192.168.1.1',
description: 'Comma-separated list of allowed IP addresses (optional).',
},
},
},
slack: {
id: 'slack',
name: 'Slack',
icon: (props) => <SlackIcon {...props} />,
configFields: {
signingSecret: {
type: 'string',
label: 'Signing Secret',
placeholder: 'Enter your Slack app signing secret',
description: 'The signing secret from your Slack app to validate request authenticity.',
},
},
},
airtable: {
id: 'airtable',
name: 'Airtable',
icon: (props) => <AirtableIcon {...props} />,
configFields: {
baseId: {
type: 'string',
label: 'Base ID',
placeholder: 'appXXXXXXXXXXXXXX',
description: 'The ID of the Airtable Base the webhook should monitor.',
defaultValue: '', // Default empty, user must provide
},
tableId: {
type: 'string',
label: 'Table ID',
placeholder: 'tblXXXXXXXXXXXXXX',
description: 'The ID of the Airtable Table within the Base to monitor.',
defaultValue: '', // Default empty, user must provide
},
},
},
telegram: {
id: 'telegram',
name: 'Telegram',
icon: (props) => <TelegramIcon {...props} />,
configFields: {
botToken: {
type: 'string',
label: 'Bot Token',
placeholder: 'Enter your Telegram Bot Token',
description: 'The token for your Telegram bot.',
},
},
},
microsoftteams: {
id: 'microsoftteams',
name: 'Microsoft Teams',
icon: (props) => <MicrosoftTeamsIcon {...props} />,
configFields: {
hmacSecret: {
type: 'string',
label: 'HMAC Secret',
placeholder: 'Enter HMAC secret from Teams outgoing webhook',
description:
'The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.',
},
},
},
}
interface WebhookConfigProps {
blockId: string
subBlockId?: string
isPreview?: boolean
value?: {
webhookProvider?: string
webhookPath?: string
providerConfig?: ProviderConfig
}
disabled?: boolean
}
export function WebhookConfig({
blockId,
subBlockId,
isPreview = false,
value: propValue,
disabled = false,
}: WebhookConfigProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [webhookId, setWebhookId] = useState<string | null>(null)
const params = useParams()
const workflowId = params.workflowId as string
const [isLoading, setIsLoading] = useState(false)
// No need to manage webhook status separately - it's determined by having provider + path
// Get the webhook provider from the block state
const [storeWebhookProvider, setWebhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
// Store the webhook path
const [storeWebhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
// Store Gmail credential from the dedicated subblock
const [storeGmailCredential, setGmailCredential] = useSubBlockValue(blockId, 'gmailCredential')
// Store Outlook credential from the dedicated subblock
const [storeOutlookCredential, setOutlookCredential] = useSubBlockValue(
blockId,
'outlookCredential'
)
// Don't auto-generate webhook paths - only create them when user actually configures a webhook
// This prevents the "Active Webhook" badge from showing on unconfigured blocks
// Store provider-specific configuration
const [storeProviderConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig')
// Use prop values when available (preview mode), otherwise use store values
const webhookProvider = propValue?.webhookProvider ?? storeWebhookProvider
const webhookPath = propValue?.webhookPath ?? storeWebhookPath
const providerConfig = propValue?.providerConfig ?? storeProviderConfig
const gmailCredentialId = storeGmailCredential || ''
const outlookCredentialId = storeOutlookCredential || ''
// Store the actual provider from the database
const [actualProvider, setActualProvider] = useState<string | null>(null)
// Track the previous provider to detect changes
const [previousProvider, setPreviousProvider] = useState<string | null>(null)
// Handle provider changes - clear webhook data when switching providers
useEffect(() => {
// Skip on initial load or if no provider is set
if (!webhookProvider || !previousProvider) {
setPreviousProvider(webhookProvider)
return
}
// If the provider has changed, clear all webhook-related data
if (webhookProvider !== previousProvider) {
// IMPORTANT: Store the current webhook ID BEFORE clearing it
const currentWebhookId = webhookId
logger.info('Webhook provider changed, clearing webhook data', {
from: previousProvider,
to: webhookProvider,
blockId,
webhookId: currentWebhookId,
})
// If there's an existing webhook, delete it from the database
const deleteExistingWebhook = async () => {
if (currentWebhookId && !isPreview) {
try {
logger.info('Deleting existing webhook due to provider change', {
webhookId: currentWebhookId,
oldProvider: previousProvider,
newProvider: webhookProvider,
})
const response = await fetch(`/api/webhooks/${currentWebhookId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to delete existing webhook', {
webhookId: currentWebhookId,
error: errorData.error,
})
} else {
logger.info('Successfully deleted existing webhook', { webhookId: currentWebhookId })
const store = useSubBlockStore.getState()
const workflowValues = store.workflowValues[workflowId] || {}
const blockValues = { ...workflowValues[blockId] }
// Clear webhook-related fields
blockValues.webhookPath = undefined
blockValues.providerConfig = undefined
// Update the store with the cleaned block values
useSubBlockStore.setState({
workflowValues: {
...workflowValues,
[workflowId]: {
...workflowValues,
[blockId]: blockValues,
},
},
})
logger.info('Cleared webhook data from store after successful deletion', { blockId })
}
} catch (error: any) {
logger.error('Error deleting existing webhook', {
webhookId: currentWebhookId,
error: error.message,
})
}
}
}
// Clear webhook fields FIRST to make badge disappear immediately
// Then delete from database to prevent the webhook check useEffect from restoring the path
// IMPORTANT: Clear webhook connection data FIRST
// This prevents the webhook check useEffect from finding and restoring the webhook
setWebhookId(null)
setActualProvider(null)
// Clear provider config
setProviderConfig({})
// Clear component state
setError(null)
setGmailCredential('')
setOutlookCredential('')
// Note: Store will be cleared AFTER successful database deletion
// This ensures store and database stay perfectly in sync
// Update previous provider to the new provider
setPreviousProvider(webhookProvider)
// Delete existing webhook AFTER clearing the path to prevent race condition
// The webhook check useEffect won't restore the path if we clear it first
// Execute deletion asynchronously but don't block the UI
;(async () => {
await deleteExistingWebhook()
})()
}
}, [webhookProvider, previousProvider, blockId, webhookId, isPreview])
// Reset provider config when provider changes (legacy effect - keeping for safety)
useEffect(() => {
if (webhookProvider) {
// Reset the provider config when the provider changes
setProviderConfig({})
// Clear webhook ID and actual provider when switching providers
// This ensures the webhook status is properly reset
if (webhookProvider !== actualProvider) {
setWebhookId(null)
setActualProvider(null)
}
// Provider config is reset - webhook status will be determined by provider + path existence
}
}, [webhookProvider, webhookId, actualProvider])
// Check if webhook exists in the database
useEffect(() => {
// Skip API calls in preview mode
if (isPreview) {
setIsLoading(false)
return
}
const checkWebhook = async () => {
setIsLoading(true)
try {
// Check if there's a webhook for this specific block
// Always include blockId - every webhook should be associated with a specific block
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
setWebhookId(webhook.id)
// Don't automatically update the provider - let user control it
// The user should be able to change providers even when a webhook exists
// Store the actual provider from the database
setActualProvider(webhook.provider)
// Update the path in the block state if it's different
if (webhook.path && webhook.path !== webhookPath) {
setWebhookPath(webhook.path)
}
// Webhook found - status will be determined by provider + path existence
} else {
setWebhookId(null)
setActualProvider(null)
// IMPORTANT: Clear stale webhook data from store when no webhook found in database
// This ensures the reactive badge status updates correctly on page refresh
if (webhookPath) {
setWebhookPath('')
logger.info('Cleared stale webhook path on page refresh - no webhook in database', {
blockId,
clearedPath: webhookPath,
})
}
// No webhook found - reactive blockWebhookStatus will now be false
}
}
} catch (error) {
logger.error('Error checking webhook:', { error })
} finally {
setIsLoading(false)
}
}
checkWebhook()
}, [workflowId, blockId, isPreview]) // Removed webhookPath dependency to prevent race condition with provider changes
const handleOpenModal = () => {
if (isPreview || disabled) return
setIsModalOpen(true)
setError(null)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleSaveWebhook = async (path: string, config: ProviderConfig) => {
if (isPreview || disabled) return false
try {
setIsSaving(true)
setError(null)
// Set the webhook path in the block state
if (path && path !== webhookPath) {
setWebhookPath(path)
}
let finalConfig = config
if (webhookProvider === 'gmail' && gmailCredentialId) {
finalConfig = {
...config,
credentialId: gmailCredentialId,
}
} else if (webhookProvider === 'outlook' && outlookCredentialId) {
finalConfig = {
...config,
credentialId: outlookCredentialId,
}
}
// Set the provider config in the block state
setProviderConfig(finalConfig)
// Save the webhook to the database
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId,
blockId,
path,
provider: webhookProvider || 'generic',
providerConfig: finalConfig,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(
typeof errorData.error === 'object'
? errorData.error.message || JSON.stringify(errorData.error)
: errorData.error || 'Failed to save webhook'
)
}
const data = await response.json()
const savedWebhookId = data.webhook.id
setWebhookId(savedWebhookId)
logger.info('Webhook saved successfully', {
webhookId: savedWebhookId,
provider: webhookProvider,
path,
blockId,
})
// Update the actual provider after saving
setActualProvider(webhookProvider || 'generic')
// Webhook saved successfully - status will be determined by provider + path existence
return true
} catch (error: any) {
logger.error('Error saving webhook:', { error })
setError(error.message || 'Failed to save webhook configuration')
return false
} finally {
setIsSaving(false)
}
}
const handleDeleteWebhook = async () => {
if (isPreview || disabled) return false
try {
setIsDeleting(true)
setError(null)
const response = await fetch(`/api/webhooks/${webhookId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete webhook')
}
// Reset the startWorkflow field to manual
useSubBlockStore.getState().setValue(blockId, 'startWorkflow', 'manual')
// Remove webhook-specific fields from the block state
const store = useSubBlockStore.getState()
const workflowValues = store.workflowValues[workflowId] || {}
const blockValues = { ...workflowValues[blockId] }
// Remove webhook-related fields
blockValues.webhookProvider = undefined
blockValues.providerConfig = undefined
blockValues.webhookPath = undefined
// Update the store with the cleaned block values
store.setValue(blockId, 'startWorkflow', 'manual')
useSubBlockStore.setState({
workflowValues: {
...workflowValues,
[workflowId]: {
...workflowValues,
[blockId]: blockValues,
},
},
})
// Clear component state
setWebhookId(null)
setActualProvider(null)
// Webhook deleted - status will be determined by provider + path existence
handleCloseModal()
return true
} catch (error: any) {
logger.error('Error deleting webhook:', { error })
setError(error.message || 'Failed to delete webhook')
return false
} finally {
setIsDeleting(false)
}
}
// Get provider icon based on the current provider
const getProviderIcon = () => {
// Only show provider icon if the webhook is connected and the selected provider matches the actual provider
if (!webhookId || webhookProvider !== actualProvider) {
return null
}
const provider = WEBHOOK_PROVIDERS[webhookProvider || 'generic']
return provider.icon({
className: 'h-4 w-4 mr-2 text-green-500 dark:text-green-400',
})
}
// Check if the webhook is connected for the selected provider
const isWebhookConnected = webhookId && webhookProvider === actualProvider
// For Gmail, show configure button when credential is available and webhook not connected
if (webhookProvider === 'gmail' && !isWebhookConnected) {
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{gmailCredentialId && (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isSaving || isDeleting || !gmailCredentialId || isPreview || disabled}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
Configure Webhook
</Button>
)}
{isModalOpen && (
<WebhookModal
isOpen={isModalOpen}
onClose={handleCloseModal}
webhookPath={webhookPath || ''}
webhookProvider={webhookProvider || 'generic'}
onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}
/>
)}
</div>
)
}
// For Outlook, show configure button when credential is available and webhook not connected
if (webhookProvider === 'outlook' && !isWebhookConnected) {
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{outlookCredentialId && (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isSaving || isDeleting || !outlookCredentialId || isPreview || disabled}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
Configure Webhook
</Button>
)}
{isModalOpen && (
<WebhookModal
isOpen={isModalOpen}
onClose={handleCloseModal}
webhookPath={webhookPath || ''}
webhookProvider={webhookProvider || 'generic'}
onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}
/>
)}
</div>
)
}
return (
<div className='w-full'>
{error && <div className='mb-2 text-red-500 text-sm dark:text-red-400'>{error}</div>}
{isWebhookConnected ? (
<div className='flex flex-col space-y-2'>
<div
className='flex h-10 cursor-pointer items-center justify-center rounded border border-border bg-background px-3 py-2 transition-colors duration-200 hover:bg-accent hover:text-accent-foreground'
onClick={handleOpenModal}
>
<div className='flex items-center gap-2'>
<div className='flex items-center'>
{getProviderIcon()}
<span className='font-normal text-sm'>
{WEBHOOK_PROVIDERS[webhookProvider || 'generic'].name} Webhook
</span>
</div>
</div>
</div>
</div>
) : (
<Button
variant='outline'
size='sm'
className='flex h-10 w-full items-center bg-background font-normal text-sm'
onClick={handleOpenModal}
disabled={isSaving || isDeleting || isPreview || disabled}
>
{isLoading ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
Configure Webhook
</Button>
)}
{isModalOpen && (
<WebhookModal
isOpen={isModalOpen}
onClose={handleCloseModal}
webhookPath={webhookPath || ''}
webhookProvider={webhookProvider || 'generic'}
onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}
/>
)}
</div>
)
}

View File

@@ -42,7 +42,6 @@ import {
ToolInput,
TriggerSave,
VariablesInput,
WebhookConfig,
} from './components'
/**
@@ -590,27 +589,6 @@ function SubBlockComponent({
/>
)
case 'webhook-config': {
const webhookValue =
isPreview && subBlockValues
? {
webhookProvider: subBlockValues.webhookProvider?.value,
webhookPath: subBlockValues.webhookPath?.value,
providerConfig: subBlockValues.providerConfig?.value,
}
: previewValue
return (
<WebhookConfig
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
value={webhookValue as any}
disabled={isDisabled}
/>
)
}
case 'schedule-save':
return <ScheduleSave blockId={blockId} isPreview={isPreview} disabled={disabled} />

View File

@@ -1,270 +0,0 @@
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowApplier')
export type EditorFormat = 'json' | 'yaml'
export interface ApplyResult {
success: boolean
errors: string[]
warnings: string[]
appliedOperations: number
}
/**
* Apply workflow changes by using the new consolidated YAML endpoint
* for YAML format or direct state replacement for JSON
*/
export async function applyWorkflowDiff(
content: string,
format: EditorFormat
): Promise<ApplyResult> {
try {
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
return {
success: false,
errors: ['No active workflow found'],
warnings: [],
appliedOperations: 0,
}
}
logger.info('Starting applyWorkflowDiff', {
format,
activeWorkflowId,
contentLength: content.length,
})
if (format === 'yaml') {
logger.info('Processing YAML format - calling consolidated YAML endpoint')
try {
// Use the new consolidated YAML endpoint
const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
yamlContent: content,
source: 'editor',
applyAutoLayout: true,
createCheckpoint: false,
}),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to save YAML workflow:', errorData)
return {
success: false,
errors: [errorData.message || `HTTP ${response.status}: ${response.statusText}`],
warnings: [],
appliedOperations: 0,
}
}
const result = await response.json()
logger.info('YAML workflow save completed', {
success: result.success,
errors: result.errors || [],
warnings: result.warnings || [],
})
// Calculate applied operations (blocks + edges)
const appliedOperations = (result.data?.blocksCount || 0) + (result.data?.edgesCount || 0)
return {
success: result.success,
errors: result.errors || [],
warnings: result.warnings || [],
appliedOperations,
}
} catch (error) {
logger.error('YAML processing failed:', error)
return {
success: false,
errors: [
`YAML processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
appliedOperations: 0,
}
}
}
if (format === 'json') {
logger.info('Processing JSON format - direct state replacement')
try {
const workflowState = JSON.parse(content)
// Validate that this looks like a workflow state
if (!workflowState.blocks || !workflowState.edges) {
return {
success: false,
errors: ['Invalid workflow state: missing blocks or edges'],
warnings: [],
appliedOperations: 0,
}
}
// Use the existing workflow state endpoint for JSON
const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...workflowState,
lastSaved: Date.now(),
}),
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to save JSON workflow state:', errorData)
return {
success: false,
errors: [errorData.error || `HTTP ${response.status}: ${response.statusText}`],
warnings: [],
appliedOperations: 0,
}
}
const result = await response.json()
logger.info('JSON workflow state save completed', {
success: result.success,
blocksCount: result.blocksCount,
edgesCount: result.edgesCount,
})
// Auto layout would need to be called separately for JSON format if needed
// JSON format doesn't automatically apply auto layout like YAML does
// Calculate applied operations
const appliedOperations = (result.blocksCount || 0) + (result.edgesCount || 0)
return {
success: true,
errors: [],
warnings: [],
appliedOperations,
}
} catch (error) {
logger.error('JSON processing failed:', error)
return {
success: false,
errors: [
`JSON processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
appliedOperations: 0,
}
}
}
return {
success: false,
errors: [`Unsupported format: ${format}`],
warnings: [],
appliedOperations: 0,
}
} catch (error) {
logger.error('applyWorkflowDiff failed:', error)
return {
success: false,
errors: [
`Failed to apply workflow changes: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
warnings: [],
appliedOperations: 0,
}
}
}
/**
* Preview what changes would be applied (simplified for the new approach)
*/
export function previewWorkflowDiff(
content: string,
format: EditorFormat
): {
summary: string
operations: Array<{
type: string
description: string
}>
} {
try {
if (format === 'yaml') {
// For YAML, we would do a complete import
return {
summary: 'Complete workflow replacement from YAML',
operations: [
{
type: 'complete_replacement',
description: 'Replace entire workflow with YAML content',
},
],
}
}
// For JSON, we would do a complete state replacement
let parsedData: any
try {
parsedData = JSON.parse(content)
} catch (error) {
return {
summary: 'Invalid JSON format',
operations: [],
}
}
const operations = []
if (parsedData.state?.blocks) {
const blockCount = Object.keys(parsedData.state.blocks).length
operations.push({
type: 'replace_blocks',
description: `Replace workflow with ${blockCount} blocks`,
})
}
if (parsedData.state?.edges) {
const edgeCount = parsedData.state.edges.length
operations.push({
type: 'replace_edges',
description: `Replace connections with ${edgeCount} edges`,
})
}
if (parsedData.subBlockValues) {
operations.push({
type: 'replace_values',
description: 'Replace all input values',
})
}
if (parsedData.workflow) {
operations.push({
type: 'update_metadata',
description: 'Update workflow metadata',
})
}
return {
summary: 'Complete workflow state replacement from JSON',
operations,
}
} catch (error) {
return {
summary: 'Error analyzing changes',
operations: [],
}
}
}

View File

@@ -1,99 +0,0 @@
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { EditorFormat } from './workflow-text-editor'
const logger = createLogger('WorkflowExporter')
/**
* Get subblock values organized by block for the exporter
*/
function getSubBlockValues() {
const workflowState = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const subBlockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks).forEach(([blockId]) => {
subBlockValues[blockId] = {}
// Get all subblock values for this block
Object.keys(workflowState.blocks[blockId].subBlocks || {}).forEach((subBlockId) => {
const value = subBlockStore.getValue(blockId, subBlockId)
if (value !== undefined) {
subBlockValues[blockId][subBlockId] = value
}
})
})
return subBlockValues
}
/**
* Generate full workflow data including metadata and state
*/
export function generateFullWorkflowData() {
const workflowState = useWorkflowStore.getState()
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
if (!currentWorkflow || !activeWorkflowId) {
throw new Error('No active workflow found')
}
const subBlockValues = getSubBlockValues()
return {
workflow: {
id: activeWorkflowId,
name: currentWorkflow.name,
description: currentWorkflow.description,
color: currentWorkflow.color,
workspaceId: currentWorkflow.workspaceId,
folderId: currentWorkflow.folderId,
},
state: {
blocks: workflowState.blocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
},
subBlockValues,
exportedAt: new Date().toISOString(),
version: '1.0',
}
}
/**
* Export workflow in the specified format
*/
export async function exportWorkflow(format: EditorFormat): Promise<string> {
try {
// Always use JSON format now
const { getJson } = useWorkflowJsonStore.getState()
return await getJson()
} catch (error) {
logger.error(`Failed to export workflow:`, error)
throw error
}
}
/**
* Parse workflow content based on format
*/
export async function parseWorkflowContent(content: string, format: EditorFormat): Promise<any> {
return JSON.parse(content)
}
/**
* Convert between YAML and JSON formats
*/
export function convertBetweenFormats(
content: string,
fromFormat: EditorFormat,
toFormat: EditorFormat
): string {
// Always JSON now
return content
}

View File

@@ -1,168 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { FileCode } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { applyWorkflowDiff } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-applier'
import { exportWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter'
import { WorkflowTextEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowTextEditorModal')
interface WorkflowTextEditorModalProps {
disabled?: boolean
className?: string
}
export function WorkflowTextEditorModal({
disabled = false,
className,
}: WorkflowTextEditorModalProps) {
const [isOpen, setIsOpen] = useState(false)
const [initialContent, setInitialContent] = useState('')
const [isLoading, setIsLoading] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
// Load initial content when modal opens
useEffect(() => {
if (isOpen && activeWorkflowId) {
setIsLoading(true)
exportWorkflow('json')
.then((content) => {
setInitialContent(content)
})
.catch((error) => {
logger.error('Failed to export workflow:', error)
setInitialContent('// Error loading workflow content')
})
.finally(() => {
setIsLoading(false)
})
}
}, [isOpen, activeWorkflowId])
// Handle save operation
const handleSave = useCallback(
async (content: string) => {
if (!activeWorkflowId) {
return { success: false, errors: ['No active workflow'] }
}
try {
logger.info('Applying workflow changes from JSON editor')
const applyResult = await applyWorkflowDiff(content, 'json')
if (applyResult.success) {
logger.info('Successfully applied workflow changes', {
appliedOperations: applyResult.appliedOperations,
})
// Update initial content to reflect current state
try {
const updatedContent = await exportWorkflow('json')
setInitialContent(updatedContent)
} catch (error) {
logger.error('Failed to refresh content after save:', error)
}
}
return {
success: applyResult.success,
errors: applyResult.errors,
warnings: applyResult.warnings,
}
} catch (error) {
logger.error('Failed to save workflow changes:', error)
return {
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
}
}
},
[activeWorkflowId]
)
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (!open) {
// Reset state when closing
setInitialContent('')
}
}, [])
const isDisabled = disabled || !activeWorkflowId
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DialogTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-10 w-10 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<FileCode className='h-5 w-5' />
</div>
) : (
<Button
variant='ghost'
size='icon'
className={cn('hover:text-foreground', className)}
>
<FileCode className='h-5 w-5' />
<span className='sr-only'>Edit as Text</span>
</Button>
)}
</DialogTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
{isDisabled
? disabled
? 'Text editor not available'
: 'No active workflow'
: 'Edit as Text'}
</Tooltip.Content>
</Tooltip.Root>
<DialogContent className='flex h-[85vh] w-[90vw] max-w-6xl flex-col p-0'>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<DialogTitle>Workflow JSON Editor</DialogTitle>
<DialogDescription>
Edit your workflow as JSON. Changes will completely replace the current workflow when
you save.
</DialogDescription>
</DialogHeader>
<div className='flex-1 overflow-hidden'>
{isLoading ? (
<div className='flex h-full items-center justify-center'>
<div className='text-center'>
<div className='mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2' />
<p className='text-muted-foreground'>Loading workflow content...</p>
</div>
</div>
) : (
<WorkflowTextEditor
initialValue={initialContent}
onSave={handleSave}
disabled={isDisabled}
className='h-full rounded-none border-0'
/>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,264 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { AlertCircle, Check, FileCode, Save } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { CodeEditor } from '../panel-new/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor'
const logger = createLogger('WorkflowTextEditor')
export type EditorFormat = 'json'
interface ValidationError {
line?: number
column?: number
message: string
}
interface WorkflowTextEditorProps {
initialValue: string
onSave: (content: string) => Promise<{ success: boolean; errors?: string[]; warnings?: string[] }>
className?: string
disabled?: boolean
}
export function WorkflowTextEditor({
initialValue,
onSave,
className,
disabled = false,
}: WorkflowTextEditorProps) {
const [content, setContent] = useState(initialValue)
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([])
const [isSaving, setIsSaving] = useState(false)
const [saveResult, setSaveResult] = useState<{
success: boolean
errors?: string[]
warnings?: string[]
} | null>(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
// Validate JSON syntax
const validateSyntax = useCallback((text: string): ValidationError[] => {
const errors: ValidationError[] = []
if (!text.trim()) {
return errors
}
try {
JSON.parse(text)
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Parse error'
const lineMatch = errorMessage.match(/line (\d+)/i)
const columnMatch = errorMessage.match(/column (\d+)/i)
errors.push({
line: lineMatch ? Number.parseInt(lineMatch[1], 10) : undefined,
column: columnMatch ? Number.parseInt(columnMatch[1], 10) : undefined,
message: errorMessage,
})
}
return errors
}, [])
// Handle content changes
const handleContentChange = useCallback(
(newContent: string) => {
setContent(newContent)
setHasUnsavedChanges(newContent !== initialValue)
// Validate on change
const errors = validateSyntax(newContent)
setValidationErrors(errors)
// Clear save result when editing
setSaveResult(null)
},
[initialValue, validateSyntax]
)
// Handle save
const handleSave = useCallback(async () => {
if (validationErrors.length > 0) {
logger.warn('Cannot save with validation errors')
return
}
setIsSaving(true)
setSaveResult(null)
try {
const result = await onSave(content)
setSaveResult(result)
if (result.success) {
setHasUnsavedChanges(false)
logger.info('Workflow successfully updated from text editor')
} else {
logger.error('Failed to save workflow:', result.errors)
}
} catch (error) {
logger.error('Save failed with exception:', error)
setSaveResult({
success: false,
errors: [error instanceof Error ? error.message : 'Unknown error'],
})
} finally {
setIsSaving(false)
}
}, [content, validationErrors, onSave])
// Update content when initialValue changes
useEffect(() => {
setContent(initialValue)
setHasUnsavedChanges(false)
setSaveResult(null)
}, [initialValue])
// Validation status
const isValid = validationErrors.length === 0
const canSave = isValid && hasUnsavedChanges && !disabled
return (
<div className={cn('flex h-full flex-col bg-background', className)}>
{/* Header with controls */}
<div className='flex-shrink-0 border-b bg-background px-6 py-4'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<FileCode className='h-5 w-5' />
<span className='font-semibold'>Workflow JSON Editor</span>
</div>
<div className='flex items-center gap-2'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleSave}
disabled={!canSave || isSaving}
size='sm'
className='flex items-center gap-2'
>
<Save className='h-4 w-4' />
{isSaving ? 'Saving...' : 'Save'}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
{!isValid
? 'Fix validation errors to save'
: !hasUnsavedChanges
? 'No changes to save'
: disabled
? 'Editor is disabled'
: 'Save changes to workflow'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{/* Status indicators */}
<div className='flex items-center gap-2 text-sm'>
{isValid ? (
<div className='flex items-center gap-1 text-green-600'>
<Check className='h-4 w-4' />
Valid JSON
</div>
) : (
<div className='flex items-center gap-1 text-red-600'>
<AlertCircle className='h-4 w-4' />
{validationErrors.length} validation error{validationErrors.length !== 1 ? 's' : ''}
</div>
)}
{hasUnsavedChanges && <div className='text-orange-600'> Unsaved changes</div>}
</div>
</div>
{/* Alerts section - fixed height, scrollable if needed */}
{(validationErrors.length > 0 || saveResult) && (
<div className='max-h-32 flex-shrink-0 overflow-y-auto border-b bg-muted/20'>
<div className='space-y-2 p-4'>
{/* Validation errors */}
{validationErrors.length > 0 && (
<>
{validationErrors.map((error, index) => (
<Alert key={index} variant='destructive' className='py-2'>
<AlertCircle className='h-4 w-4' />
<AlertDescription className='text-sm'>
{error.line && error.column
? `Line ${error.line}, Column ${error.column}: ${error.message}`
: error.message}
</AlertDescription>
</Alert>
))}
</>
)}
{/* Save result */}
{saveResult && (
<Alert variant={saveResult.success ? 'default' : 'destructive'} className='py-2'>
{saveResult.success ? (
<Check className='h-4 w-4' />
) : (
<AlertCircle className='h-4 w-4' />
)}
<AlertDescription className='text-sm'>
{saveResult.success ? (
<>
Workflow updated successfully!
{saveResult.warnings && saveResult.warnings.length > 0 && (
<div className='mt-2'>
<strong>Warnings:</strong>
<ul className='mt-1 list-inside list-disc text-xs'>
{saveResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</>
) : (
<>
Failed to update workflow:
{saveResult.errors && (
<ul className='mt-1 list-inside list-disc text-xs'>
{saveResult.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
)}
</>
)}
</AlertDescription>
</Alert>
)}
</div>
</div>
)}
{/* Code editor - takes remaining space */}
<div className='min-h-0 flex-1 overflow-hidden'>
<div className='h-full p-4'>
<CodeEditor
value={content}
onChange={handleContentChange}
language='json'
placeholder='Enter JSON workflow definition...'
className={cn(
'h-full w-full overflow-auto rounded-md border',
!isValid && 'border-red-500',
hasUnsavedChanges && 'border-orange-500'
)}
minHeight='calc(100vh - 300px)'
disabled={disabled}
/>
</div>
</div>
</div>
)
}

View File

@@ -5,8 +5,10 @@ import clsx from 'clsx'
import { Database, HelpCircle, Layout, LibraryBig, Settings } from 'lucide-react'
import Link from 'next/link'
import { useParams, usePathname } from 'next/navigation'
import { HelpModal } from '../help-modal'
import { SettingsModal } from '../settings-modal'
import {
HelpModal,
SettingsModal,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new'
interface FooterNavigationItem {
id: string

View File

@@ -7,33 +7,37 @@ import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Modal, ModalContent, ModalTitle } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
Button,
Combobox,
Input,
Modal,
ModalContent,
ModalTitle,
Textarea,
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
const logger = createLogger('HelpModal')
// File upload constraints
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB maximum upload size
const TARGET_SIZE_MB = 2 // Target size after compression
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
// UI timing constants
const SCROLL_DELAY_MS = 100
const SUCCESS_RESET_DELAY_MS = 2000
// Form default values
const DEFAULT_REQUEST_TYPE = 'bug'
const REQUEST_TYPE_OPTIONS = [
{ label: 'Bug Report', value: 'bug' },
{ label: 'Feedback', value: 'feedback' },
{ label: 'Feature Request', value: 'feature_request' },
{ label: 'Other', value: 'other' },
]
const formSchema = z.object({
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
@@ -71,6 +75,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
@@ -101,6 +106,79 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
}
}, [open, reset])
/**
* Fix z-index for popover/dropdown when inside modal
*/
useEffect(() => {
if (!open) return
const updatePopoverZIndex = () => {
const allDivs = document.querySelectorAll('div')
allDivs.forEach((div) => {
const element = div as HTMLElement
const computedZIndex = window.getComputedStyle(element).zIndex
const zIndexNum = Number.parseInt(computedZIndex) || 0
if (zIndexNum === 10000001 || (zIndexNum > 0 && zIndexNum <= 10000100)) {
const hasPopoverStructure =
element.hasAttribute('data-radix-popover-content') ||
(element.hasAttribute('role') && element.getAttribute('role') === 'dialog') ||
element.querySelector('[role="listbox"]') !== null ||
element.classList.contains('rounded-[8px]') ||
element.classList.contains('rounded-[4px]')
if (hasPopoverStructure && element.offsetParent !== null) {
element.style.zIndex = '10000101'
}
}
})
}
// Create a style element to override popover z-index
const styleId = 'help-modal-popover-z-index'
let styleElement = document.getElementById(styleId) as HTMLStyleElement | null
if (!styleElement) {
styleElement = document.createElement('style')
styleElement.id = styleId
document.head.appendChild(styleElement)
}
styleElement.textContent = `
[data-radix-popover-content] {
z-index: 10000101 !important;
}
div[style*="z-index: 10000001"],
div[style*="z-index:10000001"] {
z-index: 10000101 !important;
}
`
const observer = new MutationObserver(() => {
updatePopoverZIndex()
})
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
})
updatePopoverZIndex()
const intervalId = setInterval(updatePopoverZIndex, 100)
return () => {
const element = document.getElementById(styleId)
if (element) {
element.remove()
}
observer.disconnect()
clearInterval(intervalId)
}
}, [open])
/**
* Set default form value for request type
*/
@@ -378,27 +456,19 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
>
Request
</Label>
<Select
defaultValue={DEFAULT_REQUEST_TYPE}
onValueChange={(value) => setValue('type', value as FormValues['type'])}
>
<SelectTrigger
id='type'
className={cn(
'h-9 rounded-[4px] border-[var(--surface-11)] bg-[var(--surface-6)] text-[13px] dark:bg-[var(--surface-9)]',
errors.type &&
'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
>
<SelectValue placeholder='Select a request type' />
</SelectTrigger>
<SelectContent className='z-[10000200]'>
<SelectItem value='bug'>Bug Report</SelectItem>
<SelectItem value='feedback'>Feedback</SelectItem>
<SelectItem value='feature_request'>Feature Request</SelectItem>
<SelectItem value='other'>Other</SelectItem>
</SelectContent>
</Select>
<Combobox
id='type'
options={REQUEST_TYPE_OPTIONS}
value={watch('type') || DEFAULT_REQUEST_TYPE}
selectedValue={watch('type') || DEFAULT_REQUEST_TYPE}
onChange={(value) => setValue('type', value as FormValues['type'])}
placeholder='Select a request type'
editable={false}
filterOptions={false}
className={cn(
errors.type && 'border-[var(--text-error)] dark:border-[var(--text-error)]'
)}
/>
{errors.type && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
{errors.type.message}
@@ -509,28 +579,28 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
<Label className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Uploaded Images
</Label>
<div className='grid grid-cols-2 gap-4'>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div
key={index}
className='group relative overflow-hidden rounded-[4px] border border-[var(--surface-11)]'
>
<div className='relative aspect-video'>
<div className='relative flex max-h-[120px] min-h-[80px] w-full items-center justify-center bg-[var(--surface-2)]'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
className='object-cover'
className='object-contain'
/>
<button
type='button'
className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeImage(index)}
>
<X className='h-6 w-6 text-white' />
<X className='h-5 w-5 text-white' />
</button>
</div>
<div className='truncate bg-[var(--surface-5)] p-2 text-[12px] text-[var(--text-secondary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-secondary)]'>
<div className='truncate bg-[var(--surface-5)] p-1.5 text-[12px] text-[var(--text-secondary)] dark:bg-[var(--surface-5)] dark:text-[var(--text-secondary)]'>
{image.name}
</div>
</div>
@@ -549,24 +619,11 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
variant='default'
onClick={handleClose}
type='button'
className='h-[32px] px-[12px] font-medium text-[13px]'
disabled={isSubmitting}
>
Cancel
</Button>
<button
type='submit'
disabled={isSubmitting || isProcessing}
className={cn(
'flex h-[32px] items-center justify-center gap-[8px] rounded-[8px] px-[12px] font-medium text-[13px] text-white transition-all duration-200',
submitStatus === 'error'
? 'bg-[var(--text-error)] hover:opacity-90 dark:bg-[var(--text-error)]'
: submitStatus === 'success'
? 'bg-green-500 hover:opacity-90'
: 'bg-[var(--brand-primary-hex)] shadow-[0_0_0_0_var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hex)] disabled:hover:shadow-none'
)}
>
<Button type='submit' variant='primary' disabled={isSubmitting || isProcessing}>
{isSubmitting && <Loader2 className='h-4 w-4 animate-spin' />}
{isSubmitting
? 'Submitting...'
@@ -575,7 +632,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
: submitStatus === 'success'
? 'Success'
: 'Submit'}
</button>
</Button>
</div>
</div>
</form>

View File

@@ -1 +0,0 @@
export { HelpModal } from './help-modal'

View File

@@ -1,7 +1,7 @@
export { FooterNavigation } from './footer-navigation'
export { HelpModal } from './help-modal'
export { SearchModal } from './search-modal'
export { SettingsModal } from './settings-modal'
export { HelpModal } from './help-modal/help-modal'
export { SearchModal } from './search-modal/search-modal'
export { SettingsModal } from './settings-modal/settings-modal'
export { UsageIndicator } from './usage-indicator/usage-indicator'
export { WorkflowList } from './workflow-list/workflow-list'
export { WorkspaceHeader } from './workspace-header'

View File

@@ -1 +0,0 @@
export { SearchModal } from './search-modal'

View File

@@ -539,6 +539,7 @@ export function SearchModal({
key={`${item.type}-${item.id}`}
data-search-item-index={globalIndex}
onClick={() => handleItemClick(item)}
onMouseDown={(e) => e.preventDefault()}
className={cn(
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[var(--surface-4)]/60 px-[10px] text-left text-[15px] transition-all focus:outline-none dark:bg-[var(--surface-4)]/60',
isSelected

View File

@@ -4,8 +4,8 @@ import { useEffect, useRef, useState } from 'react'
import { Camera } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { signOut } from '@/lib/auth-client'
@@ -306,11 +306,7 @@ export function Account(_props: AccountProps) {
{/* Sign Out Button */}
<div>
<Button
onClick={handleSignOut}
variant='destructive'
className='h-8 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
>
<Button onClick={handleSignOut} variant='outline'>
Sign Out
</Button>
</div>

View File

@@ -84,7 +84,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
@@ -168,7 +167,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
setShowDeleteDialog(false)
setDeleteKey(null)
setDeleteConfirmationName('')
await deleteApiKeyMutation.mutateAsync({
workspaceId,
@@ -458,7 +456,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Create new API key</ModalTitle>
<ModalDescription>
@@ -561,7 +559,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}}
>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Your API key has been created</ModalTitle>
<ModalDescription>
@@ -592,51 +590,34 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Delete API key?</ModalTitle>
<ModalDescription>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
{deleteKey && (
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the API key name <span className='font-semibold'>{deleteKey.name}</span> to
confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Type key name to confirm'
className='h-9 rounded-[8px]'
autoFocus
/>
</div>
)}
<ModalFooter className='flex'>
<ModalFooter>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => {
setShowDeleteDialog(false)
setDeleteKey(null)
setDeleteConfirmationName('')
}}
disabled={deleteApiKeyMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => {
handleDeleteKey()
setDeleteConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={!deleteKey || deleteConfirmationName !== deleteKey.name}
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={handleDeleteKey}
disabled={deleteApiKeyMutation.isPending}
>
Delete
{deleteApiKeyMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -3,7 +3,16 @@
import { useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Label } from '@/components/emcn'
import {
Button,
Label,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
@@ -41,6 +50,8 @@ export function CustomTools() {
const [deletingTools, setDeletingTools] = useState<Set<string>>(new Set())
const [editingTool, setEditingTool] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
const [toolToDelete, setToolToDelete] = useState<{ id: string; name: string } | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const filteredTools = tools.filter((tool) => {
if (!searchTerm.trim()) return true
@@ -52,26 +63,42 @@ export function CustomTools() {
)
})
const handleDeleteTool = async (toolId: string) => {
const handleDeleteClick = (toolId: string) => {
const tool = tools.find((t) => t.id === toolId)
if (!tool) return
setDeletingTools((prev) => new Set(prev).add(toolId))
setToolToDelete({
id: toolId,
name: tool.title || tool.schema?.function?.name || 'this custom tool',
})
setShowDeleteDialog(true)
}
const handleDeleteTool = async () => {
if (!toolToDelete) return
const tool = tools.find((t) => t.id === toolToDelete.id)
if (!tool) return
setDeletingTools((prev) => new Set(prev).add(toolToDelete.id))
setShowDeleteDialog(false)
try {
// Pass null workspaceId for user-scoped tools (legacy tools without workspaceId)
await deleteToolMutation.mutateAsync({
workspaceId: tool.workspaceId ?? null,
toolId,
toolId: toolToDelete.id,
})
logger.info(`Deleted custom tool: ${toolId}`)
logger.info(`Deleted custom tool: ${toolToDelete.id}`)
} catch (error) {
logger.error('Error deleting custom tool:', error)
} finally {
setDeletingTools((prev) => {
const next = new Set(prev)
next.delete(toolId)
next.delete(toolToDelete.id)
return next
})
setToolToDelete(null)
}
}
@@ -156,7 +183,7 @@ export function CustomTools() {
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteTool(tool.id)}
onClick={() => handleDeleteClick(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8'
>
@@ -229,6 +256,42 @@ export function CustomTools() {
: undefined
}
/>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent>
<ModalHeader>
<ModalTitle>Delete custom tool?</ModalTitle>
<ModalDescription>
Deleting "{toolToDelete?.name}" will permanently remove this custom tool from your
workspace.{' '}
<span className='text-[var(--text-error)] dark:text-[var(--text-error)]'>
This action cannot be undone.
</span>
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button
className='h-[32px] px-[12px]'
variant='outline'
onClick={() => {
setShowDeleteDialog(false)
setToolToDelete(null)
}}
disabled={deleteToolMutation.isPending}
>
Cancel
</Button>
<Button
className='h-[32px] bg-[var(--text-error)] px-[12px] text-[var(--white)] hover:bg-[var(--text-error)] hover:text-[var(--white)] dark:bg-[var(--text-error)] dark:text-[var(--white)] hover:dark:bg-[var(--text-error)] dark:hover:text-[var(--white)]'
onClick={handleDeleteTool}
disabled={deleteToolMutation.isPending}
>
{deleteToolMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -220,7 +220,8 @@ export function General() {
/>
</div>
<div className='flex items-center justify-between'>
{/* TODO: Add floating controls back when we implement the new UI for it */}
{/* <div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='floating-controls' className='font-normal'>
Floating controls
@@ -248,7 +249,7 @@ export function General() {
onCheckedChange={handleFloatingControlsChange}
disabled={updateSetting.isPending}
/>
</div>
</div> */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>

View File

@@ -1,13 +1,14 @@
import { RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -97,7 +98,7 @@ export function NoOrganizationView({
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
className='h-9 rounded-[8px]'
className='h-[32px] px-[12px]'
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
@@ -106,14 +107,14 @@ export function NoOrganizationView({
</div>
</div>
<Dialog open={createOrgDialogOpen} onOpenChange={setCreateOrgDialogOpen}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='font-medium text-sm'>Create Team Organization</DialogTitle>
<DialogDescription className='text-muted-foreground text-xs'>
<Modal open={createOrgDialogOpen} onOpenChange={setCreateOrgDialogOpen}>
<ModalContent className='sm:max-w-md'>
<ModalHeader>
<ModalTitle>Create Team Organization</ModalTitle>
<ModalDescription>
Create a new team organization to manage members and billing.
</DialogDescription>
</DialogHeader>
</ModalDescription>
</ModalHeader>
<div className='space-y-4'>
{error && (
@@ -150,28 +151,28 @@ export function NoOrganizationView({
className='mt-1'
/>
</div>
<div className='flex justify-end gap-2 pt-2'>
<Button
variant='outline'
onClick={() => setCreateOrgDialogOpen(false)}
disabled={isCreatingOrg}
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button
onClick={onCreateOrganization}
disabled={isCreatingOrg || !orgName.trim()}
className='h-9 rounded-[8px]'
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Organization
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ModalFooter>
<Button
variant='outline'
onClick={() => setCreateOrgDialogOpen(false)}
disabled={isCreatingOrg}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
<Button
onClick={onCreateOrganization}
disabled={isCreatingOrg || !orgName.trim()}
className='h-[32px] px-[12px]'
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Organization
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}
@@ -193,7 +194,7 @@ export function NoOrganizationView({
})
window.dispatchEvent(event)
}}
className='h-9 rounded-[8px]'
className='h-[32px] px-[12px]'
>
Upgrade to Team Plan
</Button>

View File

@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react'
import { Button, Combobox, type ComboboxOption, Tooltip } from '@/components/emcn'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
Button,
Combobox,
type ComboboxOption,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
Tooltip,
} from '@/components/emcn'
import { Label } from '@/components/ui/label'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { env } from '@/lib/env'
@@ -61,12 +64,12 @@ export function TeamSeats({
}))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='z-[100000000]'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent>
<ModalHeader>
<ModalTitle>{title}</ModalTitle>
<ModalDescription>{description}</ModalDescription>
</ModalHeader>
<div className='py-4'>
<Label htmlFor='seats'>Number of seats</Label>
@@ -102,8 +105,13 @@ export function TeamSeats({
)}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isLoading}>
<ModalFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isLoading}
className='h-[32px] px-[12px]'
>
Cancel
</Button>
@@ -118,6 +126,7 @@ export function TeamSeats({
(showCostBreakdown && selectedSeats === currentSeats) ||
isCancelledAtPeriodEnd
}
className='h-[32px] px-[12px]'
>
{isLoading ? (
<div className='flex items-center space-x-2'>
@@ -139,8 +148,8 @@ export function TeamSeats({
</Tooltip.Content>
)}
</Tooltip.Root>
</DialogFooter>
</DialogContent>
</Dialog>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -1 +0,0 @@
export { SettingsModal } from './settings-modal'

View File

@@ -1 +0,0 @@
export { InviteModal } from './invite-modal/invite-modal'

View File

@@ -18,7 +18,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '../workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '../workflow-list/components/delete-modal/delete-modal'
import { InviteModal } from './components'
import { InviteModal } from './components/invite-modal/invite-modal'
const logger = createLogger('WorkspaceHeader')

View File

@@ -25,7 +25,9 @@ import {
const logger = createLogger('UseKnowledgeBase')
export function useKnowledgeBase(id: string) {
const queryClient = useQueryClient()
const query = useKnowledgeBaseQuery(id)
useEffect(() => {
if (query.data) {
const knowledgeBase = query.data
@@ -38,10 +40,17 @@ export function useKnowledgeBase(id: string) {
}
}, [query.data])
const refreshKnowledgeBase = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(id),
})
}, [queryClient, id])
return {
knowledgeBase: query.data ?? null,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : null,
refresh: refreshKnowledgeBase,
}
}