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} />