mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace (#2482)
* fix(autofill): add dummy inputs to prevent browser autofill for various fields, prevent having 0 workflows in workspace * cleanup * ack PR comments * fix failing test
This commit is contained in:
@@ -141,6 +141,23 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if deleting this folder would delete the last workflow(s) in the workspace
|
||||
const workflowsInFolder = await countWorkflowsInFolderRecursively(
|
||||
id,
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
|
||||
|
||||
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively delete folder and all its contents
|
||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||
|
||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of workflows in a folder and all its subfolders recursively.
|
||||
*/
|
||||
async function countWorkflowsInFolderRecursively(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
let count = 0
|
||||
|
||||
const workflowsInFolder = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
|
||||
|
||||
count += workflowsInFolder.length
|
||||
|
||||
const childFolders = await db
|
||||
.select({ id: workflowFolder.id })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
|
||||
|
||||
for (const childFolder of childFolders) {
|
||||
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// Helper function to check for circular references
|
||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||
let currentParentId: string | null = parentId
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
delete: () => mockDbDelete(),
|
||||
update: () => mockDbUpdate(),
|
||||
select: () => mockDbSelect(),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return multiple workflows so deletion is allowed
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbDelete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
})
|
||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should prevent deletion of the last workflow in workspace', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-123' },
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return only 1 workflow (the one being deleted)
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
|
||||
})
|
||||
|
||||
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
|
||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if this is the last workflow in the workspace
|
||||
if (workflowData.workspaceId) {
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workflowData.workspaceId))
|
||||
|
||||
if (totalWorkflowsInWorkspace.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete the only workflow in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
|
||||
@@ -339,12 +339,31 @@ export function CreateBaseModal({
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Label htmlFor='kb-name'>Name</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
id='name'
|
||||
id='kb-name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<EmcnInput
|
||||
value={newKeyName}
|
||||
onChange={(e) => {
|
||||
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9'
|
||||
autoFocus
|
||||
name='api_key_label'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
|
||||
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-2'>
|
||||
<div className='flex-1'>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
name='member_invite_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
|
||||
@@ -55,16 +55,31 @@ export function NoOrganizationView({
|
||||
|
||||
{/* Form fields - clean layout without card */}
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='orgName' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
|
||||
Team Name
|
||||
</Label>
|
||||
<Input
|
||||
id='orgName'
|
||||
id='team-name-field'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
className='mt-1'
|
||||
name='team_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,31 +131,52 @@ export function NoOrganizationView({
|
||||
</ModalHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='org-name' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
|
||||
Organization Name
|
||||
</Label>
|
||||
<Input
|
||||
id='org-name'
|
||||
id='org-name-field'
|
||||
placeholder='Enter organization name'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
|
||||
Organization Slug
|
||||
</Label>
|
||||
<Input
|
||||
id='org-slug'
|
||||
id='org-slug-field'
|
||||
placeholder='organization-slug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_slug_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,11 +390,26 @@ export function TemplateProfile() {
|
||||
disabled={isUploadingProfilePicture}
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Name'
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
className='h-9 flex-1'
|
||||
name='profile_display_name'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteFolder,
|
||||
useDuplicateFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { canDeleteFolder } = useCanDelete({ workspaceId })
|
||||
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
|
||||
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDelete}
|
||||
/>
|
||||
|
||||
{/* Delete Modal */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteWorkflow,
|
||||
useDuplicateWorkflow,
|
||||
useExportWorkflow,
|
||||
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isSelected = selectedWorkflows.has(workflow.id)
|
||||
|
||||
// Can delete check hook
|
||||
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||
|
||||
// Presence avatars state
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
||||
}
|
||||
|
||||
// Check if the captured selection can be deleted
|
||||
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
|
||||
|
||||
// If already selected with multiple selections, keep all selections
|
||||
handleContextMenuBase(e)
|
||||
},
|
||||
[workflow.id, workflows, handleContextMenuBase]
|
||||
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
|
||||
)
|
||||
|
||||
// Rename hook
|
||||
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableExport={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
<ModalContent className='w-[500px]'>
|
||||
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
autoComplete='off'
|
||||
>
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='emails'
|
||||
htmlFor='invite-field'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Email Addresses
|
||||
</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[4px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
id='emails'
|
||||
id='invite-field'
|
||||
name='invite_search_field'
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
)}
|
||||
autoFocus={userPerms.canAdmin}
|
||||
disabled={isSubmitting || !userPerms.canAdmin}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useCanDelete } from './use-can-delete'
|
||||
export { useDeleteFolder } from './use-delete-folder'
|
||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
|
||||
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface UseCanDeleteProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
interface UseCanDeleteReturn {
|
||||
/**
|
||||
* Checks if the given workflow IDs can be deleted.
|
||||
* Returns false if deleting them would leave no workflows in the workspace.
|
||||
*/
|
||||
canDeleteWorkflows: (workflowIds: string[]) => boolean
|
||||
/**
|
||||
* Checks if the given folder can be deleted.
|
||||
* Returns false if deleting it would leave no workflows in the workspace.
|
||||
*/
|
||||
canDeleteFolder: (folderId: string) => boolean
|
||||
/**
|
||||
* Total number of workflows in the workspace.
|
||||
*/
|
||||
totalWorkflows: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for checking if workflows or folders can be deleted.
|
||||
* Prevents deletion if it would leave the workspace with no workflows.
|
||||
*
|
||||
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Functions to check deletion eligibility
|
||||
*/
|
||||
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const { folders } = useFolderStore()
|
||||
|
||||
/**
|
||||
* Pre-computed data structures for efficient lookups
|
||||
*/
|
||||
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
|
||||
useMemo(() => {
|
||||
const workspaceWorkflows = Object.values(workflows).filter(
|
||||
(w) => w.workspaceId === workspaceId
|
||||
)
|
||||
|
||||
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
|
||||
|
||||
const byFolderId = new Map<string, number>()
|
||||
for (const w of workspaceWorkflows) {
|
||||
if (w.folderId) {
|
||||
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
for (const folder of Object.values(folders)) {
|
||||
if (folder.workspaceId === workspaceId && folder.parentId) {
|
||||
const children = childrenByParent.get(folder.parentId) || []
|
||||
children.push(folder.id)
|
||||
childrenByParent.set(folder.parentId, children)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalWorkflows: workspaceWorkflows.length,
|
||||
workflowIdSet: idSet,
|
||||
workflowsByFolderId: byFolderId,
|
||||
childFoldersByParentId: childrenByParent,
|
||||
}
|
||||
}, [workflows, folders, workspaceId])
|
||||
|
||||
/**
|
||||
* Count workflows in a folder and all its subfolders recursively.
|
||||
* Uses pre-computed maps for efficient lookups.
|
||||
*/
|
||||
const countWorkflowsInFolder = useCallback(
|
||||
(folderId: string): number => {
|
||||
let count = workflowsByFolderId.get(folderId) || 0
|
||||
|
||||
const childFolders = childFoldersByParentId.get(folderId)
|
||||
if (childFolders) {
|
||||
for (const childId of childFolders) {
|
||||
count += countWorkflowsInFolder(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
},
|
||||
[workflowsByFolderId, childFoldersByParentId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the given workflow IDs can be deleted.
|
||||
* Returns false if deleting would remove all workflows from the workspace.
|
||||
*/
|
||||
const canDeleteWorkflows = useCallback(
|
||||
(workflowIds: string[]): boolean => {
|
||||
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
|
||||
|
||||
// Must have at least one workflow remaining after deletion
|
||||
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
|
||||
},
|
||||
[totalWorkflows, workflowIdSet]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the given folder can be deleted.
|
||||
* Empty folders are always deletable. Folders containing all workspace workflows are not.
|
||||
*/
|
||||
const canDeleteFolder = useCallback(
|
||||
(folderId: string): boolean => {
|
||||
const workflowsInFolder = countWorkflowsInFolder(folderId)
|
||||
|
||||
if (workflowsInFolder === 0) return true
|
||||
return workflowsInFolder < totalWorkflows
|
||||
},
|
||||
[totalWorkflows, countWorkflowsInFolder]
|
||||
)
|
||||
|
||||
return {
|
||||
canDeleteWorkflows,
|
||||
canDeleteFolder,
|
||||
totalWorkflows,
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
|
||||
*/
|
||||
const POPOVER_ITEM_BASE_CLASSES =
|
||||
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75'
|
||||
|
||||
/**
|
||||
* Variant-specific active state styles for popover items.
|
||||
@@ -425,7 +425,10 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* ```
|
||||
*/
|
||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => {
|
||||
(
|
||||
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
|
||||
ref
|
||||
) => {
|
||||
// Try to get context - if not available, we're outside Popover (shouldn't happen)
|
||||
const context = React.useContext(PopoverContext)
|
||||
const variant = context?.variant || 'default'
|
||||
@@ -435,18 +438,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
return null
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
||||
!disabled &&
|
||||
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
|
||||
disabled && 'cursor-default opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
role='menuitem'
|
||||
aria-selected={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user