diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 16f41bbdf..1713436e6 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -70,15 +70,6 @@ const mockStore = async ( } renderComponent(); await executeThunk(fetchAssets(courseId), store.dispatch); - - // Finish loading the expected files into the data table before returning, - // because loading new files can disrupt things like accessing file menus. - if (status === RequestStatus.SUCCESSFUL) { - const numFiles = skipNextPageFetch ? 13 : 15; - await waitFor(() => { - expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument(); - }); - } }; const emptyMockStore = async (status) => { diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js index 3a9677918..4fbe4915c 100644 --- a/src/files-and-videos/files-page/data/slice.js +++ b/src/files-and-videos/files-page/data/slice.js @@ -28,7 +28,7 @@ const slice = createSlice({ if (isEmpty(state.assetIds)) { state.assetIds = payload.assetIds; } else { - state.assetIds = [...state.assetIds, ...payload.assetIds]; + state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])]; } }, setSortedAssetIds: (state, { payload }) => { diff --git a/src/files-and-videos/generic/DeleteConfirmationModal.jsx b/src/files-and-videos/generic/DeleteConfirmationModal.jsx index 0955c83a5..b6f462785 100644 --- a/src/files-and-videos/generic/DeleteConfirmationModal.jsx +++ b/src/files-and-videos/generic/DeleteConfirmationModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; @@ -7,6 +7,7 @@ import { AlertModal, Button, Collapsible, + DataTableContext, Hyperlink, Truncate, } from '@openedx/paragon'; @@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({ // injected intl, }) => { + const { clearSelection } = useContext(DataTableContext); + + const handleConfirmDeletion = () => { + handleBulkDelete(); + clearSelection(); + }; + const firstSelectedRow = selectedRows[0]?.original; let activeContentRows = []; if (Array.isArray(selectedRows)) { @@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({ - diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index ded6884a8..219148fd7 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -273,6 +273,16 @@ const FileTable = ({ setSelectedRows={setSelectedRows} fileType={fileType} /> + + {!isEmpty(selectedRows) && ( @@ -286,15 +296,7 @@ const FileTable = ({ sidebar={infoModalSidebar} /> )} - + ); }; diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index 3e813e6c4..0663ea849 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -26,13 +26,18 @@ const TableActions = ({ intl, }) => { const [isSortOpen, openSort, closeSort] = useToggle(false); - const { state } = useContext(DataTableContext); + const { state, clearSelection } = useContext(DataTableContext); // This useEffect saves DataTable state so it can persist after table re-renders due to data reload. useEffect(() => { setInitialState(state); }, [state]); + const handleOpenFileSelector = () => { + fileInputControl.click(); + clearSelection(); + }; + return ( <> diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx new file mode 100644 index 000000000..d97f3b2bc --- /dev/null +++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMocks, render } from '../../../testUtils'; +import TableActions from './TableActions'; +import messages from '../messages'; + +const defaultProps = { + selectedFlatRows: [], + fileInputControl: { click: jest.fn() }, + handleOpenDeleteConfirmation: jest.fn(), + handleBulkDownload: jest.fn(), + encodingsDownloadUrl: null, + handleSort: jest.fn(), + fileType: 'video', + setInitialState: jest.fn(), + intl: { + formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''), + }, +}; + +const mockColumns = [ + { + id: 'wrapperType', + Header: 'Type', + accessor: 'wrapperType', + filter: 'includes', + }, +]; + +const renderWithContext = (props = {}, contextOverrides = {}) => { + const contextValue = { + state: { + selectedRowIds: {}, + filters: [], + ...contextOverrides.state, + }, + clearSelection: jest.fn(), + gotoPage: jest.fn(), + setAllFilters: jest.fn(), + columns: mockColumns, + ...contextOverrides, + }; + + return render( + + + , + ); +}; + +describe('TableActions', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + test('renders buttons and dropdown', () => { + renderWithContext(); + + expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument(); + }); + + test('disables bulk and delete actions if no rows selected', () => { + renderWithContext(); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + + const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage); + const deleteButton = screen.getByTestId('open-delete-confirmation-button'); + + expect(downloadOption).toHaveAttribute('aria-disabled', 'true'); + expect(downloadOption).toHaveClass('disabled'); + + expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); + expect(deleteButton).toHaveClass('disabled'); + }); + + test('enables bulk and delete actions when rows are selected', () => { + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled(); + expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled(); + }); + + test('calls file input click and clears selection when add button clicked', () => { + const mockClick = jest.fn(); + const mockClear = jest.fn(); + + renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear); + fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })); + expect(mockClick).toHaveBeenCalled(); + }); + + test('opens sort modal when sort button clicked', () => { + renderWithContext(); + fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + test('calls handleBulkDownload when selected and clicked', () => { + const handleBulkDownload = jest.fn(); + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + handleBulkDownload, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage)); + expect(handleBulkDownload).toHaveBeenCalled(); + }); + + test('calls handleOpenDeleteConfirmation when clicked', () => { + const handleOpenDeleteConfirmation = jest.fn(); + const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }]; + renderWithContext({ + selectedFlatRows, + handleOpenDeleteConfirmation, + }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + fireEvent.click(screen.getByTestId('open-delete-confirmation-button')); + expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows); + }); + + test('shows encoding download link when provided', () => { + const encodingsDownloadUrl = '/some/path/to/encoding.zip'; + renderWithContext({ encodingsDownloadUrl }); + + fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl)); + }); +});