diff --git a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx
index 1191ed4447c..957b7b7dd0d 100644
--- a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx
+++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx
@@ -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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ 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()
+ it('should hide document settings when settings permission is not granted', async () => {
+ render()
- 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()
})
})
diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx
index af5b1960185..7fedde10168 100644
--- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx
+++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx
@@ -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}
>
-
+
-
+
)}
/>
@@ -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}
/>
|
diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx
index 2f6425d880a..c821211da09 100644
--- a/web/app/components/datasets/documents/components/operations.tsx
+++ b/web/app/components/datasets/documents/components/operations.tsx
@@ -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) => {
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) => {
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 = (
)
+ const renderListSwitch = () => {
+ if (!canEdit)
+ return
+
+ if (archived) {
+ return (
+
+ } />
+
+ {t('list.action.enableWarning', { ns: 'datasetDocuments' })}
+
+
+ )
+ }
+
+ return handleSwitch(v ? 'enable' : 'disable')} size="md" />
+ }
+
return (
- e.stopPropagation()}>
+
{isListScene && !embeddingAvailable && (
)}
{isListScene && embeddingAvailable && (
<>
- {archived
- ? (
-
- } />
-
- {t('list.action.enableWarning', { ns: 'datasetDocuments' })}
-
-
- )
- :
handleSwitch(v ? 'enable' : 'disable')} size="md" />}
-
+ {renderListSwitch()}
+ {hasOperationsMenu && }
>
)}
- {embeddingAvailable && (
+ {hasOperationsMenu && (
<>
- {!archived && (
+ {canShowPrimarySection && (
<>
-
- {IS_CE_EDITION && (
+ {canShowRenameAction && (
+
+ )}
+ {canShowSummaryAction && (
)}
- {settingsMenuItem}
- {data_source_type === DataSourceType.FILE && (
+ {canShowSettingsAction && settingsMenuItem}
+ {canShowDownloadAction && (
)}
- {['notion_import', DataSourceType.WEB].includes(data_source_type) && (
+ {canShowSyncAction && (
)}
-
+ {(canShowStatusSection || canShowDeleteAction) &&
}
>
)}
- {archived && (
- <>
- {settingsMenuItem}
- {data_source_type === DataSourceType.FILE && (
-
- )}
-
- >
- )}
- {!archived && display_status?.toLowerCase() === 'indexing' && (
+ {canShowPauseAction && (
)}
- {!archived && display_status?.toLowerCase() === 'paused' && (
+ {canShowResumeAction && (
)}
- {!archived && (
+ {canShowArchiveAction && (
)}
- {archived && (
+ {canShowUnarchiveAction && (
)}
-
+ {canShowDeleteAction && (
+
+ )}
diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx
index 11756c2eb8e..1d7ad17d07e 100644
--- a/web/app/components/datasets/documents/detail/index.tsx
+++ b/web/app/components/datasets/documents/detail/index.tsx
@@ -249,6 +249,10 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => {
datasetId={datasetId}
onUpdate={handleOperate}
className="w-[200px]!"
+ canEdit={canEditDocument}
+ canDownload={datasetACLCapabilities.canDocumentDownload}
+ canDelete={datasetACLCapabilities.canDeleteFile}
+ canViewSettings={canEditDocument}
/>