diff --git a/kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx b/kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx index 530f0db1471..d598ffcc708 100644 --- a/kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx +++ b/kafka-ui-react-app/src/components/Connect/Details/Actions/Actions.tsx @@ -51,16 +51,29 @@ const Actions: React.FC = () => { ); const stateMutation = useUpdateConnectorState(routerProps); + const mutationParams = (action: ConnectorAction) => { + return { + clusterName: routerProps.clusterName, + connectName: routerProps.connectName, + connectorName: routerProps.connectorName, + action, + }; + }; const restartConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART); + stateMutation.mutateAsync(mutationParams(ConnectorAction.RESTART)); const restartAllTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_ALL_TASKS); + stateMutation.mutateAsync( + mutationParams(ConnectorAction.RESTART_ALL_TASKS) + ); const restartFailedTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_FAILED_TASKS); + stateMutation.mutateAsync( + mutationParams(ConnectorAction.RESTART_FAILED_TASKS) + ); const pauseConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.PAUSE); + stateMutation.mutateAsync(mutationParams(ConnectorAction.PAUSE)); const resumeConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESUME); + stateMutation.mutateAsync(mutationParams(ConnectorAction.RESUME)); + return ( { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart Connector' }) ); - expect(restartConnector).toHaveBeenCalledWith(ConnectorAction.RESTART); + expect(restartConnector).toHaveBeenCalledWith({ + action: ConnectorAction.RESTART, + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }); }); it('calls restartAllTasks', async () => { @@ -151,9 +156,12 @@ describe('Actions', () => { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart All Tasks' }) ); - expect(restartAllTasks).toHaveBeenCalledWith( - ConnectorAction.RESTART_ALL_TASKS - ); + expect(restartAllTasks).toHaveBeenCalledWith({ + action: ConnectorAction.RESTART_ALL_TASKS, + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }); }); it('calls restartFailedTasks', async () => { @@ -166,9 +174,12 @@ describe('Actions', () => { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart Failed Tasks' }) ); - expect(restartFailedTasks).toHaveBeenCalledWith( - ConnectorAction.RESTART_FAILED_TASKS - ); + expect(restartFailedTasks).toHaveBeenCalledWith({ + action: ConnectorAction.RESTART_FAILED_TASKS, + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }); }); it('calls pauseConnector when pause button clicked', async () => { @@ -179,7 +190,12 @@ describe('Actions', () => { renderComponent(); await afterClickRestartButton(); await userEvent.click(screen.getByRole('menuitem', { name: 'Pause' })); - expect(pauseConnector).toHaveBeenCalledWith(ConnectorAction.PAUSE); + expect(pauseConnector).toHaveBeenCalledWith({ + action: ConnectorAction.PAUSE, + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }); }); it('calls resumeConnector when resume button clicked', async () => { @@ -193,7 +209,12 @@ describe('Actions', () => { renderComponent(); await afterClickRestartButton(); await userEvent.click(screen.getByRole('menuitem', { name: 'Resume' })); - expect(resumeConnector).toHaveBeenCalledWith(ConnectorAction.RESUME); + expect(resumeConnector).toHaveBeenCalledWith({ + action: ConnectorAction.RESUME, + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }); }); }); }); diff --git a/kafka-ui-react-app/src/components/Connect/List/BatchActionsBar.tsx b/kafka-ui-react-app/src/components/Connect/List/BatchActionsBar.tsx new file mode 100644 index 00000000000..14f5d5915df --- /dev/null +++ b/kafka-ui-react-app/src/components/Connect/List/BatchActionsBar.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { + Action, + ResourceType, + ConnectorAction, + Connector, +} from 'generated-sources'; +import useAppParams from 'lib/hooks/useAppParams'; +import { useConfirm } from 'lib/hooks/useConfirm'; +import { RouterParamsClusterConnectConnector } from 'lib/paths'; +import { useIsMutating, useQueryClient } from '@tanstack/react-query'; +import { ActionCanButton } from 'components/common/ActionComponent'; +import { + useDeleteConnector, + useUpdateConnectorState, +} from 'lib/hooks/api/kafkaConnect'; +import { Row } from '@tanstack/react-table'; +import { isPermitted } from 'lib/permissions'; +import { useUserInfo } from 'lib/hooks/useUserInfo'; + +interface BatchActionsBarProps { + rows: Row[]; + resetRowSelection(): void; +} + +const BatchActionsBar: React.FC = ({ + rows, + resetRowSelection, +}) => { + const confirm = useConfirm(); + + const selectedConnectors = rows.map(({ original }) => original); + + const mutationsNumber = useIsMutating(); + const isMutating = mutationsNumber > 0; + + const routerProps = useAppParams(); + const { clusterName } = routerProps; + const client = useQueryClient(); + const { roles, rbacFlag } = useUserInfo(); + + const canPerformActionOnSelected = (action: Action) => { + return selectedConnectors.every((connector) => + isPermitted({ + roles, + resource: ResourceType.CONNECT, + action, + value: connector.name, + clusterName, + rbacFlag, + }) + ); + }; + + const canEdit = canPerformActionOnSelected(Action.EDIT); + const canDelete = canPerformActionOnSelected(Action.DELETE); + + const deleteConnectorMutation = useDeleteConnector(routerProps); + const deleteConnectorsHandler = () => { + confirm( + 'Are you sure you want to remove selected connectors?', + async () => { + try { + await Promise.all( + selectedConnectors.map((connector) => + deleteConnectorMutation.mutateAsync({ + clusterName, + connectName: connector.connect, + connectorName: connector.name, + }) + ) + ); + resetRowSelection(); + } catch (e) { + // do nothing; + } finally { + client.invalidateQueries(['clusters', clusterName, 'connectors']); + } + } + ); + }; + + const stateMutation = useUpdateConnectorState(routerProps); + const updateConnector = (action: ConnectorAction, message: string) => { + confirm(message, async () => { + try { + await Promise.all( + selectedConnectors.map((connector) => + stateMutation.mutateAsync({ + clusterName, + connectName: connector.connect, + connectorName: connector.name, + action, + }) + ) + ); + resetRowSelection(); + } catch (e) { + // do nothing; + } finally { + client.invalidateQueries(['clusters', clusterName, 'connectors']); + } + }); + }; + const restartConnectorHandler = () => { + updateConnector( + ConnectorAction.RESTART, + 'Are you sure you want to restart selected connectors?' + ); + }; + const restartAllTasksHandler = () => + updateConnector( + ConnectorAction.RESTART_ALL_TASKS, + 'Are you sure you want to restart all tasks in selected connectors?' + ); + const restartFailedTasksHandler = () => + updateConnector( + ConnectorAction.RESTART_FAILED_TASKS, + 'Are you sure you want to restart failed tasks in selected connectors?' + ); + const pauseConnectorHandler = () => + updateConnector( + ConnectorAction.PAUSE, + 'Are you sure you want to pause selected connectors?' + ); + const resumeConnectorHandler = () => + updateConnector( + ConnectorAction.RESUME, + 'Are you sure you want to resume selected connectors?' + ); + + return ( + <> + + Pause + + + Resume + + + Restart Connector + + + Restart All Tasks + + + Restart Failed Tasks + + + Delete + + + ); +}; + +export default BatchActionsBar; diff --git a/kafka-ui-react-app/src/components/Connect/List/ConnectorCell.tsx b/kafka-ui-react-app/src/components/Connect/List/ConnectorCell.tsx new file mode 100644 index 00000000000..9f7054127d2 --- /dev/null +++ b/kafka-ui-react-app/src/components/Connect/List/ConnectorCell.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { CellContext } from '@tanstack/react-table'; +import { FullConnectorInfo } from 'generated-sources'; +import { useNavigate } from 'react-router-dom'; +import { clusterConnectConnectorPath, ClusterNameRoute } from 'lib/paths'; +import useAppParams from 'lib/hooks/useAppParams'; + +const ConnectorCell: React.FC> = ({ + row: { original }, +}) => { + const navigate = useNavigate(); + const { name, connect } = original; + const { clusterName } = useAppParams(); + const path = clusterConnectConnectorPath(clusterName, connect, name); + const handleOnClick = () => navigate(path); + + return ( +
+ {name} +
+ ); +}; + +export default ConnectorCell; diff --git a/kafka-ui-react-app/src/components/Connect/List/List.tsx b/kafka-ui-react-app/src/components/Connect/List/List.tsx index b5935e7bab2..6873fc7e1b7 100644 --- a/kafka-ui-react-app/src/components/Connect/List/List.tsx +++ b/kafka-ui-react-app/src/components/Connect/List/List.tsx @@ -1,18 +1,19 @@ import React from 'react'; import useAppParams from 'lib/hooks/useAppParams'; -import { clusterConnectConnectorPath, ClusterNameRoute } from 'lib/paths'; +import { ClusterNameRoute } from 'lib/paths'; import Table, { TagCell } from 'components/common/NewTable'; import { FullConnectorInfo } from 'generated-sources'; import { useConnectors } from 'lib/hooks/api/kafkaConnect'; import { ColumnDef } from '@tanstack/react-table'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import ActionsCell from './ActionsCell'; import TopicsCell from './TopicsCell'; +import ConnectorCell from './ConnectorCell'; import RunningTasksCell from './RunningTasksCell'; +import BatchActionsBar from './BatchActionsBar'; const List: React.FC = () => { - const navigate = useNavigate(); const { clusterName } = useAppParams(); const [searchParams] = useSearchParams(); const { data: connectors } = useConnectors( @@ -22,7 +23,7 @@ const List: React.FC = () => { const columns = React.useMemo[]>( () => [ - { header: 'Name', accessorKey: 'name' }, + { header: 'Name', accessorKey: 'name', cell: ConnectorCell }, { header: 'Connect', accessorKey: 'connect' }, { header: 'Type', accessorKey: 'type' }, { header: 'Plugin', accessorKey: 'connectorClass' }, @@ -39,9 +40,8 @@ const List: React.FC = () => { data={connectors || []} columns={columns} enableSorting - onRowClick={({ original: { connect, name } }) => - navigate(clusterConnectConnectorPath(clusterName, connect, name)) - } + batchActionsBar={BatchActionsBar} + enableRowSelection emptyMessage="No connectors found" /> ); diff --git a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx index 82b4aab2126..87d232f44b2 100644 --- a/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx +++ b/kafka-ui-react-app/src/components/Connect/List/__tests__/List.spec.tsx @@ -17,6 +17,7 @@ import { const mockedUsedNavigate = jest.fn(); const mockDelete = jest.fn(); +const mockUpdate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -61,20 +62,149 @@ describe('Connectors List', () => { it('opens broker when row clicked', async () => { renderComponent(); - await userEvent.click( - screen.getByRole('row', { - name: 'hdfs-source-connector first SOURCE FileStreamSource a b c RUNNING 2 of 2', - }) - ); - await waitFor(() => + + const link = await screen.findByRole('cell', { + name: 'hdfs-source-connector', + }); + + await userEvent.click(link); + await waitFor(() => { expect(mockedUsedNavigate).toBeCalledWith( clusterConnectConnectorPath( clusterName, 'first', 'hdfs-source-connector' ) - ) - ); + ); + }); + }); + + describe('Selectable rows', () => { + it('renders selectable rows', () => { + renderComponent(); + expect(screen.getAllByRole('checkbox').length).toEqual(3); + }); + }); + + describe('Batch actions bar', () => { + const getButtonByName = (name: string) => + screen.getByRole('button', { name }); + + beforeEach(async () => { + (useDeleteConnector as jest.Mock).mockImplementation(() => ({ + mutateAsync: mockDelete, + })); + (useUpdateConnectorState as jest.Mock).mockImplementation(() => ({ + mutateAsync: mockUpdate, + })); + + renderComponent(); + await userEvent.click(screen.getAllByRole('checkbox')[1]); + }); + it('renders batch actions bar', () => { + expect(getButtonByName('Pause')).toBeInTheDocument(); + expect(getButtonByName('Resume')).toBeInTheDocument(); + expect(getButtonByName('Restart Connector')).toBeInTheDocument(); + expect(getButtonByName('Restart All Tasks')).toBeInTheDocument(); + expect(getButtonByName('Restart Failed Tasks')).toBeInTheDocument(); + expect(getButtonByName('Delete')).toBeInTheDocument(); + }); + + it('handles delete button click', async () => { + const button = getButtonByName('Delete'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to remove selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockDelete).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockDelete).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + + it('handles pause button click', async () => { + const button = getButtonByName('Pause'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to pause selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + + it('handles resume button click', async () => { + const button = getButtonByName('Resume'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to resume selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + + it('handles restart connector button click', async () => { + const button = getButtonByName('Restart Connector'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to restart selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + + it('handles restart all tasks button click', async () => { + const button = getButtonByName('Restart All Tasks'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to restart all tasks in selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + + it('handles restart failed tasks button click', async () => { + const button = getButtonByName('Restart Failed Tasks'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to restart failed tasks in selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); }); }); diff --git a/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx b/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx index 9e7d6417b34..093322508bd 100644 --- a/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/BatchActionsBar.tsx @@ -15,12 +15,12 @@ import { ActionCanButton } from 'components/common/ActionComponent'; import { isPermitted } from 'lib/permissions'; import { useUserInfo } from 'lib/hooks/useUserInfo'; -interface BatchActionsbarProps { +interface BatchActionsBarProps { rows: Row[]; resetRowSelection(): void; } -const BatchActionsbar: React.FC = ({ +const BatchActionsBar: React.FC = ({ rows, resetRowSelection, }) => { @@ -166,4 +166,4 @@ const BatchActionsbar: React.FC = ({ ); }; -export default BatchActionsbar; +export default BatchActionsBar; diff --git a/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx b/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx index dcafa61b915..ed081727be1 100644 --- a/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx +++ b/kafka-ui-react-app/src/components/Topics/List/TopicTable.tsx @@ -11,7 +11,7 @@ import { PER_PAGE } from 'lib/constants'; import { TopicTitleCell } from './TopicTitleCell'; import ActionsCell from './ActionsCell'; -import BatchActionsbar from './BatchActionsBar'; +import BatchActionsBar from './BatchActionsBar'; const TopicTable: React.FC = () => { const { clusterName } = useAppParams<{ clusterName: ClusterName }>(); @@ -101,7 +101,7 @@ const TopicTable: React.FC = () => { columns={columns} enableSorting serverSideProcessing - batchActionsBar={BatchActionsbar} + batchActionsBar={BatchActionsBar} enableRowSelection={ !isReadOnly ? (row) => !row.original.internal : undefined } diff --git a/kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts b/kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts index 96d280f7d1c..835a4be0adb 100644 --- a/kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts +++ b/kafka-ui-react-app/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts @@ -91,7 +91,14 @@ describe('kafkaConnect hooks', () => { () => hooks.useUpdateConnectorState(connectorProps), { wrapper: TestQueryClientProvider } ); - await act(() => result.current.mutateAsync(action)); + await act(() => { + result.current.mutateAsync({ + clusterName, + connectName, + connectorName, + action, + }); + }); await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); diff --git a/kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts b/kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts index 1d01d491954..23e3f8aea49 100644 --- a/kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts +++ b/kafka-ui-react-app/src/lib/hooks/api/kafkaConnect.ts @@ -1,8 +1,8 @@ import { Connect, Connector, - ConnectorAction, NewConnector, + UpdateConnectorStateRequest, } from 'generated-sources'; import { kafkaConnectApiClient as api } from 'lib/api'; import sortBy from 'lodash/sortBy'; @@ -74,7 +74,7 @@ export function useConnectorTasks(props: UseConnectorProps) { export function useUpdateConnectorState(props: UseConnectorProps) { const client = useQueryClient(); return useMutation( - (action: ConnectorAction) => api.updateConnectorState({ ...props, action }), + (message: UpdateConnectorStateRequest) => api.updateConnectorState(message), { onSuccess: () => client.invalidateQueries(['clusters', props.clusterName, 'connectors']), @@ -136,7 +136,10 @@ export function useCreateConnector(clusterName: ClusterName) { export function useDeleteConnector(props: UseConnectorProps) { const client = useQueryClient(); - return useMutation(() => api.deleteConnector(props), { - onSuccess: () => client.invalidateQueries(connectorsKey(props.clusterName)), - }); + return useMutation( + (message: UseConnectorProps) => api.deleteConnector(message), + { + onSuccess: () => client.invalidateQueries(connectorKey(props)), + } + ); }