Add positive notifications after some successful actions. (#1830)

* add some positive notifications after successful actions

* some improvements

* improve alerts reducer tests
This commit is contained in:
Arsen Simonyan 2022-04-27 18:03:14 +04:00 committed by Arsen Simonyan
parent 3275b3fb94
commit bd9292e8a9
9 changed files with 120 additions and 48 deletions

View file

@ -1,40 +1,17 @@
import React from 'react'; import React from 'react';
import { useParams } from 'react-router';
import { Task } from 'generated-sources'; import { Task } from 'generated-sources';
import { ClusterName, ConnectName, ConnectorName } from 'redux/interfaces';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import { Table } from 'components/common/table/Table/Table.styled'; import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell'; import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import ListItemContainer from './ListItem/ListItemContainer'; import ListItemContainer from './ListItem/ListItemContainer';
interface RouterParams {
clusterName: ClusterName;
connectName: ConnectName;
connectorName: ConnectorName;
}
export interface TasksProps { export interface TasksProps {
fetchTasks(payload: {
clusterName: ClusterName;
connectName: ConnectName;
connectorName: ConnectorName;
}): void;
areTasksFetching: boolean; areTasksFetching: boolean;
tasks: Task[]; tasks: Task[];
} }
const Tasks: React.FC<TasksProps> = ({ const Tasks: React.FC<TasksProps> = ({ areTasksFetching, tasks }) => {
fetchTasks,
areTasksFetching,
tasks,
}) => {
const { clusterName, connectName, connectorName } = useParams<RouterParams>();
React.useEffect(() => {
fetchTasks({ clusterName, connectName, connectorName });
}, [fetchTasks, clusterName, connectName, connectorName]);
if (areTasksFetching) { if (areTasksFetching) {
return <PageLoader />; return <PageLoader />;
} }

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { create } from 'react-test-renderer'; import { create } from 'react-test-renderer';
import { mount } from 'enzyme';
import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers'; import { containerRendersView, TestRouterWrapper } from 'lib/testHelpers';
import { clusterConnectConnectorTasksPath } from 'lib/paths'; import { clusterConnectConnectorTasksPath } from 'lib/paths';
import TasksContainer from 'components/Connect/Details/Tasks/TasksContainer'; import TasksContainer from 'components/Connect/Details/Tasks/TasksContainer';
@ -35,12 +34,7 @@ describe('Tasks', () => {
pathname={pathname} pathname={pathname}
urlParams={{ clusterName, connectName, connectorName }} urlParams={{ clusterName, connectName, connectorName }}
> >
<Tasks <Tasks areTasksFetching={false} tasks={tasks} {...props} />
fetchTasks={jest.fn()}
areTasksFetching={false}
tasks={tasks}
{...props}
/>
</TestRouterWrapper> </TestRouterWrapper>
</ThemeProvider> </ThemeProvider>
); );
@ -59,16 +53,5 @@ describe('Tasks', () => {
const wrapper = create(setupWrapper({ tasks: [] })); const wrapper = create(setupWrapper({ tasks: [] }));
expect(wrapper.toJSON()).toMatchSnapshot(); expect(wrapper.toJSON()).toMatchSnapshot();
}); });
it('fetches tasks on mount', () => {
const fetchTasks = jest.fn();
mount(setupWrapper({ fetchTasks }));
expect(fetchTasks).toHaveBeenCalledTimes(1);
expect(fetchTasks).toHaveBeenCalledWith({
clusterName,
connectName,
connectorName,
});
});
}); });
}); });

View file

@ -126,3 +126,8 @@ export class EventSourceMock {
export const getTypeAndPayload = (store: typeof mockStoreCreator) => { export const getTypeAndPayload = (store: typeof mockStoreCreator) => {
return store.getActions().map(({ type, payload }) => ({ type, payload })); return store.getActions().map(({ type, payload }) => ({ type, payload }));
}; };
export const getAlertActions = (mockStore: typeof mockStoreCreator) =>
getTypeAndPayload(mockStore).filter((currentAction: AnyAction) =>
currentAction.type.startsWith('alerts')
);

View file

@ -7,6 +7,7 @@ import { MessageSchemaSourceEnum, TopicMessageSchema } from 'generated-sources';
import { FailurePayload } from 'redux/interfaces'; import { FailurePayload } from 'redux/interfaces';
import { getResponse } from 'lib/errorHandling'; import { getResponse } from 'lib/errorHandling';
import { internalTopicPayload } from 'redux/reducers/topics/__test__/fixtures'; import { internalTopicPayload } from 'redux/reducers/topics/__test__/fixtures';
import { getAlertActions, getTypeAndPayload } from 'lib/testHelpers';
const store = mockStoreCreator; const store = mockStoreCreator;
@ -57,9 +58,10 @@ describe('Thunks', () => {
internalTopicPayload internalTopicPayload
); );
await store.dispatch(thunks.recreateTopic(clusterName, topicName)); await store.dispatch(thunks.recreateTopic(clusterName, topicName));
expect(store.getActions()).toEqual([ expect(getTypeAndPayload(store)).toEqual([
actions.recreateTopicAction.request(), actions.recreateTopicAction.request(),
actions.recreateTopicAction.success(internalTopicPayload), actions.recreateTopicAction.success(internalTopicPayload),
...getAlertActions(store),
]); ]);
}); });
@ -88,9 +90,10 @@ describe('Thunks', () => {
200 200
); );
await store.dispatch(thunks.clearTopicMessages(clusterName, topicName)); await store.dispatch(thunks.clearTopicMessages(clusterName, topicName));
expect(store.getActions()).toEqual([ expect(getTypeAndPayload(store)).toEqual([
actions.clearMessagesTopicAction.request(), actions.clearMessagesTopicAction.request(),
actions.clearMessagesTopicAction.success(), actions.clearMessagesTopicAction.success(),
...getAlertActions(store),
]); ]);
}); });

