diff --git a/packages/react-ui/src/app/features/flows/lib/flows-api.ts b/packages/react-ui/src/app/features/flows/lib/flows-api.ts index 5082ce7e2a..2fab411e5b 100644 --- a/packages/react-ui/src/app/features/flows/lib/flows-api.ts +++ b/packages/react-ui/src/app/features/flows/lib/flows-api.ts @@ -8,6 +8,7 @@ import { CreateEmptyFlowRequest, CreateFlowFromTemplateRequest, CreateStepRunRequestBody, + DeleteFlowsRequest, FlowImportTemplate, FlowOperationRequest, FlowRun, @@ -18,6 +19,7 @@ import { ListFlowVersionRequest, ListFlowsRequest, MinimalFlow, + MoveFlowsRequest, PopulatedFlow, RunFlowSuccessResponse, SeekPage, @@ -121,6 +123,13 @@ export const flowsApi = { delete(flowId: string) { return api.delete(`/v1/flows/${flowId}`); }, + deleteMany(request: DeleteFlowsRequest) { + const query = qs.stringify(request, { arrayFormat: 'repeat' }); + return api.delete(`/v1/flows?${query}`); + }, + moveMany(request: MoveFlowsRequest) { + return api.post('/v1/flows/move', request); + }, count() { return api.get('/v1/flows/count'); }, diff --git a/packages/react-ui/src/app/routes/flows/index.tsx b/packages/react-ui/src/app/routes/flows/index.tsx index b3c8a446fa..7a34fc5de5 100644 --- a/packages/react-ui/src/app/routes/flows/index.tsx +++ b/packages/react-ui/src/app/routes/flows/index.tsx @@ -1,12 +1,20 @@ import { + Button, DataTable, + DataTableBulkAction, FOLDER_ID_PARAM_NAME, PaginationParams, + toast, + WarningWithIcon, } from '@openops/components/ui'; +import { useMutation } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { CornerUpLeft, Download, Trash2 } from 'lucide-react'; import qs from 'qs'; import { useCallback, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ConfirmationDeleteDialog } from '@/app/common/components/delete-dialog'; import { useCheckAccessAndRedirect } from '@/app/common/hooks/authorization-hooks'; import { useDefaultSidebarState } from '@/app/common/hooks/use-default-sidebar-state'; import { isModifierOrMiddleClick } from '@/app/common/navigation/table-navigation-helper'; @@ -15,7 +23,13 @@ import { createColumns, } from '@/app/features/flows/flows-columns'; import { flowsApi } from '@/app/features/flows/lib/flows-api'; +import { flowsUtils } from '@/app/features/flows/lib/flows-utils'; import { FlowsFolderBreadcrumbsManager } from '@/app/features/folders/component/folder-breadcrumbs-manager'; +import { + MoveToFolderDialog, + MoveToFolderFormSchema, +} from '@/app/features/folders/component/move-to-folder-dialog'; +import { useRefetchFolderTree } from '@/app/features/folders/hooks/refetch-folder-tree'; import { FLOWS_TABLE_FILTERS } from '@/app/features/folders/lib/flows-table-filters'; import { isSortDirection } from '@/app/lib/sort-direction'; import { @@ -23,21 +37,51 @@ import { FlowStatus, FlowVersionState, Permission, + PopulatedFlow, } from '@openops/shared'; const isFlowSortBy = (sortBy?: string): sortBy is FlowSortBy => { return !!sortBy && Object.values(FlowSortBy).includes(sortBy as FlowSortBy); }; +const getFlowIds = (rows: PopulatedFlow[]): string[] => { + return rows.map((flow) => flow.id); +}; + const FlowsPage = () => { useDefaultSidebarState('expanded'); const hasAccess = useCheckAccessAndRedirect(Permission.READ_FLOW); const navigate = useNavigate(); const [tableRefresh, setTableRefresh] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); + const refetchFolderTree = useRefetchFolderTree(); const onTableRefresh = useCallback( () => setTableRefresh((prev) => !prev), [], ); + const { mutateAsync: deleteFlows, isPending: isDeleteFlowsPending } = + useMutation({ + mutationFn: async (flowIds: string[]) => { + await flowsApi.deleteMany({ flowIds }); + }, + }); + const { mutateAsync: exportFlows, isPending: isExportFlowsPending } = + useMutation({ + mutationFn: async (flows: PopulatedFlow[]) => { + await Promise.all( + flows.map((flow) => + flowsUtils.downloadFlow(flow.id, flow.version.id), + ), + ); + }, + onSuccess: () => { + toast({ + title: t('Success'), + description: t('Workflows have been exported.'), + duration: 3000, + }); + }, + }); const [searchParams] = useSearchParams(); @@ -71,7 +115,130 @@ const FlowsPage = () => { createColumns(onTableRefresh).filter( (column) => column.accessorKey !== 'folderId', ), - [], + [onTableRefresh], + ); + + const resetSelectedRows = useCallback(() => { + setSelectedRows([]); + }, []); + + const completeBulkAction = useCallback( + (resetSelection: () => void) => { + resetSelection(); + resetSelectedRows(); + onTableRefresh(); + }, + [onTableRefresh, resetSelectedRows], + ); + + const moveSelectedFlows = useCallback( + async (folderId: string) => { + await flowsApi.moveMany({ + flowIds: getFlowIds(selectedRows), + folderId, + }); + }, + [selectedRows], + ); + + const deleteSelectedFlows = useCallback(async () => { + await deleteFlows(getFlowIds(selectedRows)); + await refetchFolderTree(); + }, [deleteFlows, refetchFolderTree, selectedRows]); + + const exportSelectedFlows = useCallback(async () => { + await exportFlows(selectedRows); + }, [exportFlows, selectedRows]); + + const bulkActions = useMemo[]>( + () => [ + { + render: (_selectedRows, resetSelection) => ( + { + await moveSelectedFlows(data.folder); + return { success: true }; + }} + onMoveTo={() => completeBulkAction(resetSelection)} + > + + + ), + }, + { + render: (_selectedRows, _resetSelection) => ( + + ), + }, + { + render: (_selectedRows, resetSelection) => ( + + {t('Delete workflows')} + + } + className="max-w-[700px]" + message={ + + {t('Are you sure you want to delete {count} workflows?', { + count: selectedRows.length, + })} + + } + mutationFn={async () => { + await deleteSelectedFlows(); + completeBulkAction(resetSelection); + }} + entityName={t('workflows')} + content={ + + } + > + + + ), + }, + ], + [ + deleteFlows, + isDeleteFlowsPending, + isExportFlowsPending, + onTableRefresh, + exportSelectedFlows, + completeBulkAction, + deleteSelectedFlows, + moveSelectedFlows, + refetchFolderTree, + selectedRows, + ], ); if (!hasAccess) { @@ -93,6 +260,9 @@ const FlowsPage = () => { columnVisibility={columnVisibility} navigationExcludedColumns={['status', 'actions']} refresh={tableRefresh} + enableSelection={true} + onSelectedRowsChange={(rows) => setSelectedRows(rows)} + bulkActions={bulkActions} getRowHref={(row) => `/flows/${row.id}?${qs.stringify({ folderId: searchParams.get(FOLDER_ID_PARAM_NAME), diff --git a/packages/server/api/src/app/flows/flow/flow.controller.ts b/packages/server/api/src/app/flows/flow/flow.controller.ts index 4c1efd8944..923725d4c0 100644 --- a/packages/server/api/src/app/flows/flow/flow.controller.ts +++ b/packages/server/api/src/app/flows/flow/flow.controller.ts @@ -7,6 +7,7 @@ import { CountFlowsRequest, CreateEmptyFlowRequest, CreateFlowFromTemplateRequest, + DeleteFlowsRequest, ErrorCode, ExecutionType, FlowOperationRequest, @@ -17,6 +18,7 @@ import { GetFlowTemplateRequestQuery, ListFlowsRequest, ListFlowVersionRequest, + MoveFlowsRequest, OpenOpsId, openOpsId, Permission, @@ -94,6 +96,16 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => { return updatedFlow; }); + app.post('/move', MoveFlowsRequestOptions, async (request, reply) => { + await flowService.moveMany({ + flowIds: request.body.flowIds, + folderId: request.body.folderId ?? null, + projectId: request.principal.projectId, + }); + + return reply.status(StatusCodes.NO_CONTENT).send(); + }); + app.get('/', ListFlowsRequestOptions, async (request) => { // TODO: use ListFlowsRequest.versionState to filter flows by version state return flowService.list({ @@ -143,6 +155,16 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => { return reply.status(StatusCodes.NO_CONTENT).send(); }); + app.delete('/', DeleteFlowsRequestOptions, async (request, reply) => { + await flowService.deleteMany({ + flowIds: request.query.flowIds, + userId: request.principal.id, + projectId: request.principal.projectId, + }); + + return reply.status(StatusCodes.NO_CONTENT).send(); + }); + app.get('/:id/versions', GetFlowVersionRequestOptions, async (request) => { const flow = await flowService.getOneOrThrow({ id: request.params.id, @@ -306,6 +328,24 @@ const UpdateFlowRequestOptions = { }, }; +const MoveFlowsRequestOptions = { + config: { + security: getProjectScopedRoutePolicy({ + allowedPrincipals: [PrincipalType.USER, PrincipalType.SERVICE], + permission: Permission.WRITE_FLOW, + }), + }, + schema: { + tags: ['flows'], + description: 'Move multiple flows to another folder in a single request.', + security: [SERVICE_KEY_SECURITY_OPENAPI], + body: MoveFlowsRequest, + response: { + [StatusCodes.NO_CONTENT]: Type.Never(), + }, + }, +}; + const ListFlowsRequestOptions = { config: { security: getProjectScopedRoutePolicy({ @@ -429,6 +469,25 @@ const DeleteFlowRequestOptions = { }, }; +const DeleteFlowsRequestOptions = { + config: { + security: getProjectScopedRoutePolicy({ + allowedPrincipals: [PrincipalType.USER, PrincipalType.SERVICE], + permission: Permission.DELETE_FLOW, + }), + }, + schema: { + tags: ['flows'], + security: [SERVICE_KEY_SECURITY_OPENAPI], + description: + 'Permanently delete multiple flows and all associated versions. This operation cannot be undone.', + querystring: DeleteFlowsRequest, + response: { + [StatusCodes.NO_CONTENT]: Type.Never(), + }, + }, +}; + const RunFlowRequestOptions = { config: { security: getProjectScopedRoutePolicy({ diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 89e7c1dbd4..67f5aad974 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -453,6 +453,60 @@ export const flowService = { } }, + async deleteMany({ + flowIds, + projectId, + userId, + }: DeleteManyParams): Promise { + await Promise.all( + flowIds.map((id) => + this.delete({ + id, + projectId, + userId, + }), + ), + ); + }, + + async moveMany({ + flowIds, + folderId, + projectId, + }: MoveManyParams): Promise { + await ensureFolderContentTypeMatches({ + projectId, + folderId, + contentType: ContentType.WORKFLOW, + }); + + const flows = await flowRepo().findBy({ + id: In(flowIds), + projectId, + }); + + if (flows.length !== flowIds.length) { + throw new ApplicationError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: {}, + }); + } + + for (const flow of flows) { + await assertThatFlowIsNotInternal(flow); + } + + await flowRepo().update( + { + id: In(flowIds), + projectId, + }, + { + folderId, + }, + ); + }, + async getAllEnabled(): Promise { return flowRepo().findBy({ status: FlowStatus.ENABLED, @@ -865,6 +919,18 @@ type DeleteParams = { projectId: ProjectId; }; +type DeleteManyParams = { + flowIds: FlowId[]; + userId: UserId; + projectId: ProjectId; +}; + +type MoveManyParams = { + flowIds: FlowId[]; + folderId: string | null; + projectId: ProjectId; +}; + type NewFlow = Omit; type LockFlowVersionIfNotLockedParams = { diff --git a/packages/server/api/test/integration/ce/flows/flow.test.ts b/packages/server/api/test/integration/ce/flows/flow.test.ts index 260ff902e2..543921b874 100644 --- a/packages/server/api/test/integration/ce/flows/flow.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow.test.ts @@ -1516,6 +1516,149 @@ describe('Flow API', () => { .findOneBy({ id: mockInternalFlow.id }); expect(flowStillExists).toBeTruthy(); }); + + it('Successfully deletes multiple non-internal flows', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const mockFlow1 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + const mockFlow2 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + await databaseConnection() + .getRepository('flow') + .save([mockFlow1, mockFlow2]); + + const mockFlowVersion1 = createMockFlowVersion({ + flowId: mockFlow1.id, + }); + const mockFlowVersion2 = createMockFlowVersion({ + flowId: mockFlow2.id, + }); + await databaseConnection() + .getRepository('flow_version') + .save([mockFlowVersion1, mockFlowVersion2]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'DELETE', + url: '/v1/flows', + headers: { + authorization: `Bearer ${mockToken}`, + }, + query: { + flowIds: [mockFlow1.id, mockFlow2.id], + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT); + + const deletedFlow1 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow1.id }); + const deletedFlow2 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow2.id }); + + expect(deletedFlow1).toBeNull(); + expect(deletedFlow2).toBeNull(); + }); + }); + + describe('Move Flow endpoint', () => { + it('Successfully moves multiple non-internal flows', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const sourceFolder = createMockFolder(mockProject.id); + const targetFolder = createMockFolder(mockProject.id); + await databaseConnection() + .getRepository('folder') + .save([sourceFolder, targetFolder]); + + const mockFlow1 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + folderId: sourceFolder.id, + }); + const mockFlow2 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + folderId: sourceFolder.id, + }); + await databaseConnection() + .getRepository('flow') + .save([mockFlow1, mockFlow2]); + + const mockFlowVersion1 = createMockFlowVersion({ + flowId: mockFlow1.id, + }); + const mockFlowVersion2 = createMockFlowVersion({ + flowId: mockFlow2.id, + }); + await databaseConnection() + .getRepository('flow_version') + .save([mockFlowVersion1, mockFlowVersion2]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'POST', + url: '/v1/flows/move', + headers: { + authorization: `Bearer ${mockToken}`, + }, + body: { + flowIds: [mockFlow1.id, mockFlow2.id], + folderId: targetFolder.id, + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT); + + const movedFlow1 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow1.id }); + const movedFlow2 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow2.id }); + + expect(movedFlow1?.folderId).toBe(targetFolder.id); + expect(movedFlow2?.folderId).toBe(targetFolder.id); + }); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 02a40de635..7590e0c21c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -39,8 +39,10 @@ export * from './lib/flows/actions/action'; export * from './lib/flows/actions/frontend-blocks'; export * from './lib/flows/dto/count-flows-request'; export * from './lib/flows/dto/create-flow-request'; +export * from './lib/flows/dto/delete-flows-request'; export * from './lib/flows/dto/flow-template-request'; export * from './lib/flows/dto/list-flows-request'; +export * from './lib/flows/dto/move-flows-request'; export * from './lib/flows/flow'; export * from './lib/flows/flow-helper'; export * from './lib/flows/flow-operations'; diff --git a/packages/shared/src/lib/flows/dto/delete-flows-request.ts b/packages/shared/src/lib/flows/dto/delete-flows-request.ts new file mode 100644 index 0000000000..55f21400e1 --- /dev/null +++ b/packages/shared/src/lib/flows/dto/delete-flows-request.ts @@ -0,0 +1,10 @@ +import { Static, Type } from '@sinclair/typebox'; +import { OpenOpsId } from '../../common/id-generator'; + +export const DeleteFlowsRequest = Type.Object({ + flowIds: Type.Array(OpenOpsId, { + minItems: 1, + }), +}); + +export type DeleteFlowsRequest = Static; diff --git a/packages/shared/src/lib/flows/dto/move-flows-request.ts b/packages/shared/src/lib/flows/dto/move-flows-request.ts new file mode 100644 index 0000000000..c1747b4137 --- /dev/null +++ b/packages/shared/src/lib/flows/dto/move-flows-request.ts @@ -0,0 +1,12 @@ +import { Static, Type } from '@sinclair/typebox'; +import { Nullable } from '../../common'; +import { OpenOpsId } from '../../common/id-generator'; + +export const MoveFlowsRequest = Type.Object({ + flowIds: Type.Array(OpenOpsId, { + minItems: 1, + }), + folderId: Nullable(OpenOpsId), +}); + +export type MoveFlowsRequest = Static; diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index beb846a833..b62abe33bd 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -21,6 +21,7 @@ export * from './ui/data-table'; export * from './ui/data-table-column-header'; export * from './ui/data-table-options-filter'; export * from './ui/data-table-select-popover'; +export * from './ui/data-table-selection-bar'; export * from './ui/data-table-skeleton'; export * from './ui/data-table-toolbar'; export * from './ui/date-picker-range'; diff --git a/packages/ui-components/src/ui/data-table-selection-bar.tsx b/packages/ui-components/src/ui/data-table-selection-bar.tsx new file mode 100644 index 0000000000..5f7a58ce75 --- /dev/null +++ b/packages/ui-components/src/ui/data-table-selection-bar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { t } from 'i18next'; +import { X } from 'lucide-react'; +import React from 'react'; + +import { Button } from './button'; + +type DataTableSelectionBarProps = { + selectedCount: number; + onClearSelection: () => void; + children: React.ReactNode; +}; + +export function DataTableSelectionBar({ + selectedCount, + onClearSelection, + children, +}: DataTableSelectionBarProps) { + if (selectedCount <= 0) { + return null; + } + + return ( +
+
+ {children} +
+ + {t('{count} selected', { count: selectedCount })} + + +
+
+ ); +} diff --git a/packages/ui-components/src/ui/data-table.tsx b/packages/ui-components/src/ui/data-table.tsx index 089051e76e..5376d69728 100644 --- a/packages/ui-components/src/ui/data-table.tsx +++ b/packages/ui-components/src/ui/data-table.tsx @@ -1,10 +1,12 @@ 'use client'; import { + CellContext, ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, + HeaderContext, SortingState, useReactTable, VisibilityState, @@ -18,8 +20,10 @@ import { SeekPage, SortDirection } from '@openops/shared'; import { cn } from '../lib/cn'; import { Button } from './button'; +import { Checkbox } from './checkbox'; import { DataTableColumnHeader } from './data-table-column-header'; import { DataTableFacetedFilter } from './data-table-options-filter'; +import { DataTableSelectionBar } from './data-table-selection-bar'; import { DataTableSkeleton } from './data-table-skeleton'; import { DataTableToolbar } from './data-table-toolbar'; import { @@ -89,6 +93,57 @@ type DataTableAction = ( row: RowDataWithActions, ) => JSX.Element; +function DataTableSelectAllHeader({ + table, +}: HeaderContext, TValue>) { + return ( + table.toggleAllPageRowsSelected(!!value)} + /> + ); +} + +function DataTableSelectRowCell({ + row, +}: CellContext, TValue>) { + return ( + row.toggleSelected(!!value)} + /> + ); +} + +function DataTableActionsColumnHeader({ + column, +}: HeaderContext, TValue>) { + return ; +} + +function DataTableActionsCell({ + rowOriginal, + actions, +}: { + rowOriginal: RowDataWithActions; + actions: DataTableAction[]; +}) { + return ( +
+ {actions.map((action, index) => ( + {action(rowOriginal)} + ))} +
+ ); +} + +export type DataTableBulkAction = { + render: ( + selectedRows: RowDataWithActions[], + resetSelection: () => void, + ) => React.ReactNode; +}; + export type PaginationParams = { cursor?: string; limit?: number; @@ -129,6 +184,8 @@ interface DataTableProps< navigationExcludedColumns?: string[]; enableSorting?: boolean; syncWithSearchParams?: boolean; + enableSelection?: boolean; + bulkActions?: DataTableBulkAction[]; } export function DataTable< @@ -155,29 +212,33 @@ export function DataTable< navigationExcludedColumns, enableSorting = false, syncWithSearchParams = true, + enableSelection = false, + bulkActions = [], }: DataTableProps) { - const columns = columnsInitial.concat([ + const selectionColumn: ColumnDef, TValue> = { + id: '__select', + accessorKey: '__select', + enableSorting: false, + enableHiding: false, + meta: { className: 'w-10' }, + header: DataTableSelectAllHeader, + cell: DataTableSelectRowCell, + }; + const columns: ColumnDef, TValue>[] = [ + ...(enableSelection ? [selectionColumn] : []), + ...columnsInitial, { accessorKey: '__actions', enableSorting: false, - header: ({ column }) => ( - + header: DataTableActionsColumnHeader, + cell: ({ row }) => ( + + rowOriginal={row.original} + actions={actions} + /> ), - cell: ({ row }) => { - return ( -
- {actions.map((action, index) => { - return ( - - {action(row.original)} - - ); - })} -
- ); - }, }, - ]); + ]; const [searchParams, setSearchParams] = useSearchParams(); const startingCursor = syncWithSearchParams @@ -294,6 +355,7 @@ export function DataTable< data: tableData, columns, manualPagination: true, + enableRowSelection: enableSelection, enableSorting, manualSorting, getCoreRowModel: getCoreRowModel(), @@ -303,6 +365,7 @@ export function DataTable< columnVisibility, sorting, }, + getRowId: (row, index) => row.id ?? `${index}`, initialState: { pagination: { pageSize: parseInt(startingLimit), @@ -330,7 +393,15 @@ export function DataTable< onSelectedRowsChange?.( table.getSelectedRowModel().rows.map((row) => row.original), ); - }, [table.getSelectedRowModel().rows]); + }, [table.getState().rowSelection]); + + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + + const resetSelection = () => { + table.toggleAllRowsSelected(false); + }; useEffect(() => { if (!syncWithSearchParams) { @@ -475,8 +546,14 @@ export function DataTable< { + if (cell.column.id === '__select') { + e.stopPropagation(); + } + }} > {rowHref && + cell.column.id !== '__select' && !navigationExcludedColumns?.includes( cell.column.id, ) ? ( @@ -550,6 +627,18 @@ export function DataTable<
)} + {bulkActions.length > 0 && selectedRows.length > 0 && ( + + {bulkActions.map((action, index) => ( + + {action.render(selectedRows, resetSelection)} + + ))} + + )} ); }