mirror of
https://github.com/langgenius/dify.git
synced 2026-07-15 01:17:04 +08:00
feat: implement access control capabilities for document operations and input field panel
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user