Confirmation modal for topic & schema delete actions (#384)

This commit is contained in:
Oleg Shur 2021-04-21 13:59:12 +03:00 committed by GitHub
parent 9d62670eef
commit ca4b3f12f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 216 additions and 52 deletions

View file

@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router';
import { SchemaSubject } from 'generated-sources'; import { SchemaSubject } from 'generated-sources';
import { ClusterName, SchemaName } from 'redux/interfaces'; import { ClusterName, SchemaName } from 'redux/interfaces';
import { clusterSchemasPath } from 'lib/paths'; import { clusterSchemasPath } from 'lib/paths';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import { useHistory } from 'react-router'; import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import Breadcrumb from '../../common/Breadcrumb/Breadcrumb'; import PageLoader from 'components/common/PageLoader/PageLoader';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import SchemaVersion from './SchemaVersion'; import SchemaVersion from './SchemaVersion';
import LatestVersionItem from './LatestVersionItem'; import LatestVersionItem from './LatestVersionItem';
import PageLoader from '../../common/PageLoader/PageLoader';
export interface DetailsProps { export interface DetailsProps {
subject: SchemaName; subject: SchemaName;
@ -32,15 +33,20 @@ const Details: React.FC<DetailsProps> = ({
isFetched, isFetched,
}) => { }) => {
const { isReadOnly } = React.useContext(ClusterContext); const { isReadOnly } = React.useContext(ClusterContext);
const [
isDeleteSchemaConfirmationVisible,
setDeleteSchemaConfirmationVisible,
] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
fetchSchemaVersions(clusterName, subject); fetchSchemaVersions(clusterName, subject);
}, [fetchSchemaVersions, clusterName]); }, [fetchSchemaVersions, clusterName]);
const history = useHistory(); const history = useHistory();
const onDelete = async () => { const onDelete = React.useCallback(() => {
await deleteSchema(clusterName, subject); deleteSchema(clusterName, subject);
history.push(clusterSchemasPath(clusterName)); history.push(clusterSchemasPath(clusterName));
}; }, [deleteSchema, clusterName, subject]);
return ( return (
<div className="section"> <div className="section">
@ -84,10 +90,17 @@ const Details: React.FC<DetailsProps> = ({
className="button is-danger is-small level-item" className="button is-danger is-small level-item"
type="button" type="button"
title="in development" title="in development"
onClick={onDelete} onClick={() => setDeleteSchemaConfirmationVisible(true)}
> >
Remove Remove
</button> </button>
<ConfirmationModal
isOpen={isDeleteSchemaConfirmationVisible}
onCancel={() => setDeleteSchemaConfirmationVisible(false)}
onConfirm={onDelete}
>
Are you sure want to remove <b>{subject}</b> schema?
</ConfirmationModal>
</div> </div>
)} )}
</div> </div>

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { shallow, mount } from 'enzyme'; import { shallow, mount, ReactWrapper } from 'enzyme';
import configureStore from 'redux/store/configureStore'; import configureStore from 'redux/store/configureStore';
import { StaticRouter } from 'react-router'; import { StaticRouter } from 'react-router';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
@ -11,6 +11,11 @@ import { schema, versions } from './fixtures';
const clusterName = 'testCluster'; const clusterName = 'testCluster';
const fetchSchemaVersionsMock = jest.fn(); const fetchSchemaVersionsMock = jest.fn();
jest.mock(
'components/common/ConfirmationModal/ConfirmationModal',
() => 'mock-ConfirmationModal'
);
describe('Details', () => { describe('Details', () => {
describe('Container', () => { describe('Container', () => {
const store = configureStore(); const store = configureStore();
@ -92,28 +97,52 @@ describe('Details', () => {
}); });
describe('when schema has versions', () => { describe('when schema has versions', () => {
const wrapper = shallow(setupWrapper({ versions }));
it('renders table heading with SchemaVersion', () => { it('renders table heading with SchemaVersion', () => {
const wrapper = shallow(setupWrapper({ versions }));
expect(wrapper.exists('LatestVersionItem')).toBeTruthy(); expect(wrapper.exists('LatestVersionItem')).toBeTruthy();
expect(wrapper.exists('button')).toBeTruthy(); expect(wrapper.exists('button')).toBeTruthy();
expect(wrapper.exists('thead')).toBeTruthy(); expect(wrapper.exists('thead')).toBeTruthy();
expect(wrapper.find('SchemaVersion').length).toEqual(2); expect(wrapper.find('SchemaVersion').length).toEqual(2);
}); });
it('calls deleteSchema on button click', () => { it('matches snapshot', () => {
expect(shallow(setupWrapper({ versions }))).toMatchSnapshot();
});
describe('confirmation', () => {
let wrapper: ReactWrapper;
let confirmationModal: ReactWrapper;
const mockDelete = jest.fn(); const mockDelete = jest.fn();
const component = mount(
const findConfirmationModal = () =>
wrapper.find('mock-ConfirmationModal');
beforeEach(() => {
wrapper = mount(
<StaticRouter> <StaticRouter>
{setupWrapper({ versions, deleteSchema: mockDelete })} {setupWrapper({ versions, deleteSchema: mockDelete })}
</StaticRouter> </StaticRouter>
); );
component.find('button').at(1).simulate('click'); confirmationModal = findConfirmationModal();
});
it('calls deleteSchema after confirmation', () => {
expect(confirmationModal.prop('isOpen')).toBeFalsy();
wrapper.find('button').at(1).simulate('click');
expect(findConfirmationModal().prop('isOpen')).toBeTruthy();
// @ts-expect-error lack of typing of enzyme#invoke
confirmationModal.invoke('onConfirm')();
expect(mockDelete).toHaveBeenCalledTimes(1); expect(mockDelete).toHaveBeenCalledTimes(1);
}); });
it('matches snapshot', () => { it('calls deleteSchema after confirmation', () => {
expect(shallow(setupWrapper({ versions }))).toMatchSnapshot(); expect(confirmationModal.prop('isOpen')).toBeFalsy();
wrapper.find('button').at(1).simulate('click');
expect(findConfirmationModal().prop('isOpen')).toBeTruthy();
// @ts-expect-error lack of typing of enzyme#invoke
wrapper.find('mock-ConfirmationModal').invoke('onCancel')();
expect(findConfirmationModal().prop('isOpen')).toBeFalsy();
});
}); });
}); });

View file

@ -67,6 +67,17 @@ exports[`Details View Initial state matches snapshot 1`] = `
> >
Remove Remove
</button> </button>
<mock-ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure want to remove
<b>
test
</b>
schema?
</mock-ConfirmationModal>
</div> </div>
</div> </div>
<LatestVersionItem <LatestVersionItem
@ -202,6 +213,17 @@ exports[`Details View when page with schema versions loaded when schema has vers
> >
Remove Remove
</button> </button>
<mock-ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure want to remove
<b>
test
</b>
schema?
</mock-ConfirmationModal>
</div> </div>
</div> </div>
<LatestVersionItem <LatestVersionItem
@ -340,6 +362,17 @@ exports[`Details View when page with schema versions loaded when versions are em
> >
Remove Remove
</button> </button>
<mock-ConfirmationModal
isOpen={false}
onCancel={[Function]}
onConfirm={[Function]}
>
Are you sure want to remove
<b>
test
</b>
schema?
</mock-ConfirmationModal>
</div> </div>
</div> </div>
<LatestVersionItem <LatestVersionItem

View file

@ -8,6 +8,7 @@ import {
} from 'redux/interfaces'; } from 'redux/interfaces';
import DropdownItem from 'components/common/Dropdown/DropdownItem'; import DropdownItem from 'components/common/Dropdown/DropdownItem';
import Dropdown from 'components/common/Dropdown/Dropdown'; import Dropdown from 'components/common/Dropdown/Dropdown';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
export interface ListItemProps { export interface ListItemProps {
topic: TopicWithDetailedInfo; topic: TopicWithDetailedInfo;
@ -20,6 +21,11 @@ const ListItem: React.FC<ListItemProps> = ({
deleteTopic, deleteTopic,
clusterName, clusterName,
}) => { }) => {
const [
isDeleteTopicConfirmationVisible,
setDeleteTopicConfirmationVisible,
] = React.useState(false);
const outOfSyncReplicas = React.useMemo(() => { const outOfSyncReplicas = React.useMemo(() => {
if (partitions === undefined || partitions.length === 0) { if (partitions === undefined || partitions.length === 0) {
return 0; return 0;
@ -54,7 +60,8 @@ const ListItem: React.FC<ListItemProps> = ({
{internal ? 'Internal' : 'External'} {internal ? 'Internal' : 'External'}
</div> </div>
</td> </td>
<td className="has-text-right"> <td>
<div className="has-text-right">
<Dropdown <Dropdown
label={ label={
<span className="icon"> <span className="icon">
@ -63,10 +70,20 @@ const ListItem: React.FC<ListItemProps> = ({
} }
right right
> >
<DropdownItem onClick={deleteTopicHandler}> <DropdownItem
onClick={() => setDeleteTopicConfirmationVisible(true)}
>
<span className="has-text-danger">Remove Topic</span> <span className="has-text-danger">Remove Topic</span>
</DropdownItem> </DropdownItem>
</Dropdown> </Dropdown>
</div>
<ConfirmationModal
isOpen={isDeleteTopicConfirmationVisible}
onCancel={() => setDeleteTopicConfirmationVisible(false)}
onConfirm={deleteTopicHandler}
>
Are you sure want to remove <b>{name}</b> topic?
</ConfirmationModal>
</td> </td>
</tr> </tr>
); );

View file

@ -10,6 +10,11 @@ import ListItem, { ListItemProps } from '../ListItem';
const mockDelete = jest.fn(); const mockDelete = jest.fn();
const clusterName = 'local'; const clusterName = 'local';
jest.mock(
'components/common/ConfirmationModal/ConfirmationModal',
() => 'mock-ConfirmationModal'
);
describe('ListItem', () => { describe('ListItem', () => {
const setupComponent = (props: Partial<ListItemProps> = {}) => ( const setupComponent = (props: Partial<ListItemProps> = {}) => (
<ListItem <ListItem
@ -22,11 +27,25 @@ describe('ListItem', () => {
it('triggers the deleteTopic when clicked on the delete button', () => { it('triggers the deleteTopic when clicked on the delete button', () => {
const wrapper = shallow(setupComponent()); const wrapper = shallow(setupComponent());
wrapper.find('DropdownItem').simulate('click'); expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeFalsy();
wrapper.find('DropdownItem').last().simulate('click');
const modal = wrapper.find('mock-ConfirmationModal');
expect(modal.prop('isOpen')).toBeTruthy();
modal.simulate('confirm');
expect(mockDelete).toBeCalledTimes(1); expect(mockDelete).toBeCalledTimes(1);
expect(mockDelete).toBeCalledWith(clusterName, internalTopicPayload.name); expect(mockDelete).toBeCalledWith(clusterName, internalTopicPayload.name);
}); });
it('closes ConfirmationModal when clicked on the cancel button', () => {
const wrapper = shallow(setupComponent());
expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeFalsy();
wrapper.find('DropdownItem').last().simulate('click');
expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeTruthy();
wrapper.find('mock-ConfirmationModal').simulate('cancel');
expect(mockDelete).toBeCalledTimes(0);
expect(wrapper.find('mock-ConfirmationModal').prop('isOpen')).toBeFalsy();
});
it('renders correct tags for internal topic', () => { it('renders correct tags for internal topic', () => {
const wrapper = mount( const wrapper = mount(
<StaticRouter> <StaticRouter>
@ -50,4 +69,20 @@ describe('ListItem', () => {
expect(wrapper.find('.tag.is-primary').text()).toEqual('External'); expect(wrapper.find('.tag.is-primary').text()).toEqual('External');
}); });
it('renders correct out of sync replicas number', () => {
const wrapper = mount(
<StaticRouter>
<table>
<tbody>
{setupComponent({
topic: { ...externalTopicPayload, partitions: undefined },
})}
</tbody>
</table>
</StaticRouter>
);
expect(wrapper.find('td').at(2).text()).toEqual('0');
});
}); });

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { ClusterName, TopicName } from 'redux/interfaces'; import { ClusterName, TopicName } from 'redux/interfaces';
import { Topic, TopicDetails } from 'generated-sources'; import { Topic, TopicDetails } from 'generated-sources';
import { NavLink, Switch, Route, Link } from 'react-router-dom'; import { NavLink, Switch, Route, Link, useHistory } from 'react-router-dom';
import { import {
clusterTopicSettingsPath, clusterTopicSettingsPath,
clusterTopicPath, clusterTopicPath,
clusterTopicMessagesPath, clusterTopicMessagesPath,
clusterTopicsTopicEditPath, clusterTopicsPath,
clusterTopicEditPath,
} from 'lib/paths'; } from 'lib/paths';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import OverviewContainer from './Overview/OverviewContainer'; import OverviewContainer from './Overview/OverviewContainer';
import MessagesContainer from './Messages/MessagesContainer'; import MessagesContainer from './Messages/MessagesContainer';
import SettingsContainer from './Settings/SettingsContainer'; import SettingsContainer from './Settings/SettingsContainer';
@ -16,10 +18,20 @@ import SettingsContainer from './Settings/SettingsContainer';
interface Props extends Topic, TopicDetails { interface Props extends Topic, TopicDetails {
clusterName: ClusterName; clusterName: ClusterName;
topicName: TopicName; topicName: TopicName;
deleteTopic: (clusterName: ClusterName, topicName: TopicName) => void;
} }
const Details: React.FC<Props> = ({ clusterName, topicName }) => { const Details: React.FC<Props> = ({ clusterName, topicName, deleteTopic }) => {
const history = useHistory();
const { isReadOnly } = React.useContext(ClusterContext); const { isReadOnly } = React.useContext(ClusterContext);
const [
isDeleteTopicConfirmationVisible,
setDeleteTopicConfirmationVisible,
] = React.useState(false);
const deleteTopicHandler = React.useCallback(() => {
deleteTopic(clusterName, topicName);
history.push(clusterTopicsPath(clusterName));
}, [clusterName, topicName]);
return ( return (
<div className="box"> <div className="box">
@ -51,15 +63,35 @@ const Details: React.FC<Props> = ({ clusterName, topicName }) => {
</NavLink> </NavLink>
</div> </div>
<div className="navbar-end"> <div className="navbar-end">
<div className="buttons">
{!isReadOnly && ( {!isReadOnly && (
<>
<button
className="button is-danger"
type="button"
onClick={() => setDeleteTopicConfirmationVisible(true)}
>
Delete Topic
</button>
<Link <Link
to={clusterTopicsTopicEditPath(clusterName, topicName)} to={clusterTopicEditPath(clusterName, topicName)}
className="button" className="button"
> >
Edit settings Edit settings
</Link> </Link>
<ConfirmationModal
isOpen={isDeleteTopicConfirmationVisible}
onCancel={() => setDeleteTopicConfirmationVisible(false)}
onConfirm={deleteTopicHandler}
>
Are you sure want to remove <b>{topicName}</b> topic?
</ConfirmationModal>
</>
)} )}
</div> </div>
</div>
</nav> </nav>
<br /> <br />
<Switch> <Switch>

View file

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { ClusterName, RootState, TopicName } from 'redux/interfaces'; import { ClusterName, RootState, TopicName } from 'redux/interfaces';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import { deleteTopic } from 'redux/actions';
import Details from './Details'; import Details from './Details';
interface RouteProps { interface RouteProps {
@ -22,4 +23,10 @@ const mapStateToProps = (
topicName, topicName,
}); });
export default withRouter(connect(mapStateToProps)(Details)); const mapDispatchToProps = {
deleteTopic,
};
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Details)
);

View file

@ -30,8 +30,7 @@ const mapStateToProps = (
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
fetchTopicConfig: (clusterName: ClusterName, topicName: TopicName) => fetchTopicConfig,
fetchTopicConfig(clusterName, topicName),
}; };
export default withRouter( export default withRouter(

View file

@ -61,12 +61,11 @@ describe('Paths', () => {
'/ui/clusters/local/topics/topic123/messages' '/ui/clusters/local/topics/topic123/messages'
); );
}); });
it('clusterTopicsTopicEditPath', () => { it('clusterTopicEditPath', () => {
expect(paths.clusterTopicsTopicEditPath('local', 'topic123')).toEqual( expect(paths.clusterTopicEditPath('local', 'topic123')).toEqual(
'/ui/clusters/local/topics/topic123/edit' '/ui/clusters/local/topics/topic123/edit'
); );
}); });
it('clusterConnectorsPath', () => { it('clusterConnectorsPath', () => {
expect(paths.clusterConnectorsPath('local')).toEqual( expect(paths.clusterConnectorsPath('local')).toEqual(
'/ui/clusters/local/connectors' '/ui/clusters/local/connectors'

View file

@ -41,7 +41,7 @@ export const clusterTopicMessagesPath = (
clusterName: ClusterName, clusterName: ClusterName,
topicName: TopicName topicName: TopicName
) => `${clusterTopicsPath(clusterName)}/${topicName}/messages`; ) => `${clusterTopicsPath(clusterName)}/${topicName}/messages`;
export const clusterTopicsTopicEditPath = ( export const clusterTopicEditPath = (
clusterName: ClusterName, clusterName: ClusterName,
topicName: TopicName topicName: TopicName
) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`; ) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;