View file

@ -19,10 +19,12 @@ import {
TopicsState, TopicsState,
FailurePayload, FailurePayload,
TopicFormData, TopicFormData,
AppDispatch,
} from 'redux/interfaces'; } from 'redux/interfaces';
import { BASE_PARAMS } from 'lib/constants'; import { BASE_PARAMS } from 'lib/constants';
import * as actions from 'redux/actions/actions'; import * as actions from 'redux/actions/actions';
import { getResponse } from 'lib/errorHandling'; import { getResponse } from 'lib/errorHandling';
import { showSuccessAlert } from 'redux/reducers/alerts/alertsSlice';
const apiClientConf = new Configuration(BASE_PARAMS); const apiClientConf = new Configuration(BASE_PARAMS);
export const topicsApiClient = new TopicsApi(apiClientConf); export const topicsApiClient = new TopicsApi(apiClientConf);
@ -76,6 +78,13 @@ export const clearTopicMessages =
partitions, partitions,
}); });
dispatch(actions.clearMessagesTopicAction.success()); dispatch(actions.clearMessagesTopicAction.success());
(dispatch as AppDispatch)(
showSuccessAlert({
id: `message-${topicName}-${clusterName}-${partitions}`,
message: 'Messages successfully cleared!',
})
);
} catch (e) { } catch (e) {
const response = await getResponse(e); const response = await getResponse(e);
const alert: FailurePayload = { const alert: FailurePayload = {
@ -269,6 +278,13 @@ export const recreateTopic =
topicName, topicName,
}); });
dispatch(actions.recreateTopicAction.success(topic)); dispatch(actions.recreateTopicAction.success(topic));
(dispatch as AppDispatch)(
showSuccessAlert({
id: topicName,
message: 'Topic successfully recreated!',
})
);
} catch (e) { } catch (e) {
dispatch(actions.recreateTopicAction.failure()); dispatch(actions.recreateTopicAction.failure());
} }

View file

@ -1,8 +1,12 @@
import { dismissAlert, createTopicAction } from 'redux/actions'; import { dismissAlert, createTopicAction } from 'redux/actions';
import reducer from 'redux/reducers/alerts/reducer'; import reducer from 'redux/reducers/alerts/reducer';
import { showSuccessAlert } from 'redux/reducers/alerts/alertsSlice';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import { failurePayload1, failurePayload2 } from './fixtures'; import { failurePayload1, failurePayload2 } from './fixtures';
const store = mockStoreCreator;
jest.mock('lodash', () => ({ jest.mock('lodash', () => ({
...jest.requireActual('lodash'), ...jest.requireActual('lodash'),
now: () => 1234567890, now: () => 1234567890,
@ -63,4 +67,38 @@ describe('Alerts reducer', () => {
}, },
}); });
}); });
describe('Alert thunks', () => {
afterEach(() => {
store.clearActions();
});
it('dismisses alert after showing success alert', async () => {
const passedPayload = { id: 'some-id', message: 'Alert message.' };
const { payload: creationDate } = await store.dispatch(
showSuccessAlert(passedPayload)
);
const actionsData = store
.getActions()
.map(({ type, payload }) => ({ type, payload }));
const expectedActions = [
{ type: 'alerts/showSuccessAlert/pending', payload: undefined },
{
type: 'alerts/alertAdded',
payload: {
...passedPayload,
title: '',
type: 'success',
createdAt: creationDate,
},
},
{ type: 'alerts/showSuccessAlert/fulfilled', payload: creationDate },
];
expect(actionsData).toEqual(expectedActions);
});
});
}); });

View file

