mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { FrozenCanvas } from './frozen-canvas'
|
||||
export { FrozenCanvasModal } from './frozen-canvas-modal'
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { HelpModal } from './help-modal'
|
||||
@@ -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'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { SearchModal } from './search-modal'
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { SettingsModal } from './settings-modal'
|
||||
@@ -1 +0,0 @@
|
||||
export { InviteModal } from './invite-modal/invite-modal'
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user