mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -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
|
// Recursively delete folder and all its contents
|
||||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||||
|
|
||||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
|||||||
return stats
|
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
|
// Helper function to check for circular references
|
||||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||||
let currentParentId: string | null = parentId
|
let currentParentId: string | null = parentId
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
|||||||
const mockGetWorkflowAccessContext = vi.fn()
|
const mockGetWorkflowAccessContext = vi.fn()
|
||||||
const mockDbDelete = vi.fn()
|
const mockDbDelete = vi.fn()
|
||||||
const mockDbUpdate = vi.fn()
|
const mockDbUpdate = vi.fn()
|
||||||
|
const mockDbSelect = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
vi.mock('@/lib/auth', () => ({
|
||||||
getSession: () => mockGetSession(),
|
getSession: () => mockGetSession(),
|
||||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
|||||||
db: {
|
db: {
|
||||||
delete: () => mockDbDelete(),
|
delete: () => mockDbDelete(),
|
||||||
update: () => mockDbUpdate(),
|
update: () => mockDbUpdate(),
|
||||||
|
select: () => mockDbSelect(),
|
||||||
},
|
},
|
||||||
workflow: {},
|
workflow: {},
|
||||||
}))
|
}))
|
||||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
|||||||
isWorkspaceOwner: false,
|
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({
|
mockDbDelete.mockReturnValue({
|
||||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||||
})
|
})
|
||||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
|||||||
expect(data.success).toBe(true)
|
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 () => {
|
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||||
const mockWorkflow = {
|
const mockWorkflow = {
|
||||||
id: 'workflow-123',
|
id: 'workflow-123',
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
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
|
// Check if workflow has published templates before deletion
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
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 ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
<div className='space-y-[12px]'>
|
<div className='space-y-[12px]'>
|
||||||
<div className='flex flex-col gap-[8px]'>
|
<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
|
<Input
|
||||||
id='name'
|
id='kb-name'
|
||||||
placeholder='Enter knowledge base name'
|
placeholder='Enter knowledge base name'
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||||
Enter a name for your API key to help you identify it later.
|
Enter a name for your API key to help you identify it later.
|
||||||
</p>
|
</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
|
<EmcnInput
|
||||||
value={newKeyName}
|
value={newKeyName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
placeholder='e.g., Development, Production'
|
placeholder='e.g., Development, Production'
|
||||||
className='h-9'
|
className='h-9'
|
||||||
autoFocus
|
autoFocus
|
||||||
|
name='api_key_label'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
{createError && (
|
{createError && (
|
||||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||||
|
|||||||
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
|
|||||||
{/* Main invitation input */}
|
{/* Main invitation input */}
|
||||||
<div className='flex items-start gap-2'>
|
<div className='flex items-start gap-2'>
|
||||||
<div className='flex-1'>
|
<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
|
<Input
|
||||||
placeholder='Enter email address'
|
placeholder='Enter email address'
|
||||||
value={inviteEmail}
|
value={inviteEmail}
|
||||||
onChange={handleEmailChange}
|
onChange={handleEmailChange}
|
||||||
disabled={isInviting || !hasAvailableSeats}
|
disabled={isInviting || !hasAvailableSeats}
|
||||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
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 && (
|
{emailError && (
|
||||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
<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 */}
|
{/* Form fields - clean layout without card */}
|
||||||
<div className='space-y-4'>
|
<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>
|
<div>
|
||||||
<Label htmlFor='orgName' className='font-medium text-[13px]'>
|
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
|
||||||
Team Name
|
Team Name
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='orgName'
|
id='team-name-field'
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={onOrgNameChange}
|
onChange={onOrgNameChange}
|
||||||
placeholder='My Team'
|
placeholder='My Team'
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='team_name_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,31 +131,52 @@ export function NoOrganizationView({
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
|
|
||||||
<div className='space-y-4'>
|
<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>
|
<div>
|
||||||
<Label htmlFor='org-name' className='font-medium text-[13px]'>
|
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
|
||||||
Organization Name
|
Organization Name
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='org-name'
|
id='org-name-field'
|
||||||
placeholder='Enter organization name'
|
placeholder='Enter organization name'
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={onOrgNameChange}
|
onChange={onOrgNameChange}
|
||||||
disabled={isCreatingOrg}
|
disabled={isCreatingOrg}
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='org_name_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
|
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
|
||||||
Organization Slug
|
Organization Slug
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='org-slug'
|
id='org-slug-field'
|
||||||
placeholder='organization-slug'
|
placeholder='organization-slug'
|
||||||
value={orgSlug}
|
value={orgSlug}
|
||||||
onChange={(e) => setOrgSlug(e.target.value)}
|
onChange={(e) => setOrgSlug(e.target.value)}
|
||||||
disabled={isCreatingOrg}
|
disabled={isCreatingOrg}
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='org_slug_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -390,11 +390,26 @@ export function TemplateProfile() {
|
|||||||
disabled={isUploadingProfilePicture}
|
disabled={isUploadingProfilePicture}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Input
|
||||||
placeholder='Name'
|
placeholder='Name'
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => updateField('name', e.target.value)}
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
className='h-9 flex-1'
|
className='h-9 flex-1'
|
||||||
|
name='profile_display_name'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
useItemRename,
|
useItemRename,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
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 { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||||
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
|||||||
const createFolderMutation = useCreateFolder()
|
const createFolderMutation = useCreateFolder()
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
|
const { canDeleteFolder } = useCanDelete({ workspaceId })
|
||||||
|
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
|
||||||
|
|
||||||
// Delete modal state
|
// Delete modal state
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
|||||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||||
disableDuplicate={!userPermissions.canEdit}
|
disableDuplicate={!userPermissions.canEdit}
|
||||||
disableDelete={!userPermissions.canEdit}
|
disableDelete={!userPermissions.canEdit || !canDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Modal */}
|
{/* Delete Modal */}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useItemRename,
|
useItemRename,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import {
|
import {
|
||||||
|
useCanDelete,
|
||||||
useDeleteWorkflow,
|
useDeleteWorkflow,
|
||||||
useDuplicateWorkflow,
|
useDuplicateWorkflow,
|
||||||
useExportWorkflow,
|
useExportWorkflow,
|
||||||
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const isSelected = selectedWorkflows.has(workflow.id)
|
const isSelected = selectedWorkflows.has(workflow.id)
|
||||||
|
|
||||||
|
// Can delete check hook
|
||||||
|
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||||
|
|
||||||
// Delete modal state
|
// Delete modal state
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||||
|
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||||
|
|
||||||
// Presence avatars state
|
// Presence avatars state
|
||||||
const [hasAvatars, setHasAvatars] = useState(false)
|
const [hasAvatars, setHasAvatars] = useState(false)
|
||||||
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
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
|
// If already selected with multiple selections, keep all selections
|
||||||
handleContextMenuBase(e)
|
handleContextMenuBase(e)
|
||||||
},
|
},
|
||||||
[workflow.id, workflows, handleContextMenuBase]
|
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rename hook
|
// Rename hook
|
||||||
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
disableRename={!userPermissions.canEdit}
|
disableRename={!userPermissions.canEdit}
|
||||||
disableDuplicate={!userPermissions.canEdit}
|
disableDuplicate={!userPermissions.canEdit}
|
||||||
disableExport={!userPermissions.canEdit}
|
disableExport={!userPermissions.canEdit}
|
||||||
disableDelete={!userPermissions.canEdit}
|
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
|
|||||||
@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
<ModalContent className='w-[500px]'>
|
<ModalContent className='w-[500px]'>
|
||||||
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
|
<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>
|
<ModalBody>
|
||||||
<div className='space-y-[12px]'>
|
<div className='space-y-[12px]'>
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label
|
||||||
htmlFor='emails'
|
htmlFor='invite-field'
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||||
>
|
>
|
||||||
Email Addresses
|
Email Addresses
|
||||||
</Label>
|
</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)]'>
|
<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) => (
|
{invalidEmails.map((email, index) => (
|
||||||
<EmailTag
|
<EmailTag
|
||||||
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Input
|
<Input
|
||||||
id='emails'
|
id='invite-field'
|
||||||
|
name='invite_search_field'
|
||||||
type='text'
|
type='text'
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
)}
|
)}
|
||||||
autoFocus={userPerms.canAdmin}
|
autoFocus={userPerms.canAdmin}
|
||||||
disabled={isSubmitting || !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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { useCanDelete } from './use-can-delete'
|
||||||
export { useDeleteFolder } from './use-delete-folder'
|
export { useDeleteFolder } from './use-delete-folder'
|
||||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
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.
|
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
|
||||||
*/
|
*/
|
||||||
const POPOVER_ITEM_BASE_CLASSES =
|
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.
|
* 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>(
|
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)
|
// Try to get context - if not available, we're outside Popover (shouldn't happen)
|
||||||
const context = React.useContext(PopoverContext)
|
const context = React.useContext(PopoverContext)
|
||||||
const variant = context?.variant || 'default'
|
const variant = context?.variant || 'default'
|
||||||
@@ -435,18 +438,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) {
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClick?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
POPOVER_ITEM_BASE_CLASSES,
|
POPOVER_ITEM_BASE_CLASSES,
|
||||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
!disabled &&
|
||||||
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
|
||||||
|
disabled && 'cursor-default opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
onClick={handleClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user