mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
fix(templates): added option to delete/keep templates when deleting workspace, updated template modal, sidebar code cleanup (#1086)
* feat(templates): added in the ability to keep/remove templates when deleting workspace * code cleanup in sidebar * add the ability to edit existing templates * updated template modal * fix build * revert bun.lock * add template logic to workflow deletion as well * add ability to delete templates * add owner/admin enforcemnet to modify or delete templates
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { templates } from '@/db/schema'
|
||||
import { templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(100),
|
||||
category: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i),
|
||||
state: z.any().optional(), // Workflow state
|
||||
})
|
||||
|
||||
// PUT /api/templates/[id] - Update a template
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validationResult = updateTemplateSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid template data', details: validationResult.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, description, author, category, icon, color, state } = validationResult.data
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for update: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Permission: template owner OR admin of the workflow's workspace (if any)
|
||||
let canUpdate = existingTemplate[0].userId === session.user.id
|
||||
|
||||
if (!canUpdate && existingTemplate[0].workflowId) {
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update the template
|
||||
const updatedTemplate = await db
|
||||
.update(templates)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
author,
|
||||
category,
|
||||
icon,
|
||||
color,
|
||||
...(state && { state }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated template: ${id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
data: updatedTemplate[0],
|
||||
message: 'Template updated successfully',
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error updating template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/templates/[id] - Delete a template
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch template
|
||||
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
if (existing.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const template = existing[0]
|
||||
|
||||
// Permission: owner or admin of the workflow's workspace (if any)
|
||||
let canDelete = template.userId === session.user.id
|
||||
|
||||
if (!canDelete && template.workflowId) {
|
||||
// Look up workflow to get workspaceId
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canDelete = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Deleted template: ${id}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
search: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/templates - Retrieve templates
|
||||
@@ -111,6 +112,11 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Apply workflow filter if provided (for getting template by workflow)
|
||||
if (params.workflowId) {
|
||||
conditions.push(eq(templates.workflowId, params.workflowId))
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
|
||||
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
@@ -218,6 +218,48 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
const deleteTemplatesParam = searchParams.get('deleteTemplates')
|
||||
|
||||
if (checkTemplates) {
|
||||
// Return template information for frontend to handle
|
||||
const publishedTemplates = await db
|
||||
.select()
|
||||
.from(templates)
|
||||
.where(eq(templates.workflowId, workflowId))
|
||||
|
||||
return NextResponse.json({
|
||||
hasPublishedTemplates: publishedTemplates.length > 0,
|
||||
count: publishedTemplates.length,
|
||||
publishedTemplates: publishedTemplates.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
views: t.views,
|
||||
stars: t.stars,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Handle template deletion based on user choice
|
||||
if (deleteTemplatesParam !== null) {
|
||||
const deleteTemplates = deleteTemplatesParam === 'delete'
|
||||
|
||||
if (deleteTemplates) {
|
||||
// Delete all templates associated with this workflow
|
||||
await db.delete(templates).where(eq(templates.workflowId, workflowId))
|
||||
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
|
||||
} else {
|
||||
// Orphan the templates (set workflowId to null)
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ workflowId: null })
|
||||
.where(eq(templates.workflowId, workflowId))
|
||||
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI')
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { knowledgeBase, permissions, workspace } from '@/db/schema'
|
||||
import { knowledgeBase, permissions, templates, workspace } from '@/db/schema'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
@@ -19,6 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
const url = new URL(request.url)
|
||||
const checkTemplates = url.searchParams.get('check-templates') === 'true'
|
||||
|
||||
// Check if user has any access to this workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
@@ -26,6 +28,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If checking for published templates before deletion
|
||||
if (checkTemplates) {
|
||||
try {
|
||||
// Get all workflows in this workspace
|
||||
const workspaceWorkflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
if (workspaceWorkflows.length === 0) {
|
||||
return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] })
|
||||
}
|
||||
|
||||
const workflowIds = workspaceWorkflows.map((w) => w.id)
|
||||
|
||||
// Check for published templates that reference these workflows
|
||||
const publishedTemplates = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
workflowId: templates.workflowId,
|
||||
})
|
||||
.from(templates)
|
||||
.where(inArray(templates.workflowId, workflowIds))
|
||||
|
||||
return NextResponse.json({
|
||||
hasPublishedTemplates: publishedTemplates.length > 0,
|
||||
publishedTemplates,
|
||||
count: publishedTemplates.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
@@ -108,6 +146,8 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates
|
||||
|
||||
// Check if user has admin permissions to delete workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
@@ -116,10 +156,39 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
|
||||
logger.info(
|
||||
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
||||
)
|
||||
|
||||
// Delete workspace and all related data in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Get all workflows in this workspace before deletion
|
||||
const workspaceWorkflows = await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
if (workspaceWorkflows.length > 0) {
|
||||
const workflowIds = workspaceWorkflows.map((w) => w.id)
|
||||
|
||||
// Handle templates based on user choice
|
||||
if (deleteTemplates) {
|
||||
// Delete published templates that reference these workflows
|
||||
await tx.delete(templates).where(inArray(templates.workflowId, workflowIds))
|
||||
logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
|
||||
} else {
|
||||
// Set workflowId to null for templates to create "orphaned" templates
|
||||
// This allows templates to remain in marketplace but without source workflows
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({ workflowId: null })
|
||||
.where(inArray(templates.workflowId, workflowIds))
|
||||
logger.info(
|
||||
`Updated templates to orphaned status for workflows in workspace ${workspaceId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all workflows in the workspace - database cascade will handle all workflow-related data
|
||||
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
|
||||
// workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,
|
||||
|
||||
@@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value']
|
||||
// Template data structure
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string
|
||||
workflowId: string | null
|
||||
userId: string
|
||||
name: string
|
||||
description: string | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Award,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
@@ -48,6 +49,16 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ColorPicker } from '@/components/ui/color-picker'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -68,6 +79,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -100,7 +112,6 @@ interface TemplateModalProps {
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
// Enhanced icon selection with category-relevant icons
|
||||
const icons = [
|
||||
// Content & Documentation
|
||||
{ value: 'FileText', label: 'File Text', component: FileText },
|
||||
@@ -165,6 +176,10 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
@@ -178,6 +193,63 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
},
|
||||
})
|
||||
|
||||
// Watch form state to determine if all required fields are valid
|
||||
const formValues = form.watch()
|
||||
const isFormValid =
|
||||
form.formState.isValid &&
|
||||
formValues.name?.trim() &&
|
||||
formValues.description?.trim() &&
|
||||
formValues.author?.trim() &&
|
||||
formValues.category
|
||||
|
||||
// Check for existing template when modal opens
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
checkExistingTemplate()
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
|
||||
// Pre-fill form with existing template data
|
||||
if (template) {
|
||||
form.reset({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
category: template.category,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
})
|
||||
} else {
|
||||
// No existing template found
|
||||
setExistingTemplate(null)
|
||||
// Reset form to defaults
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
category: '',
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
logger.error('User not authenticated')
|
||||
@@ -201,21 +273,36 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
state: templateState,
|
||||
}
|
||||
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update existing template
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
} else {
|
||||
// Create new template
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create template')
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info('Template created successfully:', result)
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Reset form and close modal
|
||||
form.reset()
|
||||
@@ -241,7 +328,35 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
|
||||
<div className='flex items-center gap-3'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
{isLoadingTemplate
|
||||
? 'Loading...'
|
||||
: existingTemplate
|
||||
? 'Update Template'
|
||||
: 'Publish Template'}
|
||||
</DialogTitle>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{existingTemplate.stars > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
|
||||
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
|
||||
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
|
||||
{existingTemplate.stars}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{existingTemplate.views > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
|
||||
<Eye className='h-3 w-3 text-blue-500' />
|
||||
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
|
||||
{existingTemplate.views}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -259,65 +374,189 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex gap-3'>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-6'>
|
||||
{isLoadingTemplate ? (
|
||||
<div className='space-y-6'>
|
||||
{/* Icon and Color row */}
|
||||
<div className='flex gap-3'>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
|
||||
</div>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Color picker */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Author and Category row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-20 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex gap-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Icon
|
||||
</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
|
||||
field.value === icon.value &&
|
||||
'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Color
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
|
||||
field.value === icon.value &&
|
||||
'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter template name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Category
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20'
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='resize-none'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -325,91 +564,28 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter template name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='resize-none'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className='mt-auto border-t px-6 pt-4 pb-6'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex items-center'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSubmitting || isLoadingTemplate}
|
||||
className='h-10 rounded-md px-4 py-2'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
|
||||
className={cn(
|
||||
'font-medium',
|
||||
'ml-auto font-medium',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
@@ -420,16 +596,59 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Publishing...
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
'Update Template'
|
||||
) : (
|
||||
'Publish'
|
||||
'Publish Template'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{existingTemplate && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this template will remove it from the gallery. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!existingTemplate) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to delete template')
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete template', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
@@ -113,6 +112,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
|
||||
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
|
||||
|
||||
// Delete workflow state - grouped for better organization
|
||||
const [deleteState, setDeleteState] = useState({
|
||||
showDialog: false,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [] as any[],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
|
||||
// Deployed state management
|
||||
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
|
||||
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
|
||||
@@ -337,35 +345,170 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleting the current workflow
|
||||
* Reset delete state
|
||||
*/
|
||||
const handleDeleteWorkflow = () => {
|
||||
const resetDeleteState = useCallback(() => {
|
||||
setDeleteState({
|
||||
showDialog: false,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Navigate to next workflow after deletion
|
||||
*/
|
||||
const navigateAfterDeletion = useCallback(
|
||||
(currentWorkflowId: string) => {
|
||||
const sidebarWorkflows = getSidebarOrderedWorkflows()
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
|
||||
|
||||
// Find next workflow: try next, then previous
|
||||
let nextWorkflowId: string | null = null
|
||||
if (sidebarWorkflows.length > 1) {
|
||||
if (currentIndex < sidebarWorkflows.length - 1) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
|
||||
} else if (currentIndex > 0) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to next workflow or workspace home
|
||||
if (nextWorkflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
|
||||
} else {
|
||||
router.push(`/workspace/${workspaceId}`)
|
||||
}
|
||||
},
|
||||
[workspaceId, router]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if workflow has published templates
|
||||
*/
|
||||
const checkPublishedTemplates = useCallback(async (workflowId: string) => {
|
||||
const checkResponse = await fetch(`/api/workflows/${workflowId}?check-templates=true`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!checkResponse.ok) {
|
||||
throw new Error(`Failed to check templates: ${checkResponse.statusText}`)
|
||||
}
|
||||
|
||||
return await checkResponse.json()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Delete workflow with optional template handling
|
||||
*/
|
||||
const deleteWorkflowWithTemplates = useCallback(
|
||||
async (workflowId: string, templateAction?: 'keep' | 'delete') => {
|
||||
const endpoint = templateAction
|
||||
? `/api/workflows/${workflowId}?deleteTemplates=${templateAction}`
|
||||
: null
|
||||
|
||||
if (endpoint) {
|
||||
// Use custom endpoint for template handling
|
||||
const response = await fetch(endpoint, { method: 'DELETE' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete workflow: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Manual registry cleanup since we used custom API
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const newWorkflows = { ...state.workflows }
|
||||
delete newWorkflows[workflowId]
|
||||
|
||||
return {
|
||||
...state,
|
||||
workflows: newWorkflows,
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Use registry's built-in deletion (handles database + state)
|
||||
await useWorkflowRegistry.getState().removeWorkflow(workflowId)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle deleting the current workflow - called after user confirms
|
||||
*/
|
||||
const handleDeleteWorkflow = useCallback(async () => {
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
if (!currentWorkflowId || !userPermissions.canEdit) return
|
||||
|
||||
const sidebarWorkflows = getSidebarOrderedWorkflows()
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
|
||||
|
||||
// Find next workflow: try next, then previous
|
||||
let nextWorkflowId: string | null = null
|
||||
if (sidebarWorkflows.length > 1) {
|
||||
if (currentIndex < sidebarWorkflows.length - 1) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
|
||||
} else if (currentIndex > 0) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
|
||||
try {
|
||||
// Check if workflow has published templates
|
||||
const checkData = await checkPublishedTemplates(currentWorkflowId)
|
||||
|
||||
if (checkData.hasPublishedTemplates) {
|
||||
setDeleteState((prev) => ({
|
||||
...prev,
|
||||
hasPublishedTemplates: true,
|
||||
publishedTemplates: checkData.publishedTemplates || [],
|
||||
showTemplateChoice: true,
|
||||
isDeleting: false, // Stop showing "Deleting..." and show template choice
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to next workflow or workspace home
|
||||
if (nextWorkflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
|
||||
} else {
|
||||
router.push(`/workspace/${workspaceId}`)
|
||||
// No templates, proceed with standard deletion
|
||||
navigateAfterDeletion(currentWorkflowId)
|
||||
await deleteWorkflowWithTemplates(currentWorkflowId)
|
||||
resetDeleteState()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workflow:', error)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
|
||||
}
|
||||
}, [
|
||||
params.workflowId,
|
||||
userPermissions.canEdit,
|
||||
checkPublishedTemplates,
|
||||
navigateAfterDeletion,
|
||||
deleteWorkflowWithTemplates,
|
||||
resetDeleteState,
|
||||
])
|
||||
|
||||
// Remove the workflow from the registry using the URL parameter
|
||||
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
|
||||
}
|
||||
/**
|
||||
* Handle template action selection
|
||||
*/
|
||||
const handleTemplateAction = useCallback(
|
||||
async (action: 'keep' | 'delete') => {
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
if (!currentWorkflowId || !userPermissions.canEdit) return
|
||||
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
|
||||
|
||||
try {
|
||||
logger.info(`Deleting workflow ${currentWorkflowId} with template action: ${action}`)
|
||||
|
||||
navigateAfterDeletion(currentWorkflowId)
|
||||
await deleteWorkflowWithTemplates(currentWorkflowId, action)
|
||||
|
||||
logger.info(
|
||||
`Successfully deleted workflow ${currentWorkflowId} with template action: ${action}`
|
||||
)
|
||||
resetDeleteState()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workflow:', error)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
|
||||
}
|
||||
},
|
||||
[
|
||||
params.workflowId,
|
||||
userPermissions.canEdit,
|
||||
navigateAfterDeletion,
|
||||
deleteWorkflowWithTemplates,
|
||||
resetDeleteState,
|
||||
]
|
||||
)
|
||||
|
||||
// Helper function to open subscription settings
|
||||
const openSubscriptionSettings = () => {
|
||||
@@ -422,7 +565,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialog
|
||||
open={deleteState.showDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
// Reset all state when opening dialog to ensure clean start
|
||||
setDeleteState({
|
||||
showDialog: true,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
} else {
|
||||
resetDeleteState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -444,21 +603,71 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions, and
|
||||
configuration.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
|
||||
</AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? (
|
||||
<div className='space-y-3'>
|
||||
<AlertDialogDescription>
|
||||
This workflow has {deleteState.publishedTemplates.length} published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
|
||||
</AlertDialogDescription>
|
||||
{deleteState.publishedTemplates.length > 0 && (
|
||||
<ul className='list-disc space-y-1 pl-6'>
|
||||
{deleteState.publishedTemplates.map((template) => (
|
||||
<li key={template.id}>{template.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<AlertDialogDescription>
|
||||
What would you like to do with the published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions,
|
||||
and configuration.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteWorkflow}
|
||||
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'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
{deleteState.showTemplateChoice ? (
|
||||
<div className='flex w-full gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => handleTemplateAction('keep')}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
>
|
||||
Keep templates
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTemplateAction('delete')}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDeleteWorkflow()
|
||||
}}
|
||||
disabled={deleteState.isDeleting}
|
||||
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'
|
||||
>
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -1002,10 +1211,10 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{renderToggleButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{renderDuplicateButton()}
|
||||
{renderDeleteButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{isExpanded && renderPublishButton()}
|
||||
{renderDeleteButton()}
|
||||
{renderDuplicateButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{renderDeployButton()}
|
||||
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
|
||||
import { NavigationItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/navigation-item/navigation-item'
|
||||
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
workspaceId: string
|
||||
pathname: string
|
||||
onShowSettings: () => void
|
||||
onShowHelp: () => void
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export const FloatingNavigation = ({
|
||||
workspaceId,
|
||||
pathname,
|
||||
onShowSettings,
|
||||
onShowHelp,
|
||||
bottom,
|
||||
}: FloatingNavigationProps) => {
|
||||
// Navigation items with their respective actions
|
||||
const navigationItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: Settings,
|
||||
onClick: onShowSettings,
|
||||
tooltip: 'Settings',
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
icon: HelpCircle,
|
||||
onClick: onShowHelp,
|
||||
tooltip: 'Help',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
tooltip: 'Logs',
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
icon: LibraryBig,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
tooltip: 'Knowledge',
|
||||
active: pathname === `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
tooltip: 'Templates',
|
||||
active: pathname === `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='pointer-events-auto fixed left-4 z-50 w-56' style={{ bottom: `${bottom}px` }}>
|
||||
<div className='flex items-center gap-1'>
|
||||
{navigationItems.map((item) => (
|
||||
<NavigationItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
export { CreateMenu } from './create-menu/create-menu'
|
||||
export { FloatingNavigation } from './floating-navigation/floating-navigation'
|
||||
export { FolderTree } from './folder-tree/folder-tree'
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { KeyboardShortcut } from './keyboard-shortcut/keyboard-shortcut'
|
||||
export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags'
|
||||
export { KnowledgeTags } from './knowledge-tags/knowledge-tags'
|
||||
export { LogsFilters } from './logs-filters/logs-filters'
|
||||
export { NavigationItem } from './navigation-item/navigation-item'
|
||||
export { SettingsModal } from './settings-modal/settings-modal'
|
||||
export { SubscriptionModal } from './subscription-modal/subscription-modal'
|
||||
export { Toolbar } from './toolbar/toolbar'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KeyboardShortcutProps {
|
||||
shortcut: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
|
||||
const parts = shortcut.split('+')
|
||||
|
||||
// Helper function to determine if a part is a symbol that should be larger
|
||||
const isSymbol = (part: string) => {
|
||||
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
|
||||
}
|
||||
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: {
|
||||
id: string
|
||||
icon: React.ElementType
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
tooltip: string
|
||||
shortcut?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const NavigationItem = ({ item }: NavigationItemProps) => {
|
||||
// Settings and help buttons get gray hover, others get purple hover
|
||||
const isGrayHover = item.id === 'settings' || item.id === 'help'
|
||||
|
||||
const content = item.disabled ? (
|
||||
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
|
||||
isGrayHover && 'hover:bg-secondary',
|
||||
!isGrayHover &&
|
||||
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
|
||||
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
|
||||
)}
|
||||
>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (item.href && !item.disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href={item.href} className='inline-block'>
|
||||
{content}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' command={item.shortcut}>
|
||||
{item.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side='top' command={item.shortcut}>
|
||||
{item.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ interface WorkspaceSelectorProps {
|
||||
onWorkspaceUpdate: () => Promise<void>
|
||||
onSwitchWorkspace: (workspace: Workspace) => Promise<void>
|
||||
onCreateWorkspace: () => Promise<void>
|
||||
onDeleteWorkspace: (workspace: Workspace) => Promise<void>
|
||||
onDeleteWorkspace: (workspace: Workspace, templateAction?: 'keep' | 'delete') => Promise<void>
|
||||
onLeaveWorkspace: (workspace: Workspace) => Promise<void>
|
||||
updateWorkspaceName: (workspaceId: string, newName: string) => Promise<boolean>
|
||||
isDeleting: boolean
|
||||
@@ -76,6 +76,14 @@ export function WorkspaceSelector({
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
|
||||
const [leaveConfirmationName, setLeaveConfirmationName] = useState('')
|
||||
const [isCheckingTemplates, setIsCheckingTemplates] = useState(false)
|
||||
const [showTemplateChoice, setShowTemplateChoice] = useState(false)
|
||||
const [templatesInfo, setTemplatesInfo] = useState<{
|
||||
count: number
|
||||
templates: Array<{ id: string; name: string }>
|
||||
} | null>(null)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [workspaceToDelete, setWorkspaceToDelete] = useState<Workspace | null>(null)
|
||||
|
||||
// Refs
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
@@ -206,15 +214,82 @@ export function WorkspaceSelector({
|
||||
)
|
||||
|
||||
/**
|
||||
* Confirm delete workspace
|
||||
* Reset delete dialog state
|
||||
*/
|
||||
const confirmDeleteWorkspace = useCallback(
|
||||
async (workspaceToDelete: Workspace) => {
|
||||
await onDeleteWorkspace(workspaceToDelete)
|
||||
const resetDeleteState = useCallback(() => {
|
||||
setDeleteConfirmationName('')
|
||||
setShowTemplateChoice(false)
|
||||
setTemplatesInfo(null)
|
||||
setIsCheckingTemplates(false)
|
||||
setWorkspaceToDelete(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle dialog close
|
||||
*/
|
||||
const handleDialogClose = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
resetDeleteState()
|
||||
}
|
||||
setIsDeleteDialogOpen(open)
|
||||
},
|
||||
[onDeleteWorkspace]
|
||||
[resetDeleteState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle template choice action
|
||||
*/
|
||||
const handleTemplateAction = useCallback(
|
||||
async (action: 'keep' | 'delete') => {
|
||||
if (!workspaceToDelete) return
|
||||
|
||||
setShowTemplateChoice(false)
|
||||
setTemplatesInfo(null)
|
||||
setDeleteConfirmationName('')
|
||||
await onDeleteWorkspace(workspaceToDelete, action)
|
||||
setWorkspaceToDelete(null)
|
||||
setIsDeleteDialogOpen(false)
|
||||
},
|
||||
[workspaceToDelete, onDeleteWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check for templates and handle deletion
|
||||
*/
|
||||
const handleDeleteClick = useCallback(async () => {
|
||||
if (!workspaceToDelete) return
|
||||
|
||||
setIsCheckingTemplates(true)
|
||||
try {
|
||||
const checkResponse = await fetch(
|
||||
`/api/workspaces/${workspaceToDelete.id}?check-templates=true`
|
||||
)
|
||||
if (checkResponse.ok) {
|
||||
const templateCheck = await checkResponse.json()
|
||||
if (templateCheck.hasPublishedTemplates && templateCheck.count > 0) {
|
||||
// Templates exist - show template choice
|
||||
setTemplatesInfo({
|
||||
count: templateCheck.count,
|
||||
templates: templateCheck.publishedTemplates,
|
||||
})
|
||||
setShowTemplateChoice(true)
|
||||
setIsCheckingTemplates(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking templates:', error)
|
||||
}
|
||||
|
||||
// No templates or error - proceed with deletion
|
||||
setIsCheckingTemplates(false)
|
||||
setDeleteConfirmationName('')
|
||||
await onDeleteWorkspace(workspaceToDelete)
|
||||
setWorkspaceToDelete(null)
|
||||
setIsDeleteDialogOpen(false)
|
||||
}, [workspaceToDelete, onDeleteWorkspace])
|
||||
|
||||
/**
|
||||
* Confirm leave workspace
|
||||
*/
|
||||
@@ -352,7 +427,7 @@ export function WorkspaceSelector({
|
||||
<Input
|
||||
value={leaveConfirmationName}
|
||||
onChange={(e) => setLeaveConfirmationName(e.target.value)}
|
||||
placeholder='Placeholder'
|
||||
placeholder={workspace.name}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
@@ -381,66 +456,21 @@ export function WorkspaceSelector({
|
||||
|
||||
{/* Delete Workspace - for admin users */}
|
||||
{workspace.permissions === 'admin' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
|
||||
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<Trash2 className='!h-3.5 !w-3.5' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workspace?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this workspace will permanently remove all associated workflows,
|
||||
logs, and knowledge bases.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='py-2'>
|
||||
<p className='mb-2 font-[360] text-sm'>
|
||||
Enter the workspace name{' '}
|
||||
<span className='font-semibold'>{workspace.name}</span> to confirm.
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmationName}
|
||||
onChange={(e) => setDeleteConfirmationName(e.target.value)}
|
||||
placeholder='Placeholder'
|
||||
className='h-9 rounded-[8px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => setDeleteConfirmationName('')}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
confirmDeleteWorkspace(workspace)
|
||||
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={isDeleting || deleteConfirmationName !== workspace.name}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setWorkspaceToDelete(workspace)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className={cn(
|
||||
'h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground',
|
||||
!isEditing && isHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<Trash2 className='!h-3.5 !w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,6 +526,106 @@ export function WorkspaceSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Centralized Delete Workspace Dialog */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={handleDialogClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{showTemplateChoice
|
||||
? 'Delete workspace with published templates?'
|
||||
: 'Delete workspace?'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{showTemplateChoice ? (
|
||||
<>
|
||||
This workspace contains {templatesInfo?.count} published template
|
||||
{templatesInfo?.count === 1 ? '' : 's'}:
|
||||
<br />
|
||||
<br />
|
||||
{templatesInfo?.templates.map((template) => (
|
||||
<span key={template.id} className='block'>
|
||||
• {template.name}
|
||||
</span>
|
||||
))}
|
||||
<br />
|
||||
What would you like to do with the published templates?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Deleting this workspace will permanently remove all associated workflows, logs,
|
||||
and knowledge bases.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{showTemplateChoice ? (
|
||||
<div className='flex gap-2 py-2'>
|
||||
<Button
|
||||
onClick={() => handleTemplateAction('keep')}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
variant='outline'
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Keep Templates'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTemplateAction('delete')}
|
||||
className='h-9 flex-1 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={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Templates'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='py-2'>
|
||||
<p className='mb-2 font-[360] text-sm'>
|
||||
Enter the workspace name{' '}
|
||||
<span className='font-semibold'>{workspaceToDelete?.name}</span> to confirm.
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmationName}
|
||||
onChange={(e) => setDeleteConfirmationName(e.target.value)}
|
||||
placeholder={workspaceToDelete?.name}
|
||||
className='h-9 rounded-[8px]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showTemplateChoice && (
|
||||
<AlertDialogFooter className='flex'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => {
|
||||
resetDeleteState()
|
||||
setIsDeleteDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDeleteClick()
|
||||
}}
|
||||
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={
|
||||
isDeleting ||
|
||||
deleteConfirmationName !== workspaceToDelete?.name ||
|
||||
isCheckingTemplates
|
||||
}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : isCheckingTemplates ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Invite Modal */}
|
||||
<InviteModal
|
||||
open={showInviteMembers}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
|
||||
import {
|
||||
CreateMenu,
|
||||
FloatingNavigation,
|
||||
FolderTree,
|
||||
HelpModal,
|
||||
KeyboardShortcut,
|
||||
KnowledgeBaseTags,
|
||||
KnowledgeTags,
|
||||
LogsFilters,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
WorkspaceSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
|
||||
import {
|
||||
getKeyboardShortcutText,
|
||||
useGlobalShortcuts,
|
||||
@@ -40,109 +42,6 @@ const SIDEBAR_GAP = 12 // 12px gap between components - easily editable
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
/**
|
||||
* Optimized auto-scroll hook for smooth drag operations
|
||||
* Extracted outside component for better performance
|
||||
*/
|
||||
const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const speedRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
|
||||
const animateScroll = useCallback(() => {
|
||||
const scrollContainer = containerRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (!scrollContainer || speedRef.current === 0) {
|
||||
animationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
|
||||
|
||||
// Check bounds and stop if needed
|
||||
if (
|
||||
(speedRef.current < 0 && currentScrollTop <= 0) ||
|
||||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
|
||||
) {
|
||||
speedRef.current = 0
|
||||
animationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Apply smooth scroll
|
||||
scrollContainer.scrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
|
||||
)
|
||||
animationRef.current = requestAnimationFrame(animateScroll)
|
||||
}, [containerRef])
|
||||
|
||||
const startScroll = useCallback(
|
||||
(speed: number) => {
|
||||
speedRef.current = speed
|
||||
if (!animationRef.current) {
|
||||
animationRef.current = requestAnimationFrame(animateScroll)
|
||||
}
|
||||
},
|
||||
[animateScroll]
|
||||
)
|
||||
|
||||
const stopScroll = useCallback(() => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
animationRef.current = null
|
||||
}
|
||||
speedRef.current = 0
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
const now = performance.now()
|
||||
// Throttle to ~16ms for 60fps
|
||||
if (now - lastUpdateRef.current < 16) return
|
||||
lastUpdateRef.current = now
|
||||
|
||||
const scrollContainer = containerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect()
|
||||
const mouseY = e.clientY
|
||||
|
||||
// Early exit if mouse is outside container
|
||||
if (mouseY < rect.top || mouseY > rect.bottom) {
|
||||
stopScroll()
|
||||
return
|
||||
}
|
||||
|
||||
const scrollZone = 50
|
||||
const maxSpeed = 4
|
||||
const distanceFromTop = mouseY - rect.top
|
||||
const distanceFromBottom = rect.bottom - mouseY
|
||||
|
||||
let scrollSpeed = 0
|
||||
|
||||
if (distanceFromTop < scrollZone) {
|
||||
const intensity = (scrollZone - distanceFromTop) / scrollZone
|
||||
scrollSpeed = -maxSpeed * intensity ** 2
|
||||
} else if (distanceFromBottom < scrollZone) {
|
||||
const intensity = (scrollZone - distanceFromBottom) / scrollZone
|
||||
scrollSpeed = maxSpeed * intensity ** 2
|
||||
}
|
||||
|
||||
if (Math.abs(scrollSpeed) > 0.1) {
|
||||
startScroll(scrollSpeed)
|
||||
} else {
|
||||
stopScroll()
|
||||
}
|
||||
},
|
||||
[containerRef, startScroll, stopScroll]
|
||||
)
|
||||
|
||||
return { handleDragOver, stopScroll }
|
||||
}
|
||||
|
||||
// Heights for dynamic calculation (in px)
|
||||
const SIDEBAR_HEIGHTS = {
|
||||
CONTAINER_PADDING: 32, // p-4 = 16px top + 16px bottom (bottom provides control bar spacing match)
|
||||
@@ -204,6 +103,7 @@ export function Sidebar() {
|
||||
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
|
||||
// Add sidebar collapsed state
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string
|
||||
@@ -509,16 +409,22 @@ export function Sidebar() {
|
||||
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
|
||||
|
||||
/**
|
||||
* Confirm delete workspace
|
||||
* Confirm delete workspace (called from regular deletion dialog)
|
||||
*/
|
||||
const confirmDeleteWorkspace = useCallback(
|
||||
async (workspaceToDelete: Workspace) => {
|
||||
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
logger.info('Deleting workspace:', workspaceToDelete.id)
|
||||
|
||||
const deleteTemplates = templateAction === 'delete'
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ deleteTemplates }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -961,44 +867,6 @@ export function Sidebar() {
|
||||
}
|
||||
}, [stopScroll])
|
||||
|
||||
// Navigation items with their respective actions
|
||||
const navigationItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: Settings,
|
||||
onClick: () => setShowSettings(true),
|
||||
tooltip: 'Settings',
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setShowHelp(true),
|
||||
tooltip: 'Help',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
tooltip: 'Logs',
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
icon: LibraryBig,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
tooltip: 'Knowledge',
|
||||
active: pathname === `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: Shapes,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
tooltip: 'Templates',
|
||||
active: pathname === `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Sidebar - Overlay */}
|
||||
@@ -1155,16 +1023,13 @@ export function Sidebar() {
|
||||
)}
|
||||
|
||||
{/* Floating Navigation - Always visible */}
|
||||
<div
|
||||
className='pointer-events-auto fixed left-4 z-50 w-56'
|
||||
style={{ bottom: `${navigationBottom}px` }}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
{navigationItems.map((item) => (
|
||||
<NavigationItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FloatingNavigation
|
||||
workspaceId={workspaceId}
|
||||
pathname={pathname}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
onShowHelp={() => setShowHelp(true)}
|
||||
bottom={navigationBottom}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<SettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||
@@ -1183,98 +1048,3 @@ export function Sidebar() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Keyboard Shortcut Component
|
||||
interface KeyboardShortcutProps {
|
||||
shortcut: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const KeyboardShortcut = ({ shortcut, className }: KeyboardShortcutProps) => {
|
||||
const parts = shortcut.split('+')
|
||||
|
||||
// Helper function to determine if a part is a symbol that should be larger
|
||||
const isSymbol = (part: string) => {
|
||||
return ['⌘', '⇧', '⌥', '⌃'].includes(part)
|
||||
}
|
||||
|
||||
return (
|
||||
<kbd
|
||||
className={cn(
|
||||
'flex h-6 w-8 items-center justify-center rounded-[5px] border border-border bg-background font-mono text-[#CDCDCD] text-xs dark:text-[#454545]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center justify-center gap-[1px] pt-[1px]'>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className={cn(isSymbol(part) ? 'text-[17px]' : 'text-xs')}>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation Item Component
|
||||
interface NavigationItemProps {
|
||||
item: {
|
||||
id: string
|
||||
icon: React.ElementType
|
||||
onClick?: () => void
|
||||
href?: string
|
||||
tooltip: string
|
||||
shortcut?: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const NavigationItem = ({ item }: NavigationItemProps) => {
|
||||
// Settings and help buttons get gray hover, others get purple hover
|
||||
const isGrayHover = item.id === 'settings' || item.id === 'help'
|
||||
|
||||
const content = item.disabled ? (
|
||||
<div className='inline-flex h-[42px] w-[42px] cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
|
||||
isGrayHover && 'hover:bg-secondary',
|
||||
!isGrayHover &&
|
||||
'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white',
|
||||
item.active && 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)] text-white'
|
||||
)}
|
||||
>
|
||||
<item.icon className='h-4 w-4' />
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (item.href && !item.disabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href={item.href} className='inline-block'>
|
||||
{content}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' command={item.shortcut}>
|
||||
{item.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side='top' command={item.shortcut}>
|
||||
{item.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
103
apps/sim/app/workspace/[workspaceId]/w/hooks/use-auto-scroll.ts
Normal file
103
apps/sim/app/workspace/[workspaceId]/w/hooks/use-auto-scroll.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Optimized auto-scroll hook for smooth drag operations
|
||||
*/
|
||||
export const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const speedRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
|
||||
const animateScroll = useCallback(() => {
|
||||
const scrollContainer = containerRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
) as HTMLElement
|
||||
if (!scrollContainer || speedRef.current === 0) {
|
||||
animationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const currentScrollTop = scrollContainer.scrollTop
|
||||
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
|
||||
|
||||
// Check bounds and stop if needed
|
||||
if (
|
||||
(speedRef.current < 0 && currentScrollTop <= 0) ||
|
||||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
|
||||
) {
|
||||
speedRef.current = 0
|
||||
animationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Apply smooth scroll
|
||||
scrollContainer.scrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
|
||||
)
|
||||
animationRef.current = requestAnimationFrame(animateScroll)
|
||||
}, [containerRef])
|
||||
|
||||
const startScroll = useCallback(
|
||||
(speed: number) => {
|
||||
speedRef.current = speed
|
||||
if (!animationRef.current) {
|
||||
animationRef.current = requestAnimationFrame(animateScroll)
|
||||
}
|
||||
},
|
||||
[animateScroll]
|
||||
)
|
||||
|
||||
const stopScroll = useCallback(() => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
animationRef.current = null
|
||||
}
|
||||
speedRef.current = 0
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent) => {
|
||||
const now = performance.now()
|
||||
// Throttle to ~16ms for 60fps
|
||||
if (now - lastUpdateRef.current < 16) return
|
||||
lastUpdateRef.current = now
|
||||
|
||||
const scrollContainer = containerRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect()
|
||||
const mouseY = e.clientY
|
||||
|
||||
// Early exit if mouse is outside container
|
||||
if (mouseY < rect.top || mouseY > rect.bottom) {
|
||||
stopScroll()
|
||||
return
|
||||
}
|
||||
|
||||
const scrollZone = 50
|
||||
const maxSpeed = 4
|
||||
const distanceFromTop = mouseY - rect.top
|
||||
const distanceFromBottom = rect.bottom - mouseY
|
||||
|
||||
let scrollSpeed = 0
|
||||
|
||||
if (distanceFromTop < scrollZone) {
|
||||
const intensity = (scrollZone - distanceFromTop) / scrollZone
|
||||
scrollSpeed = -maxSpeed * intensity ** 2
|
||||
} else if (distanceFromBottom < scrollZone) {
|
||||
const intensity = (scrollZone - distanceFromBottom) / scrollZone
|
||||
scrollSpeed = maxSpeed * intensity ** 2
|
||||
}
|
||||
|
||||
if (Math.abs(scrollSpeed) > 0.1) {
|
||||
startScroll(scrollSpeed)
|
||||
} else {
|
||||
stopScroll()
|
||||
}
|
||||
},
|
||||
[containerRef, startScroll, stopScroll]
|
||||
)
|
||||
|
||||
return { handleDragOver, stopScroll }
|
||||
}
|
||||
1
apps/sim/db/migrations/0077_rapid_chimera.sql
Normal file
1
apps/sim/db/migrations/0077_rapid_chimera.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "templates" ALTER COLUMN "workflow_id" DROP NOT NULL;
|
||||
5910
apps/sim/db/migrations/meta/0077_snapshot.json
Normal file
5910
apps/sim/db/migrations/meta/0077_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -533,6 +533,13 @@
|
||||
"when": 1755375658161,
|
||||
"tag": "0076_damp_vector",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1755809024626,
|
||||
"tag": "0077_rapid_chimera",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1062,9 +1062,7 @@ export const templates = pgTable(
|
||||
'templates',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workflowId: text('workflow_id')
|
||||
.notNull()
|
||||
.references(() => workflow.id),
|
||||
workflowId: text('workflow_id').references(() => workflow.id),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
|
||||
Reference in New Issue
Block a user