@ -1,4 +1,5 @@
import { import {
createAsyncThunk,
createEntityAdapter, createEntityAdapter,
createSlice, createSlice,
nanoid, nanoid,
@ -69,4 +70,31 @@ export const { selectAll } = alertsAdapter.getSelectors<RootState>(
export const { alertDissmissed, alertAdded, serverErrorAlertAdded } = export const { alertDissmissed, alertAdded, serverErrorAlertAdded } =
alertsSlice.actions; alertsSlice.actions;
export const showSuccessAlert = createAsyncThunk<
number,
{ id: string; message: string },
{ fulfilledMeta: null }
>(
'alerts/showSuccessAlert',
async ({ id, message }, { dispatch, fulfillWithValue }) => {
const creationDate = Date.now();
dispatch(
alertAdded({
id,
message,
title: '',
type: 'success',
createdAt: creationDate,
})
);
setTimeout(() => {
dispatch(alertDissmissed(id));
}, 3000);
return fulfillWithValue(creationDate, null);
}
);
export default alertsSlice.reducer; export default alertsSlice.reducer;

View file

@ -21,7 +21,7 @@ import reducer, {
} from 'redux/reducers/connect/connectSlice'; } from 'redux/reducers/connect/connectSlice';
import fetchMock from 'fetch-mock-jest'; import fetchMock from 'fetch-mock-jest';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator'; import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import { getTypeAndPayload } from 'lib/testHelpers'; import { getTypeAndPayload, getAlertActions } from 'lib/testHelpers';
import { import {
connects, connects,
@ -636,6 +636,11 @@ describe('Connect slice', () => {
expect(getTypeAndPayload(store)).toEqual([ expect(getTypeAndPayload(store)).toEqual([
{ type: restartConnectorTask.pending.type }, { type: restartConnectorTask.pending.type },
{ type: fetchConnectorTasks.pending.type }, { type: fetchConnectorTasks.pending.type },
{
type: fetchConnectorTasks.fulfilled.type,
payload: { tasks },
},
...getAlertActions(store),
{ type: restartConnectorTask.fulfilled.type }, { type: restartConnectorTask.fulfilled.type },
]); ]);
}); });
@ -710,6 +715,7 @@ describe('Connect slice', () => {
`/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`, `/api/clusters/${clusterName}/connects/${connectName}/connectors/${connectorName}/config`,
connectorServerPayload connectorServerPayload
); );
await store.dispatch( await store.dispatch(
updateConnectorConfig({ updateConnectorConfig({
clusterName, clusterName,
@ -719,7 +725,8 @@ describe('Connect slice', () => {
}) })
); );
expect(getTypeAndPayload(store)).toEqual([ expect(getTypeAndPayload(store)).toEqual([
{ type: updateConnectorConfig.pending.type }, { type: updateConnectorConfig.pending.type, payload: undefined },
...getAlertActions(store),
{ {
type: updateConnectorConfig.fulfilled.type, type: updateConnectorConfig.fulfilled.type,
payload: { connector }, payload: { connector },

View file

@ -22,6 +22,7 @@ import {
ConnectorSearch, ConnectorSearch,
ConnectState, ConnectState,
} from 'redux/interfaces'; } from 'redux/interfaces';
import { showSuccessAlert } from 'redux/reducers/alerts/alertsSlice';
const apiClientConf = new Configuration(BASE_PARAMS); const apiClientConf = new Configuration(BASE_PARAMS);
export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf); export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
@ -254,7 +255,7 @@ export const restartConnectorTask = createAsyncThunk<
taskId: Number(taskId), taskId: Number(taskId),
}); });
dispatch( await dispatch(
fetchConnectorTasks({ fetchConnectorTasks({
clusterName, clusterName,
connectName, connectName,
@ -262,6 +263,13 @@ export const restartConnectorTask = createAsyncThunk<
}) })
); );
dispatch(
showSuccessAlert({
id: `connect-${connectName}-${clusterName}`,
message: 'Tasks successfully restarted.',
})
);
return undefined; return undefined;
} catch (err) { } catch (err) {
return rejectWithValue(await getResponse(err as Response)); return rejectWithValue(await getResponse(err as Response));
@ -305,7 +313,7 @@ export const updateConnectorConfig = createAsyncThunk<
'connect/updateConnectorConfig', 'connect/updateConnectorConfig',
async ( async (
{ clusterName, connectName, connectorName, connectorConfig }, { clusterName, connectName, connectorName, connectorConfig },
{ rejectWithValue } { rejectWithValue, dispatch }
) => { ) => {
try { try {
const connector = await kafkaConnectApiClient.setConnectorConfig({ const connector = await kafkaConnectApiClient.setConnectorConfig({
@ -315,6 +323,13 @@ export const updateConnectorConfig = createAsyncThunk<
requestBody: connectorConfig, requestBody: connectorConfig,
}); });
dispatch(
showSuccessAlert({
id: `connector-${connectorName}-${clusterName}`,
message: 'Connector config updated.',
})
);
return { connector }; return { connector };
} catch (err) { } catch (err) {
return rejectWithValue(await getResponse(err as Response)); return rejectWithValue(await getResponse(err as Response));