feat: implement access control capabilities for document operations and input field panel

This commit is contained in:
twwu
2026-06-17 16:21:11 +08:00
parent 88e0145fca
commit 471663264b
6 changed files with 250 additions and 73 deletions

View File

@ -1,22 +1,15 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasetACLPermission } from '@/utils/permission'
import Operations from '../operations'
const mockPush = vi.fn()
let mockDatasetPermissionKeys: string[] = []
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset: { permission_keys: string[] } }) => unknown) =>
selector({ dataset: { permission_keys: mockDatasetPermissionKeys } }),
}))
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
success: vi.fn(),
@ -93,15 +86,14 @@ describe('Operations', () => {
datasetId: 'dataset-1',
detail: defaultDetail,
onUpdate: mockOnUpdate,
canEdit: true,
canDownload: true,
canDelete: true,
canViewSettings: true,
}
beforeEach(() => {
vi.clearAllMocks()
mockDatasetPermissionKeys = [
DatasetACLPermission.Edit,
DatasetACLPermission.DocumentDownload,
DatasetACLPermission.DeleteFile,
]
mockArchive.mockResolvedValue({})
mockUnArchive.mockResolvedValue({})
mockEnable.mockResolvedValue({})
@ -136,6 +128,54 @@ describe('Operations', () => {
const disabledSwitch = screen.getByRole('switch')
expect(disabledSwitch)!.toHaveAttribute('aria-disabled', 'true')
})
it('should not render an operations menu and should disable switch without any document operation permissions', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
canEdit={false}
canDownload={false}
canDelete={false}
canViewSettings={false}
/>,
)
expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument()
const switchElement = screen.getByRole('switch')
expect(switchElement)!.toHaveAttribute('aria-disabled', 'true')
await act(async () => {
fireEvent.click(switchElement)
vi.advanceTimersByTime(600)
})
expect(mockEnable).not.toHaveBeenCalled()
expect(mockDisable).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should only render download action when download is the only granted document operation', async () => {
render(
<Operations
{...defaultProps}
canEdit={false}
canDownload
canDelete={false}
canViewSettings={false}
/>,
)
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
await act(async () => {
fireEvent.click(moreButton)
})
expect(screen.getByText('datasetDocuments.list.action.download'))!.toBeInTheDocument()
expect(screen.queryByText('datasetDocuments.list.table.rename')).not.toBeInTheDocument()
expect(screen.queryByText('datasetDocuments.list.action.settings')).not.toBeInTheDocument()
expect(screen.queryByText('datasetDocuments.list.action.archive')).not.toBeInTheDocument()
expect(screen.queryByText('datasetDocuments.list.action.delete')).not.toBeInTheDocument()
})
})
describe('switch toggle', () => {
@ -236,11 +276,15 @@ describe('Operations', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1/settings')
})
it('should hide document settings when dataset only has readonly ACL permission', () => {
mockDatasetPermissionKeys = [DatasetACLPermission.Readonly]
render(<Operations {...defaultProps} />)
it('should hide document settings when settings permission is not granted', async () => {
render(<Operations {...defaultProps} canViewSettings={false} />)
expect(screen.queryByLabelText('datasetDocuments.list.action.settings')).not.toBeInTheDocument()
const moreButton = screen.getByRole('button', { name: 'common.operation.more' })
await act(async () => {
fireEvent.click(moreButton)
})
expect(screen.queryByText('datasetDocuments.list.action.settings')).not.toBeInTheDocument()
})
})

View File

@ -78,7 +78,7 @@ const DocumentTableRow = React.memo(({
router.push(`/datasets/${datasetId}/documents/${doc.id}${queryString ? `?${queryString}` : ''}`)
}, [router, datasetId, doc.id, queryString])
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
const stopPropagation = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation()
}, [])
@ -93,7 +93,7 @@ const DocumentTableRow = React.memo(({
onClick={handleRowClick}
>
<td className="text-left align-middle text-xs text-text-tertiary">
<div className="flex items-center" onClick={handleCheckboxClick}>
<div className="flex items-center" role="presentation" onClick={stopPropagation} onKeyDown={stopPropagation}>
<Checkbox
className="mr-2 shrink-0"
value={doc.id}
@ -127,12 +127,13 @@ const DocumentTableRow = React.memo(({
<Tooltip>
<TooltipTrigger
render={(
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
<button
type="button"
className="cursor-pointer rounded-md border-none bg-transparent p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<span className="i-ri-edit-line size-4 text-text-tertiary" />
</div>
</button>
)}
/>
<TooltipContent>
@ -165,6 +166,10 @@ const DocumentTableRow = React.memo(({
datasetId={datasetId}
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form', 'display_status'])}
onUpdate={onUpdate}
canEdit={datasetACLCapabilities.canEdit}
canDownload={datasetACLCapabilities.canDocumentDownload}
canDelete={datasetACLCapabilities.canDeleteFile}
canViewSettings={datasetACLCapabilities.canEdit}
/>
</td>
</tr>

View File

@ -51,8 +51,25 @@ type OperationsProps = {
onUpdate: (operationName?: string) => void
scene?: 'list' | 'detail'
className?: string
canEdit: boolean
canDownload: boolean
canDelete: boolean
canViewSettings: boolean
}
const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSelectedIdChange, onUpdate, scene = 'list', className = '' }: OperationsProps) => {
const Operations = ({
embeddingAvailable,
datasetId,
detail,
selectedIds,
onSelectedIdChange,
onUpdate,
scene = 'list',
className = '',
canEdit,
canDownload,
canDelete,
canViewSettings,
}: OperationsProps) => {
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
@ -71,7 +88,29 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
const { mutateAsync: pauseDocument } = useDocumentPause()
const { mutateAsync: resumeDocument } = useDocumentResume()
const isListScene = scene === 'list'
const canShowRenameAction = canEdit && !archived
const canShowSummaryAction = canEdit && !archived && IS_CE_EDITION
const canShowSettingsAction = canViewSettings
const canShowDownloadAction = canDownload && data_source_type === DataSourceType.FILE
const canShowSyncAction = canEdit && !archived && ['notion_import', DataSourceType.WEB].includes(data_source_type)
const canShowPauseAction = canEdit && !archived && display_status?.toLowerCase() === 'indexing'
const canShowResumeAction = canEdit && !archived && display_status?.toLowerCase() === 'paused'
const canShowArchiveAction = canEdit && !archived
const canShowUnarchiveAction = canEdit && archived
const canShowDeleteAction = canDelete
const canShowPrimarySection = canShowRenameAction || canShowSummaryAction || canShowSettingsAction || canShowDownloadAction || canShowSyncAction
const canShowStatusSection = canShowPauseAction || canShowResumeAction || canShowArchiveAction || canShowUnarchiveAction
const hasOperationsMenu = embeddingAvailable && (canShowPrimarySection || canShowStatusSection || canShowDeleteAction)
const canOperate = useCallback((operationName: OperationName) => {
if (operationName === DocumentActionType.delete)
return canDelete
return canEdit
}, [canDelete, canEdit])
const onOperate = useCallback(async (operationName: OperationName) => {
if (!canOperate(operationName))
return
let opApi
switch (operationName) {
case 'archive':
@ -121,6 +160,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
setDeleting(false)
}, [
archiveDocument,
canOperate,
data_source_type,
datasetId,
deleteDocument,
@ -139,6 +179,8 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
unArchiveDocument,
])
const { run: handleSwitch } = useDebounceFn((operationName: OperationName) => {
if (!canEdit)
return
if (operationName === DocumentActionType.enable && enabled)
return
if (operationName === DocumentActionType.disable && !enabled)
@ -154,16 +196,24 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
id: string
name: string
}) => {
if (!canEdit)
return
setCurrDocument(doc)
setShowRenameModalTrue()
}, [setShowRenameModalTrue])
}, [canEdit, setShowRenameModalTrue])
const handleRenamed = useCallback(() => {
onUpdate()
}, [onUpdate])
const closeOperationsMenu = useCallback(() => {
setIsOperationsMenuOpen(false)
}, [])
const stopPropagation = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation()
}, [])
const handleDownload = useCallback(async () => {
if (!canDownload)
return
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
return
@ -175,7 +225,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
}
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, t])
}, [canDownload, datasetId, downloadDocument, id, isDownloading, name, t])
const handleShowRename = useCallback(() => {
closeOperationsMenu()
handleShowRenameModal({
@ -188,50 +238,68 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
void onOperate(operationName)
}, [closeOperationsMenu, onOperate])
const handleDeleteClick = useCallback(() => {
if (!canDelete)
return
closeOperationsMenu()
setShowModal(true)
}, [closeOperationsMenu])
}, [canDelete, closeOperationsMenu])
const handleDownloadClick = useCallback((evt: React.MouseEvent<HTMLElement>) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
if (!canDownload)
return
closeOperationsMenu()
void handleDownload()
}, [closeOperationsMenu, handleDownload])
}, [canDownload, closeOperationsMenu, handleDownload])
const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent')
const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group')
const handleSettingsClick = useCallback((evt: React.MouseEvent<HTMLElement>) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
if (!canViewSettings)
return
closeOperationsMenu()
router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)
}, [closeOperationsMenu, datasetId, detail.id, router])
}, [canViewSettings, closeOperationsMenu, datasetId, detail.id, router])
const settingsMenuItem = (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleSettingsClick}>
<span aria-hidden className="i-ri-equalizer-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.settings', { ns: 'datasetDocuments' })}</span>
</button>
)
const renderListSwitch = () => {
if (!canEdit)
return <Switch checked={archived ? false : enabled} onCheckedChange={noop} disabled={true} size="md" />
if (archived) {
return (
<Popover>
<PopoverTrigger nativeButton={false} openOnHover render={<div><Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /></div>} />
<PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary">
{t('list.action.enableWarning', { ns: 'datasetDocuments' })}
</PopoverContent>
</Popover>
)
}
return <Switch checked={enabled} onCheckedChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />
}
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
<div className="flex items-center" role="presentation" onClick={stopPropagation} onKeyDown={stopPropagation}>
{isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)}
{isListScene && embeddingAvailable && (
<>
{archived
? (
<Popover>
<PopoverTrigger nativeButton={false} openOnHover render={<div><Switch checked={false} onCheckedChange={noop} disabled={true} size="md" /></div>} />
<PopoverContent popupClassName="px-3 py-2 font-semibold system-xs-regular text-text-tertiary">
{t('list.action.enableWarning', { ns: 'datasetDocuments' })}
</PopoverContent>
</Popover>
)
: <Switch checked={enabled} onCheckedChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />}
<Divider className="mr-2! ml-4! h-3!" type="vertical" />
{renderListSwitch()}
{hasOperationsMenu && <Divider className="mr-2! ml-4! h-3!" type="vertical" />}
</>
)}
{embeddingAvailable && (
{hasOperationsMenu && (
<>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
@ -258,74 +326,66 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
popupClassName={cn('w-[200px] py-0', className)}
>
<div className="w-full py-1">
{!archived && (
{canShowPrimarySection && (
<>
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</button>
{IS_CE_EDITION && (
{canShowRenameAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canShowSummaryAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('summary')}>
<span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</button>
)}
{settingsMenuItem}
{data_source_type === DataSourceType.FILE && (
{canShowSettingsAction && settingsMenuItem}
{canShowDownloadAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</button>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
{canShowSyncAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('sync')}>
<span aria-hidden className="i-ri-loop-left-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</button>
)}
<Divider className="my-1" />
{(canShowStatusSection || canShowDeleteAction) && <Divider className="my-1" />}
</>
)}
{archived && (
<>
{settingsMenuItem}
{data_source_type === DataSourceType.FILE && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</button>
)}
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
{canShowPauseAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('pause')}>
<span aria-hidden className="i-ri-pause-circle-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span>
</button>
)}
{!archived && display_status?.toLowerCase() === 'paused' && (
{canShowResumeAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('resume')}>
<span aria-hidden className="i-ri-play-circle-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span>
</button>
)}
{!archived && (
{canShowArchiveAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('archive')}>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span>
</button>
)}
{archived && (
{canShowUnarchiveAction && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('un_archive')}>
<span aria-hidden className="i-ri-archive-2-line size-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span>
</button>
)}
<button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line size-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</button>
{canShowDeleteAction && (
<button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line size-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -249,6 +249,10 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
datasetId={datasetId}
onUpdate={handleOperate}
className="w-[200px]!"
canEdit={canEditDocument}
canDownload={datasetACLCapabilities.canDocumentDownload}
canDelete={datasetACLCapabilities.canDeleteFile}
canViewSettings={canEditDocument}
/>
<button
type="button"

View File

@ -16,6 +16,7 @@ const mockCloseAllInputFieldPanels = vi.fn()
const mockToggleInputFieldPreviewPanel = vi.fn()
let mockIsPreviewing = false
let mockIsEditing = false
let mockCanEdit = true
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useInputFieldPanel: () => ({
@ -26,6 +27,15 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({
}),
}))
vi.mock('@/app/components/workflow/hooks-store', () => ({
useHooksStore: <T,>(selector: (state: { accessControl: { canEdit: boolean } }) => T): T =>
selector({
accessControl: {
canEdit: mockCanEdit,
},
}),
}))
let mockRagPipelineVariables: RAGPipelineVariables = []
const mockSetRagPipelineVariables = vi.fn()
@ -178,11 +188,13 @@ const setupMocks = (options?: {
ragPipelineVariables?: RAGPipelineVariables
isPreviewing?: boolean
isEditing?: boolean
canEdit?: boolean
}) => {
mockNodesData = options?.nodes || []
mockRagPipelineVariables = options?.ragPipelineVariables || []
mockIsPreviewing = options?.isPreviewing || false
mockIsEditing = options?.isEditing || false
mockCanEdit = options?.canEdit ?? true
}
describe('InputFieldPanel', () => {
@ -429,6 +441,16 @@ describe('InputFieldPanel', () => {
'false',
)
})
it('should set readonly to true when access control cannot edit', () => {
setupMocks({ canEdit: false })
render(<InputFieldPanel />)
expect(screen.getByTestId('field-list-readonly-shared'))!.toHaveTextContent(
'true',
)
})
})
describe('Input Fields Change Handler', () => {
@ -514,6 +536,41 @@ describe('InputFieldPanel', () => {
const hasSharedField = setVarsCall.some(isSharedField)
expect(hasSharedField).toBe(true)
})
it('should ignore input field changes when access control cannot edit', () => {
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes, canEdit: false })
render(<InputFieldPanel />)
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
expect(mockSetRagPipelineVariables).not.toHaveBeenCalled()
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should ignore input field changes while previewing', () => {
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes, isPreviewing: true })
render(<InputFieldPanel />)
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
expect(mockSetRagPipelineVariables).not.toHaveBeenCalled()
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
it('should still persist input field changes while edit panel is open if access control can edit', async () => {
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
setupMocks({ nodes, isEditing: true, canEdit: true })
render(<InputFieldPanel />)
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
await waitFor(() => {
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
})
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
})
})
describe('Label Class Names', () => {

View File

@ -16,6 +16,7 @@ import Divider from '@/app/components/base/divider'
import { Infotip } from '@/app/components/base/infotip'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import FieldList from './field-list'
@ -32,6 +33,9 @@ const InputFieldPanel = () => {
isPreviewing,
isEditing,
} = useInputFieldPanel()
const canEdit = useHooksStore(s => s.accessControl.canEdit)
const shouldIgnoreInputFieldChange = !canEdit || isPreviewing
const isReadonly = shouldIgnoreInputFieldChange || isEditing
const ragPipelineVariables = useStore(state => state.ragPipelineVariables)
const setRagPipelineVariables = useStore(state => state.setRagPipelineVariables)
@ -61,6 +65,9 @@ const InputFieldPanel = () => {
}, [nodes])
const updateInputFields = useCallback(async (key: string, value: InputVar[]) => {
if (shouldIgnoreInputFieldChange)
return
inputFieldsMap.current[key] = value
const datasourceNodeInputFields: RAGPipelineVariables = []
const globalInputFields: RAGPipelineVariables = []
@ -85,7 +92,7 @@ const InputFieldPanel = () => {
const newRagPipelineVariables = [...datasourceNodeInputFields, ...globalInputFields]
setRagPipelineVariables?.(newRagPipelineVariables)
handleSyncWorkflowDraft()
}, [setRagPipelineVariables, handleSyncWorkflowDraft])
}, [shouldIgnoreInputFieldChange, setRagPipelineVariables, handleSyncWorkflowDraft])
const closePanel = useCallback(() => {
closeAllInputFieldPanels()
@ -154,7 +161,7 @@ const InputFieldPanel = () => {
nodeId={key}
LabelRightContent={<Datasource nodeData={datasourceNodeDataMap[key]!} />}
inputFields={inputFields}
readonly={isPreviewing || isEditing}
readonly={isReadonly}
labelClassName="pt-1 pb-1"
handleInputFieldsChange={updateInputFields}
allVariableNames={allVariableNames}
@ -168,7 +175,7 @@ const InputFieldPanel = () => {
nodeId="shared"
LabelRightContent={<GlobalInputs />}
inputFields={inputFieldsMap.current.shared || []}
readonly={isPreviewing || isEditing}
readonly={isReadonly}
labelClassName="pt-2 pb-1"
handleInputFieldsChange={updateInputFields}
allVariableNames={allVariableNames}