diff --git a/frontend/src/components/Connect/Details/Actions/Actions.tsx b/frontend/src/components/Connect/Details/Actions/Actions.tsx index cc3755e19..b248d74f1 100644 --- a/frontend/src/components/Connect/Details/Actions/Actions.tsx +++ b/frontend/src/components/Connect/Details/Actions/Actions.tsx @@ -33,7 +33,7 @@ const Actions: React.FC = () => { const { data: connector } = useConnector(routerProps); const confirm = useConfirm(); - const deleteConnectorMutation = useDeleteConnector(routerProps); + const deleteConnectorMutation = useDeleteConnector(routerProps.clusterName); const deleteConnectorHandler = () => confirm( <> @@ -42,7 +42,9 @@ const Actions: React.FC = () => { , async () => { try { - await deleteConnectorMutation.mutateAsync(); + await deleteConnectorMutation.mutateAsync({ + props: routerProps, + }); navigate(clusterConnectorsPath(routerProps.clusterName)); } catch { // do not redirect @@ -50,17 +52,18 @@ const Actions: React.FC = () => { } ); - const stateMutation = useUpdateConnectorState(routerProps); - const restartConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART); - const restartAllTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_ALL_TASKS); - const restartFailedTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_FAILED_TASKS); - const pauseConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.PAUSE); - const resumeConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESUME); + const stateMutation = useUpdateConnectorState(routerProps.clusterName); + const performConnectorAction = (action: ConnectorAction) => { + stateMutation.mutateAsync({ + props: { + clusterName: routerProps.clusterName, + connectName: routerProps.connectName, + connectorName: routerProps.connectorName, + }, + action, + }); + }; + return ( { > {connector?.status.state === ConnectorState.RUNNING && ( performConnectorAction(ConnectorAction.PAUSE)} disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -86,7 +89,7 @@ const Actions: React.FC = () => { )} {connector?.status.state === ConnectorState.PAUSED && ( performConnectorAction(ConnectorAction.RESUME)} disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -98,7 +101,7 @@ const Actions: React.FC = () => { )} performConnectorAction(ConnectorAction.RESTART)} disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -109,7 +112,9 @@ const Actions: React.FC = () => { Restart Connector + performConnectorAction(ConnectorAction.RESTART_ALL_TASKS) + } disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -120,7 +125,9 @@ const Actions: React.FC = () => { Restart All Tasks + performConnectorAction(ConnectorAction.RESTART_FAILED_TASKS) + } disabled={isMutating} permission={{ resource: ResourceType.CONNECT, diff --git a/frontend/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx b/frontend/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx index 27743949b..ad608e841 100644 --- a/frontend/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx +++ b/frontend/src/components/Connect/Details/Actions/__tests__/Actions.spec.tsx @@ -58,11 +58,16 @@ describe('Actions', () => { }); describe('view', () => { + const connectorProps = { + clusterName: 'myCluster', + connectName: 'myConnect', + connectorName: 'myConnector', + }; const route = clusterConnectConnectorPath(); const path = clusterConnectConnectorPath( - 'myCluster', - 'myConnect', - 'myConnector' + connectorProps.clusterName, + connectorProps.connectName, + connectorProps.connectorName ); const renderComponent = () => @@ -147,7 +152,10 @@ describe('Actions', () => { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart Connector' }) ); - expect(restartConnector).toHaveBeenCalledWith(ConnectorAction.RESTART); + expect(restartConnector).toHaveBeenCalledWith({ + props: connectorProps, + action: ConnectorAction.RESTART, + }); }); it('calls restartAllTasks', async () => { @@ -160,9 +168,10 @@ describe('Actions', () => { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart All Tasks' }) ); - expect(restartAllTasks).toHaveBeenCalledWith( - ConnectorAction.RESTART_ALL_TASKS - ); + expect(restartAllTasks).toHaveBeenCalledWith({ + props: connectorProps, + action: ConnectorAction.RESTART_ALL_TASKS, + }); }); it('calls restartFailedTasks', async () => { @@ -175,9 +184,10 @@ describe('Actions', () => { await userEvent.click( screen.getByRole('menuitem', { name: 'Restart Failed Tasks' }) ); - expect(restartFailedTasks).toHaveBeenCalledWith( - ConnectorAction.RESTART_FAILED_TASKS - ); + expect(restartFailedTasks).toHaveBeenCalledWith({ + props: connectorProps, + action: ConnectorAction.RESTART_FAILED_TASKS, + }); }); it('calls pauseConnector when pause button clicked', async () => { @@ -188,7 +198,10 @@ describe('Actions', () => { renderComponent(); await afterClickRestartButton(); await userEvent.click(screen.getByRole('menuitem', { name: 'Pause' })); - expect(pauseConnector).toHaveBeenCalledWith(ConnectorAction.PAUSE); + expect(pauseConnector).toHaveBeenCalledWith({ + props: connectorProps, + action: ConnectorAction.PAUSE, + }); }); it('calls resumeConnector when resume button clicked', async () => { @@ -202,7 +215,10 @@ describe('Actions', () => { renderComponent(); await afterClickRestartButton(); await userEvent.click(screen.getByRole('menuitem', { name: 'Resume' })); - expect(resumeConnector).toHaveBeenCalledWith(ConnectorAction.RESUME); + expect(resumeConnector).toHaveBeenCalledWith({ + props: connectorProps, + action: ConnectorAction.RESUME, + }); }); }); }); diff --git a/frontend/src/components/Connect/List/ActionsCell.tsx b/frontend/src/components/Connect/List/ActionsCell.tsx index 246ad332c..e610509bb 100644 --- a/frontend/src/components/Connect/List/ActionsCell.tsx +++ b/frontend/src/components/Connect/List/ActionsCell.tsx @@ -26,43 +26,41 @@ const ActionsCell: React.FC> = ({ const mutationsNumber = useIsMutating(); const isMutating = mutationsNumber > 0; const confirm = useConfirm(); - const deleteMutation = useDeleteConnector({ - clusterName, - connectName: connect, - connectorName: name, - }); - const stateMutation = useUpdateConnectorState({ - clusterName, - connectName: connect, - connectorName: name, - }); + const deleteMutation = useDeleteConnector(clusterName); + const stateMutation = useUpdateConnectorState(clusterName); const handleDelete = () => { confirm( <> Are you sure want to remove {name} connector? , async () => { - await deleteMutation.mutateAsync(); + await deleteMutation.mutateAsync({ + props: { + clusterName, + connectName: connect, + connectorName: name, + }, + }); } ); }; // const stateMutation = useUpdateConnectorState(routerProps); - const resumeConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESUME); - const restartConnectorHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART); - - const restartAllTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_ALL_TASKS); - - const restartFailedTasksHandler = () => - stateMutation.mutateAsync(ConnectorAction.RESTART_FAILED_TASKS); + const performConnectorAction = (action: ConnectorAction) => { + stateMutation.mutateAsync({ + props: { + clusterName, + connectName: connect, + connectorName: name, + }, + action, + }); + }; return ( - {status.state === ConnectorState.PAUSED && ( + {status.state === ConnectorState.PAUSED ? ( performConnectorAction(ConnectorAction.RESUME)} disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -72,9 +70,21 @@ const ActionsCell: React.FC> = ({ > Resume + ) : ( + performConnectorAction(ConnectorAction.PAUSE)} + disabled={isMutating} + permission={{ + resource: ResourceType.CONNECT, + action: Action.EDIT, + value: name, + }} + > + Pause + )} performConnectorAction(ConnectorAction.RESTART)} disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -85,7 +95,9 @@ const ActionsCell: React.FC> = ({ Restart Connector + performConnectorAction(ConnectorAction.RESTART_ALL_TASKS) + } disabled={isMutating} permission={{ resource: ResourceType.CONNECT, @@ -96,7 +108,9 @@ const ActionsCell: React.FC> = ({ Restart All Tasks + performConnectorAction(ConnectorAction.RESTART_FAILED_TASKS) + } disabled={isMutating} permission={{ resource: ResourceType.CONNECT, diff --git a/frontend/src/components/Connect/List/BatchActionsBar.tsx b/frontend/src/components/Connect/List/BatchActionsBar.tsx new file mode 100644 index 000000000..9f4139bbd --- /dev/null +++ b/frontend/src/components/Connect/List/BatchActionsBar.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { + Action, + ResourceType, + ConnectorAction, + FullConnectorInfo, +} from 'generated-sources'; +import { Row } from '@tanstack/react-table'; +import { ActionButton } from 'components/common/ActionComponent'; +import { useConfirm } from 'lib/hooks/useConfirm'; +import { + useDeleteConnector, + useUpdateConnectorState, +} from 'lib/hooks/api/kafkaConnect'; +import useAppParams from 'lib/hooks/useAppParams'; +import { ClusterNameRoute } from 'lib/paths'; +import { showServerError } from 'lib/errorHandling'; + +interface BatchActionsBarProps { + rows: Row[]; + resetRowSelection(): void; +} + +const BatchActionsBar: React.FC = ({ + rows, + resetRowSelection, +}) => { + const { clusterName } = useAppParams(); + const selectedConnectors = rows.map(({ original }) => original); + const stateMutation = useUpdateConnectorState(clusterName); + const deleteMutation = useDeleteConnector(clusterName); + const confirm = useConfirm(); + + const performBatchConnectorsAction = async ( + confirmMessage: string, + action: ConnectorAction + ) => { + confirm(confirmMessage, async () => { + try { + await Promise.all( + selectedConnectors.map(({ connect, name }) => + stateMutation.mutateAsync({ + props: { + clusterName, + connectName: connect, + connectorName: name, + }, + action, + }) + ) + ); + } catch (error) { + showServerError(error as Response); + } finally { + resetRowSelection(); + } + }); + }; + + const deleteConnectorsFailedTasksHandler = async () => { + confirm( + 'Are you sure you want to delete selected connectors?', + async () => { + try { + await Promise.all( + selectedConnectors.map(({ connect, name }) => + deleteMutation.mutateAsync({ + props: { + clusterName, + connectName: connect, + connectorName: name, + }, + }) + ) + ); + } catch (error) { + showServerError(error as Response); + } finally { + resetRowSelection(); + } + } + ); + }; + + return ( + <> + + performBatchConnectorsAction( + 'Are you sure you want to pause selected connectors?', + ConnectorAction.PAUSE + ) + } + > + Pause Connectors + + + performBatchConnectorsAction( + 'Are you sure you want to resume selected connectors?', + ConnectorAction.RESUME + ) + } + > + Resume Connectors + + + performBatchConnectorsAction( + 'Are you sure you want to restart selected connectors?', + ConnectorAction.RESTART + ) + } + > + Restart Connectors + + + performBatchConnectorsAction( + 'Are you sure you want to restart all tasks in selected connectors?', + ConnectorAction.RESTART_ALL_TASKS + ) + } + > + Restart All Tasks + + + performBatchConnectorsAction( + 'Are you sure you want to restart all failed tasks in selected connectors?', + ConnectorAction.RESTART_FAILED_TASKS + ) + } + > + Restart Failed Tasks + + + Remove Connectors + + + ); +}; + +export default BatchActionsBar; diff --git a/frontend/src/components/Connect/List/ConnectorTitleCell.tsx b/frontend/src/components/Connect/List/ConnectorTitleCell.tsx new file mode 100644 index 000000000..04d239161 --- /dev/null +++ b/frontend/src/components/Connect/List/ConnectorTitleCell.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CellContext } from '@tanstack/react-table'; +import { clusterConnectConnectorPath, ClusterNameRoute } from 'lib/paths'; +import useAppParams from 'lib/hooks/useAppParams'; +import { FullConnectorInfo } from 'generated-sources'; +import { NavLink } from 'react-router-dom'; + +export const ConnectorTitleCell: React.FC< + CellContext +> = ({ row: { original } }) => { + const { clusterName } = useAppParams(); + const { name, connect } = original; + return ( + + {name} + + ); +}; diff --git a/frontend/src/components/Connect/List/List.tsx b/frontend/src/components/Connect/List/List.tsx index 87b4d56db..b30902d44 100644 --- a/frontend/src/components/Connect/List/List.tsx +++ b/frontend/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 RunningTasksCell from './RunningTasksCell'; +import BatchActionsBar from './BatchActionsBar'; +import { ConnectorTitleCell } from './ConnectorTitleCell'; 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: ConnectorTitleCell }, { header: 'Connect', accessorKey: 'connect' }, { header: 'Type', accessorKey: 'type' }, { header: 'Plugin', accessorKey: 'connectorClass' }, @@ -39,11 +40,10 @@ const List: React.FC = () => { data={connectors || []} columns={columns} enableSorting - onRowClick={({ original: { connect, name } }) => - navigate(clusterConnectConnectorPath(clusterName, connect, name)) - } emptyMessage="No connectors found" setRowId={(originalRow) => `${originalRow.name}-${originalRow.connect}`} + enableRowSelection + batchActionsBar={BatchActionsBar} /> ); }; diff --git a/frontend/src/components/Connect/List/__tests__/List.spec.tsx b/frontend/src/components/Connect/List/__tests__/List.spec.tsx index 82b4aab21..5f99a9ebe 100644 --- a/frontend/src/components/Connect/List/__tests__/List.spec.tsx +++ b/frontend/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'), @@ -31,6 +32,8 @@ jest.mock('lib/hooks/api/kafkaConnect', () => ({ const clusterName = 'local'; +const getButtonByName = (name: string) => screen.getByRole('button', { name }); + const renderComponent = (contextValue: ContextProps = initialValue) => render( @@ -59,23 +62,93 @@ describe('Connectors List', () => { expect(screen.getAllByRole('row').length).toEqual(3); }); - it('opens broker when row clicked', async () => { + it('connector link has correct href', async () => { renderComponent(); - await userEvent.click( - screen.getByRole('row', { - name: 'hdfs-source-connector first SOURCE FileStreamSource a b c RUNNING 2 of 2', - }) - ); - await waitFor(() => - expect(mockedUsedNavigate).toBeCalledWith( - clusterConnectConnectorPath( - clusterName, - 'first', - 'hdfs-source-connector' - ) + const connectorTitleLink = screen.getByTitle( + connectors[0].name + ) as HTMLAnchorElement; + expect(connectorTitleLink).toHaveAttribute( + 'href', + clusterConnectConnectorPath( + clusterName, + connectors[0].connect, + connectors[0].name ) ); }); + + describe('Batch actions bar', () => { + beforeEach(() => { + renderComponent(); + expect(screen.getAllByRole('checkbox').length).toEqual(3); + expect(screen.getAllByRole('checkbox')[1]).toBeEnabled(); + expect(screen.getAllByRole('checkbox')[2]).toBeEnabled(); + (useUpdateConnectorState as jest.Mock).mockImplementation(() => ({ + mutateAsync: mockUpdate, + })); + (useDeleteConnector as jest.Mock).mockImplementation(() => ({ + mutateAsync: mockDelete, + })); + }); + describe('when only one connector is selected', () => { + beforeEach(async () => { + await userEvent.click(screen.getAllByRole('checkbox')[1]); + }); + it('renders batch actions bar', () => { + expect(getButtonByName('Pause Connectors')).toBeEnabled(); + expect(getButtonByName('Resume Connectors')).toBeEnabled(); + expect(getButtonByName('Restart Connectors')).toBeEnabled(); + expect(getButtonByName('Restart All Tasks')).toBeEnabled(); + expect(getButtonByName('Restart Failed Tasks')).toBeEnabled(); + expect(getButtonByName('Remove Connectors')).toBeEnabled(); + }); + it('handels pause button click', async () => { + const button = getButtonByName('Pause Connectors'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to pause selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(confirmBtn).toBeInTheDocument(); + expect(mockUpdate).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + }); + }); + describe('when more then one connectors are selected', () => { + beforeEach(async () => { + await userEvent.click(screen.getAllByRole('checkbox')[1]); + await userEvent.click(screen.getAllByRole('checkbox')[2]); + }); + it('renders batch actions bar', () => { + expect(getButtonByName('Pause Connectors')).toBeEnabled(); + expect(getButtonByName('Resume Connectors')).toBeEnabled(); + expect(getButtonByName('Restart Connectors')).toBeEnabled(); + expect(getButtonByName('Restart All Tasks')).toBeEnabled(); + expect(getButtonByName('Restart Failed Tasks')).toBeEnabled(); + expect(getButtonByName('Remove Connectors')).toBeEnabled(); + }); + it('handels delete button click', async () => { + const button = getButtonByName('Remove Connectors'); + await userEvent.click(button); + expect( + screen.getByText( + 'Are you sure you want to delete selected connectors?' + ) + ).toBeInTheDocument(); + const confirmBtn = getButtonByName('Confirm'); + expect(confirmBtn).toBeInTheDocument(); + expect(mockDelete).not.toHaveBeenCalled(); + await userEvent.click(confirmBtn); + expect(mockDelete).toHaveBeenCalledTimes(2); + expect(screen.getAllByRole('checkbox')[1]).not.toBeChecked(); + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + }); + }); + }); }); describe('when table is empty', () => { @@ -113,7 +186,13 @@ describe('Connectors List', () => { name: 'Confirm', })[0]; await userEvent.click(submitButton); - expect(mockDelete).toHaveBeenCalledWith(); + expect(mockDelete).toHaveBeenCalledWith({ + props: { + clusterName, + connectName: connectors[0].connect, + connectorName: connectors[0].name, + }, + }); }); it('closes the modal when cancel button is clicked', async () => { diff --git a/frontend/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts b/frontend/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts index 96d280f7d..035b63aa1 100644 --- a/frontend/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts +++ b/frontend/src/lib/hooks/api/__tests__/kafkaConnect.spec.ts @@ -88,10 +88,19 @@ describe('kafkaConnect hooks', () => { const uri = `${connectorPath}/action/${action}`; const mock = fetchMock.postOnce(uri, connectors[0]); const { result } = renderHook( - () => hooks.useUpdateConnectorState(connectorProps), + () => hooks.useUpdateConnectorState(connectorProps.clusterName), { wrapper: TestQueryClientProvider } ); - await act(() => result.current.mutateAsync(action)); + await act(() => + result.current.mutateAsync({ + props: { + clusterName: connectorProps.clusterName, + connectName: connectorProps.connectName, + connectorName: connectorProps.connectorName, + }, + action, + }) + ); await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); }); @@ -148,11 +157,17 @@ describe('kafkaConnect hooks', () => { it('returns the correct data', async () => { const mock = fetchMock.deleteOnce(connectorPath, {}); const { result } = renderHook( - () => hooks.useDeleteConnector(connectorProps), + () => hooks.useDeleteConnector(connectorProps.clusterName), { wrapper: TestQueryClientProvider } ); await act(async () => { - await result.current.mutateAsync(); + await result.current.mutateAsync({ + props: { + clusterName: connectorProps.clusterName, + connectName: connectorProps.connectName, + connectorName: connectorProps.connectorName, + }, + }); }); await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); expect(mock.calls()).toHaveLength(1); diff --git a/frontend/src/lib/hooks/api/kafkaConnect.ts b/frontend/src/lib/hooks/api/kafkaConnect.ts index 225e72165..6870d2f7e 100644 --- a/frontend/src/lib/hooks/api/kafkaConnect.ts +++ b/frontend/src/lib/hooks/api/kafkaConnect.ts @@ -92,13 +92,19 @@ export function useConnectorTasks(props: UseConnectorProps) { } ); } -export function useUpdateConnectorState(props: UseConnectorProps) { +export function useUpdateConnectorState(clusterName: ClusterName) { const client = useQueryClient(); return useMutation( - (action: ConnectorAction) => api.updateConnectorState({ ...props, action }), + ({ + props, + action, + }: { + props: UseConnectorProps; + action: ConnectorAction; + }) => api.updateConnectorState({ ...props, action }), { - onSuccess: () => - client.invalidateQueries(['clusters', props.clusterName, 'connectors']), + onSuccess: async () => + client.invalidateQueries(['clusters', clusterName, 'connectors']), } ); } @@ -154,10 +160,13 @@ export function useCreateConnector(clusterName: ClusterName) { }; } -export function useDeleteConnector(props: UseConnectorProps) { +export function useDeleteConnector(clusterName: ClusterName) { const client = useQueryClient(); - return useMutation(() => api.deleteConnector(props), { - onSuccess: () => client.invalidateQueries(connectorsKey(props.clusterName)), - }); + return useMutation( + ({ props }: { props: UseConnectorProps }) => api.deleteConnector(props), + { + onSuccess: () => client.invalidateQueries(connectorsKey(clusterName)), + } + ); } diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index 6c8284afd..be561d7ff 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -1269,6 +1269,7 @@ export const darkTheme: ThemeType = { }, icons: { ...baseTheme.icons, + chevronDownIcon: Colors.neutral[90], editIcon: { normal: Colors.neutral[50], hover: Colors.neutral[30],