parent
00da655e54
commit
98fcc90c6b
29 changed files with 1163 additions and 504 deletions
|
@ -18,8 +18,9 @@ const mapStateToProps = (state: RootState) => ({
|
||||||
isFetching: getIsSchemaListFetching(state),
|
isFetching: getIsSchemaListFetching(state),
|
||||||
schemas: getSchemaList(state),
|
schemas: getSchemaList(state),
|
||||||
globalSchemaCompatibilityLevel: getGlobalSchemaCompatibilityLevel(state),
|
globalSchemaCompatibilityLevel: getGlobalSchemaCompatibilityLevel(state),
|
||||||
isGlobalSchemaCompatibilityLevelFetched:
|
isGlobalSchemaCompatibilityLevelFetched: getGlobalSchemaCompatibilityLevelFetched(
|
||||||
getGlobalSchemaCompatibilityLevelFetched(state),
|
state
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
|
|
|
@ -12,6 +12,9 @@ import { FetchTopicsListParams } from 'redux/actions';
|
||||||
import ClusterContext from 'components/contexts/ClusterContext';
|
import ClusterContext from 'components/contexts/ClusterContext';
|
||||||
import PageLoader from 'components/common/PageLoader/PageLoader';
|
import PageLoader from 'components/common/PageLoader/PageLoader';
|
||||||
import Pagination from 'components/common/Pagination/Pagination';
|
import Pagination from 'components/common/Pagination/Pagination';
|
||||||
|
import { TopicColumnsToSort } from 'generated-sources';
|
||||||
|
import SortableColumnHeader from 'components/common/table/SortableCulumnHeader/SortableColumnHeader';
|
||||||
|
import Search from 'components/common/Search/Search';
|
||||||
|
|
||||||
import ListItem from './ListItem';
|
import ListItem from './ListItem';
|
||||||
|
|
||||||
|
@ -27,6 +30,10 @@ interface Props {
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
partitions?: number[]
|
partitions?: number[]
|
||||||
): void;
|
): void;
|
||||||
|
search: string;
|
||||||
|
orderBy: TopicColumnsToSort | null;
|
||||||
|
setTopicsSearch(search: string): void;
|
||||||
|
setTopicsOrderBy(orderBy: TopicColumnsToSort | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const List: React.FC<Props> = ({
|
const List: React.FC<Props> = ({
|
||||||
|
@ -37,14 +44,24 @@ const List: React.FC<Props> = ({
|
||||||
fetchTopicsList,
|
fetchTopicsList,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
clearTopicMessages,
|
clearTopicMessages,
|
||||||
|
search,
|
||||||
|
orderBy,
|
||||||
|
setTopicsSearch,
|
||||||
|
setTopicsOrderBy,
|
||||||
}) => {
|
}) => {
|
||||||
const { isReadOnly } = React.useContext(ClusterContext);
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
const { clusterName } = useParams<{ clusterName: ClusterName }>();
|
const { clusterName } = useParams<{ clusterName: ClusterName }>();
|
||||||
const { page, perPage } = usePagination();
|
const { page, perPage } = usePagination();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchTopicsList({ clusterName, page, perPage });
|
fetchTopicsList({
|
||||||
}, [fetchTopicsList, clusterName, page, perPage]);
|
clusterName,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
orderBy: orderBy || undefined,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
}, [fetchTopicsList, clusterName, page, perPage, orderBy, search]);
|
||||||
|
|
||||||
const [showInternal, setShowInternal] = React.useState<boolean>(true);
|
const [showInternal, setShowInternal] = React.useState<boolean>(true);
|
||||||
|
|
||||||
|
@ -52,14 +69,16 @@ const List: React.FC<Props> = ({
|
||||||
setShowInternal(!showInternal);
|
setShowInternal(!showInternal);
|
||||||
}, [showInternal]);
|
}, [showInternal]);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => setTopicsSearch(value);
|
||||||
|
|
||||||
const items = showInternal ? topics : externalTopics;
|
const items = showInternal ? topics : externalTopics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<Breadcrumb>{showInternal ? `All Topics` : `External Topics`}</Breadcrumb>
|
<Breadcrumb>{showInternal ? `All Topics` : `External Topics`}</Breadcrumb>
|
||||||
<div className="box">
|
<div className="box">
|
||||||
<div className="level">
|
<div className="columns">
|
||||||
<div className="level-item level-left">
|
<div className="column is-one-quarter is-align-items-center is-flex">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<input
|
<input
|
||||||
id="switchRoundedDefault"
|
id="switchRoundedDefault"
|
||||||
|
@ -72,7 +91,14 @@ const List: React.FC<Props> = ({
|
||||||
<label htmlFor="switchRoundedDefault">Show Internal Topics</label>
|
<label htmlFor="switchRoundedDefault">Show Internal Topics</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="level-item level-right">
|
<div className="column">
|
||||||
|
<Search
|
||||||
|
handleSearch={handleSearch}
|
||||||
|
placeholder="Search by Topic Name"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="column is-2 is-justify-content-flex-end is-flex">
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<Link
|
<Link
|
||||||
className="button is-primary"
|
className="button is-primary"
|
||||||
|
@ -91,9 +117,24 @@ const List: React.FC<Props> = ({
|
||||||
<table className="table is-fullwidth">
|
<table className="table is-fullwidth">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Topic Name</th>
|
<SortableColumnHeader
|
||||||
<th>Total Partitions</th>
|
value={TopicColumnsToSort.NAME}
|
||||||
<th>Out of sync replicas</th>
|
title="Topic Name"
|
||||||
|
orderBy={orderBy}
|
||||||
|
setOrderBy={setTopicsOrderBy}
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
value={TopicColumnsToSort.TOTAL_PARTITIONS}
|
||||||
|
title="Total Partitions"
|
||||||
|
orderBy={orderBy}
|
||||||
|
setOrderBy={setTopicsOrderBy}
|
||||||
|
/>
|
||||||
|
<SortableColumnHeader
|
||||||
|
value={TopicColumnsToSort.OUT_OF_SYNC_REPLICAS}
|
||||||
|
title="Out of sync replicas"
|
||||||
|
orderBy={orderBy}
|
||||||
|
setOrderBy={setTopicsOrderBy}
|
||||||
|
/>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th> </th>
|
<th> </th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -4,12 +4,16 @@ import {
|
||||||
fetchTopicsList,
|
fetchTopicsList,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
clearTopicMessages,
|
clearTopicMessages,
|
||||||
|
setTopicsSearchAction,
|
||||||
|
setTopicsOrderByAction,
|
||||||
} from 'redux/actions';
|
} from 'redux/actions';
|
||||||
import {
|
import {
|
||||||
getTopicList,
|
getTopicList,
|
||||||
getExternalTopicList,
|
getExternalTopicList,
|
||||||
getAreTopicsFetching,
|
getAreTopicsFetching,
|
||||||
getTopicListTotalPages,
|
getTopicListTotalPages,
|
||||||
|
getTopicsSearch,
|
||||||
|
getTopicsOrderBy,
|
||||||
} from 'redux/reducers/topics/selectors';
|
} from 'redux/reducers/topics/selectors';
|
||||||
|
|
||||||
import List from './List';
|
import List from './List';
|
||||||
|
@ -19,12 +23,16 @@ const mapStateToProps = (state: RootState) => ({
|
||||||
topics: getTopicList(state),
|
topics: getTopicList(state),
|
||||||
externalTopics: getExternalTopicList(state),
|
externalTopics: getExternalTopicList(state),
|
||||||
totalPages: getTopicListTotalPages(state),
|
totalPages: getTopicListTotalPages(state),
|
||||||
|
search: getTopicsSearch(state),
|
||||||
|
orderBy: getTopicsOrderBy(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
fetchTopicsList,
|
fetchTopicsList,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
clearTopicMessages,
|
clearTopicMessages,
|
||||||
|
setTopicsSearch: setTopicsSearchAction,
|
||||||
|
setTopicsOrderBy: setTopicsOrderByAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(List);
|
export default connect(mapStateToProps, mapDispatchToProps)(List);
|
||||||
|
|
|
@ -23,8 +23,10 @@ const ListItem: React.FC<ListItemProps> = ({
|
||||||
clusterName,
|
clusterName,
|
||||||
clearTopicMessages,
|
clearTopicMessages,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDeleteTopicConfirmationVisible, setDeleteTopicConfirmationVisible] =
|
const [
|
||||||
React.useState(false);
|
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) {
|
||||||
|
|
|
@ -24,6 +24,10 @@ describe('List', () => {
|
||||||
fetchTopicsList={jest.fn()}
|
fetchTopicsList={jest.fn()}
|
||||||
deleteTopic={jest.fn()}
|
deleteTopic={jest.fn()}
|
||||||
clearTopicMessages={jest.fn()}
|
clearTopicMessages={jest.fn()}
|
||||||
|
search=""
|
||||||
|
orderBy={null}
|
||||||
|
setTopicsSearch={jest.fn()}
|
||||||
|
setTopicsOrderBy={jest.fn()}
|
||||||
/>
|
/>
|
||||||
</ClusterContext.Provider>
|
</ClusterContext.Provider>
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
|
@ -33,7 +37,8 @@ describe('List', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when it does not have readonly flag', () => {
|
describe('when it does not have readonly flag', () => {
|
||||||
it('renders the Add a Topic button', () => {
|
const mockFetch = jest.fn();
|
||||||
|
jest.useFakeTimers();
|
||||||
const component = mount(
|
const component = mount(
|
||||||
<StaticRouter>
|
<StaticRouter>
|
||||||
<ClusterContext.Provider
|
<ClusterContext.Provider
|
||||||
|
@ -48,14 +53,32 @@ describe('List', () => {
|
||||||
topics={[]}
|
topics={[]}
|
||||||
externalTopics={[]}
|
externalTopics={[]}
|
||||||
totalPages={1}
|
totalPages={1}
|
||||||
fetchTopicsList={jest.fn()}
|
fetchTopicsList={mockFetch}
|
||||||
deleteTopic={jest.fn()}
|
deleteTopic={jest.fn()}
|
||||||
clearTopicMessages={jest.fn()}
|
clearTopicMessages={jest.fn()}
|
||||||
|
search=""
|
||||||
|
orderBy={null}
|
||||||
|
setTopicsSearch={jest.fn()}
|
||||||
|
setTopicsOrderBy={jest.fn()}
|
||||||
/>
|
/>
|
||||||
</ClusterContext.Provider>
|
</ClusterContext.Provider>
|
||||||
</StaticRouter>
|
</StaticRouter>
|
||||||
);
|
);
|
||||||
|
it('renders the Add a Topic button', () => {
|
||||||
expect(component.exists('Link')).toBeTruthy();
|
expect(component.exists('Link')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
it('matches the snapshot', () => {
|
||||||
|
expect(component).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls fetchTopicsList on input', () => {
|
||||||
|
const input = component.find('input').at(1);
|
||||||
|
input.simulate('change', { target: { value: 't' } });
|
||||||
|
expect(setTimeout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 300);
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
}, 301);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,255 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`List when it does not have readonly flag matches the snapshot 1`] = `
|
||||||
|
<StaticRouter>
|
||||||
|
<Router
|
||||||
|
history={
|
||||||
|
Object {
|
||||||
|
"action": "POP",
|
||||||
|
"block": [Function],
|
||||||
|
"createHref": [Function],
|
||||||
|
"go": [Function],
|
||||||
|
"goBack": [Function],
|
||||||
|
"goForward": [Function],
|
||||||
|
"listen": [Function],
|
||||||
|
"location": Object {
|
||||||
|
"hash": "",
|
||||||
|
"pathname": "/",
|
||||||
|
"search": "",
|
||||||
|
"state": undefined,
|
||||||
|
},
|
||||||
|
"push": [Function],
|
||||||
|
"replace": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
staticContext={Object {}}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
areTopicsFetching={false}
|
||||||
|
clearTopicMessages={[MockFunction]}
|
||||||
|
deleteTopic={[MockFunction]}
|
||||||
|
externalTopics={Array []}
|
||||||
|
fetchTopicsList={[MockFunction]}
|
||||||
|
orderBy={null}
|
||||||
|
search=""
|
||||||
|
setTopicsOrderBy={[MockFunction]}
|
||||||
|
setTopicsSearch={[MockFunction]}
|
||||||
|
topics={Array []}
|
||||||
|
totalPages={1}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="section"
|
||||||
|
>
|
||||||
|
<Breadcrumb>
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumbs"
|
||||||
|
className="breadcrumb"
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
className="is-active"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
All Topics
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div
|
||||||
|
className="box"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="columns"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="column is-one-quarter is-align-items-center is-flex"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="field"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked={true}
|
||||||
|
className="switch is-rounded"
|
||||||
|
id="switchRoundedDefault"
|
||||||
|
name="switchRoundedDefault"
|
||||||
|
onChange={[Function]}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="switchRoundedDefault"
|
||||||
|
>
|
||||||
|
Show Internal Topics
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="column"
|
||||||
|
>
|
||||||
|
<Search
|
||||||
|
handleSearch={[Function]}
|
||||||
|
placeholder="Search by Topic Name"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="control has-icons-left"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Search by Topic Name"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="icon is-small is-left"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-search"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</Search>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="column is-2 is-justify-content-flex-end is-flex"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="button is-primary"
|
||||||
|
to="/ui/clusters/undefined/topics/create_new"
|
||||||
|
>
|
||||||
|
<LinkAnchor
|
||||||
|
className="button is-primary"
|
||||||
|
href="/ui/clusters/undefined/topics/create_new"
|
||||||
|
navigate={[Function]}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="button is-primary"
|
||||||
|
href="/ui/clusters/undefined/topics/create_new"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Add a Topic
|
||||||
|
</a>
|
||||||
|
</LinkAnchor>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="box"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
className="table is-fullwidth"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<ListHeaderCell
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={[MockFunction]}
|
||||||
|
title="Topic Name"
|
||||||
|
value="NAME"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
className="is-clickable"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Topic Name
|
||||||
|
<span
|
||||||
|
className="icon is-small"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</ListHeaderCell>
|
||||||
|
<ListHeaderCell
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={[MockFunction]}
|
||||||
|
title="Total Partitions"
|
||||||
|
value="TOTAL_PARTITIONS"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
className="is-clickable"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Total Partitions
|
||||||
|
<span
|
||||||
|
className="icon is-small"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</ListHeaderCell>
|
||||||
|
<ListHeaderCell
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={[MockFunction]}
|
||||||
|
title="Out of sync replicas"
|
||||||
|
value="OUT_OF_SYNC_REPLICAS"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
className="is-clickable"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Out of sync replicas
|
||||||
|
<span
|
||||||
|
className="icon is-small"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</ListHeaderCell>
|
||||||
|
<th>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={10}
|
||||||
|
>
|
||||||
|
No topics found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination
|
||||||
|
totalPages={1}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
aria-label="pagination"
|
||||||
|
className="pagination is-small is-right"
|
||||||
|
role="navigation"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="pagination-previous"
|
||||||
|
disabled={true}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pagination-next"
|
||||||
|
disabled={true}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Next page
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List>
|
||||||
|
</Router>
|
||||||
|
</StaticRouter>
|
||||||
|
`;
|
|
@ -33,8 +33,10 @@ const Details: React.FC<Props> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { isReadOnly } = React.useContext(ClusterContext);
|
const { isReadOnly } = React.useContext(ClusterContext);
|
||||||
const [isDeleteTopicConfirmationVisible, setDeleteTopicConfirmationVisible] =
|
const [
|
||||||
React.useState(false);
|
isDeleteTopicConfirmationVisible,
|
||||||
|
setDeleteTopicConfirmationVisible,
|
||||||
|
] = React.useState(false);
|
||||||
const deleteTopicHandler = React.useCallback(() => {
|
const deleteTopicHandler = React.useCallback(() => {
|
||||||
deleteTopic(clusterName, topicName);
|
deleteTopic(clusterName, topicName);
|
||||||
history.push(clusterTopicsPath(clusterName));
|
history.push(clusterTopicsPath(clusterName));
|
||||||
|
|
|
@ -50,8 +50,9 @@ const Messages: React.FC<Props> = ({
|
||||||
fetchTopicMessages,
|
fetchTopicMessages,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
const [searchQuery, setSearchQuery] = React.useState<string>('');
|
||||||
const [searchTimestamp, setSearchTimestamp] =
|
const [searchTimestamp, setSearchTimestamp] = React.useState<Date | null>(
|
||||||
React.useState<Date | null>(null);
|
null
|
||||||
|
);
|
||||||
const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
|
const [filterProps, setFilterProps] = React.useState<FilterProps[]>([]);
|
||||||
const [selectedSeekType, setSelectedSeekType] = React.useState<SeekType>(
|
const [selectedSeekType, setSelectedSeekType] = React.useState<SeekType>(
|
||||||
SeekType.OFFSET
|
SeekType.OFFSET
|
||||||
|
|
|
@ -39,8 +39,9 @@ const CustomParamSelect: React.FC<CustomParamSelectProps> = ({
|
||||||
return valid || 'Custom Parameter must be unique';
|
return valid || 'Custom Parameter must be unique';
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange =
|
const onChange = (inputName: string) => (
|
||||||
(inputName: string) => (event: React.ChangeEvent<HTMLSelectElement>) => {
|
event: React.ChangeEvent<HTMLSelectElement>
|
||||||
|
) => {
|
||||||
trigger(inputName);
|
trigger(inputName);
|
||||||
onNameChange(index, event.target.value);
|
onNameChange(index, event.target.value);
|
||||||
};
|
};
|
||||||
|
|
|
@ -35,8 +35,10 @@ const CustomParams: React.FC<Props> = ({ isSubmitting, config }) => {
|
||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [formCustomParams, setFormCustomParams] =
|
const [
|
||||||
React.useState<TopicFormCustomParams>({
|
formCustomParams,
|
||||||
|
setFormCustomParams,
|
||||||
|
] = React.useState<TopicFormCustomParams>({
|
||||||
byIndex,
|
byIndex,
|
||||||
allIndexes: Object.keys(byIndex),
|
allIndexes: Object.keys(byIndex),
|
||||||
});
|
});
|
||||||
|
|
35
kafka-ui-react-app/src/components/common/Search/Search.tsx
Normal file
35
kafka-ui-react-app/src/components/common/Search/Search.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
interface SearchProps {
|
||||||
|
handleSearch: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search: React.FC<SearchProps> = ({
|
||||||
|
handleSearch,
|
||||||
|
placeholder = 'Search',
|
||||||
|
value,
|
||||||
|
}) => {
|
||||||
|
const onChange = useDebouncedCallback(
|
||||||
|
(e) => handleSearch(e.target.value),
|
||||||
|
300
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<p className="control has-icons-left">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={onChange}
|
||||||
|
defaultValue={value}
|
||||||
|
/>
|
||||||
|
<span className="icon is-small is-left">
|
||||||
|
<i className="fas fa-search" />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import Search from 'components/common/Search/Search';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
jest.mock('use-debounce', () => ({
|
||||||
|
useDebouncedCallback: (fn: (e: Event) => void) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Search', () => {
|
||||||
|
const handleSearch = jest.fn();
|
||||||
|
let component = shallow(
|
||||||
|
<Search
|
||||||
|
handleSearch={handleSearch}
|
||||||
|
value=""
|
||||||
|
placeholder="Search bt the Topic name"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
it('calls handleSearch on input', () => {
|
||||||
|
component.find('input').simulate('change', { target: { value: 'test' } });
|
||||||
|
expect(handleSearch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when placeholder is provided', () => {
|
||||||
|
it('matches the snapshot', () => {
|
||||||
|
expect(component).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when placeholder is not provided', () => {
|
||||||
|
component = shallow(<Search handleSearch={handleSearch} value="" />);
|
||||||
|
it('matches the snapshot', () => {
|
||||||
|
expect(component).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,43 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Search when placeholder is not provided matches the snapshot 1`] = `
|
||||||
|
<p
|
||||||
|
className="control has-icons-left"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Search"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="icon is-small is-left"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-search"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Search when placeholder is provided matches the snapshot 1`] = `
|
||||||
|
<p
|
||||||
|
className="control has-icons-left"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={[Function]}
|
||||||
|
placeholder="Search"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="icon is-small is-left"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-search"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
`;
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import React from 'react';
|
||||||
|
import cx from 'classnames';
|
||||||
|
|
||||||
|
export interface ListHeaderProps {
|
||||||
|
value: any;
|
||||||
|
title: string;
|
||||||
|
orderBy: any;
|
||||||
|
setOrderBy: React.Dispatch<React.SetStateAction<any>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListHeaderCell: React.FC<ListHeaderProps> = ({
|
||||||
|
value,
|
||||||
|
title,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
}) => (
|
||||||
|
<th
|
||||||
|
className={cx('is-clickable', orderBy === value && 'has-text-link-dark')}
|
||||||
|
onClick={() => setOrderBy(value)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fas fa-sort" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ListHeaderCell;
|
|
@ -0,0 +1,37 @@
|
||||||
|
import SortableColumnHeader from 'components/common/table/SortableCulumnHeader/SortableColumnHeader';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { TopicColumnsToSort } from 'generated-sources';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
describe('ListHeader', () => {
|
||||||
|
const setOrderBy = jest.fn();
|
||||||
|
const component = mount(
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortableColumnHeader
|
||||||
|
value={TopicColumnsToSort.NAME}
|
||||||
|
title="Name"
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={setOrderBy}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
it('matches the snapshot', () => {
|
||||||
|
expect(component).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on column click', () => {
|
||||||
|
it('calls setOrderBy', () => {
|
||||||
|
component.find('th').simulate('click');
|
||||||
|
expect(setOrderBy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setOrderBy).toHaveBeenCalledWith(TopicColumnsToSort.NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the snapshot', () => {
|
||||||
|
expect(component).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,59 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`ListHeader matches the snapshot 1`] = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<ListHeaderCell
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={[MockFunction]}
|
||||||
|
title="Name"
|
||||||
|
value="NAME"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
className="is-clickable"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
<span
|
||||||
|
className="icon is-small"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</ListHeaderCell>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ListHeader on column click matches the snapshot 1`] = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<ListHeaderCell
|
||||||
|
orderBy={null}
|
||||||
|
setOrderBy={[MockFunction]}
|
||||||
|
title="Name"
|
||||||
|
value="NAME"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
className="is-clickable"
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
<span
|
||||||
|
className="icon is-small"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="fas fa-sort"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</ListHeaderCell>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
`;
|
|
@ -3,6 +3,7 @@ import {
|
||||||
schemaVersionsPayload,
|
schemaVersionsPayload,
|
||||||
} from 'redux/reducers/schemas/__test__/fixtures';
|
} from 'redux/reducers/schemas/__test__/fixtures';
|
||||||
import * as actions from 'redux/actions';
|
import * as actions from 'redux/actions';
|
||||||
|
import { TopicColumnsToSort } from 'generated-sources';
|
||||||
|
|
||||||
describe('Actions', () => {
|
describe('Actions', () => {
|
||||||
describe('fetchClusterStatsAction', () => {
|
describe('fetchClusterStatsAction', () => {
|
||||||
|
@ -131,4 +132,22 @@ describe('Actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setTopicsSearchAction', () => {
|
||||||
|
it('creartes SET_TOPICS_SEARCH', () => {
|
||||||
|
expect(actions.setTopicsSearchAction('test')).toEqual({
|
||||||
|
type: 'SET_TOPICS_SEARCH',
|
||||||
|
payload: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setTopicsOrderByAction', () => {
|
||||||
|
it('creartes SET_TOPICS_ORDER_BY', () => {
|
||||||
|
expect(actions.setTopicsOrderByAction(TopicColumnsToSort.NAME)).toEqual({
|
||||||
|
type: 'SET_TOPICS_ORDER_BY',
|
||||||
|
payload: TopicColumnsToSort.NAME,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
ConsumerGroupDetails,
|
ConsumerGroupDetails,
|
||||||
SchemaSubject,
|
SchemaSubject,
|
||||||
CompatibilityLevelCompatibilityEnum,
|
CompatibilityLevelCompatibilityEnum,
|
||||||
|
TopicColumnsToSort,
|
||||||
Connector,
|
Connector,
|
||||||
FullConnectorInfo,
|
FullConnectorInfo,
|
||||||
Connect,
|
Connect,
|
||||||
|
@ -233,3 +234,11 @@ export const updateConnectorConfigAction = createAsyncAction(
|
||||||
'PATCH_CONNECTOR_CONFIG__SUCCESS',
|
'PATCH_CONNECTOR_CONFIG__SUCCESS',
|
||||||
'PATCH_CONNECTOR_CONFIG__FAILURE'
|
'PATCH_CONNECTOR_CONFIG__FAILURE'
|
||||||
)<undefined, { connector: Connector }, { alert?: FailurePayload }>();
|
)<undefined, { connector: Connector }, { alert?: FailurePayload }>();
|
||||||
|
|
||||||
|
export const setTopicsSearchAction = createAction(
|
||||||
|
'SET_TOPICS_SEARCH'
|
||||||
|
)<string>();
|
||||||
|
|
||||||
|
export const setTopicsOrderByAction = createAction(
|
||||||
|
'SET_TOPICS_ORDER_BY'
|
||||||
|
)<TopicColumnsToSort>();
|
||||||
|
|
|
@ -6,9 +6,9 @@ import * as actions from 'redux/actions/actions';
|
||||||
const apiClientConf = new Configuration(BASE_PARAMS);
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
export const brokersApiClient = new BrokersApi(apiClientConf);
|
export const brokersApiClient = new BrokersApi(apiClientConf);
|
||||||
|
|
||||||
export const fetchBrokers =
|
export const fetchBrokers = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.fetchBrokersAction.request());
|
dispatch(actions.fetchBrokersAction.request());
|
||||||
try {
|
try {
|
||||||
const payload = await brokersApiClient.getBrokers({ clusterName });
|
const payload = await brokersApiClient.getBrokers({ clusterName });
|
||||||
|
@ -16,11 +16,12 @@ export const fetchBrokers =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchBrokersAction.failure());
|
dispatch(actions.fetchBrokersAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchBrokerMetrics =
|
export const fetchBrokerMetrics = (
|
||||||
(clusterName: ClusterName, brokerId: BrokerId): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch) => {
|
brokerId: BrokerId
|
||||||
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.fetchBrokerMetricsAction.request());
|
dispatch(actions.fetchBrokerMetricsAction.request());
|
||||||
try {
|
try {
|
||||||
const payload = await brokersApiClient.getBrokersMetrics({
|
const payload = await brokersApiClient.getBrokersMetrics({
|
||||||
|
@ -31,4 +32,4 @@ export const fetchBrokerMetrics =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchBrokerMetricsAction.failure());
|
dispatch(actions.fetchBrokerMetricsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,9 +16,9 @@ export const fetchClustersList = (): PromiseThunkResult => async (dispatch) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClusterStats =
|
export const fetchClusterStats = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.fetchClusterStatsAction.request());
|
dispatch(actions.fetchClusterStatsAction.request());
|
||||||
try {
|
try {
|
||||||
const payload = await clustersApiClient.getClusterStats({ clusterName });
|
const payload = await clustersApiClient.getClusterStats({ clusterName });
|
||||||
|
@ -26,11 +26,11 @@ export const fetchClusterStats =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchClusterStatsAction.failure());
|
dispatch(actions.fetchClusterStatsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClusterMetrics =
|
export const fetchClusterMetrics = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.fetchClusterMetricsAction.request());
|
dispatch(actions.fetchClusterMetricsAction.request());
|
||||||
try {
|
try {
|
||||||
const payload = await clustersApiClient.getClusterMetrics({
|
const payload = await clustersApiClient.getClusterMetrics({
|
||||||
|
@ -40,4 +40,4 @@ export const fetchClusterMetrics =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchClusterMetricsAction.failure());
|
dispatch(actions.fetchClusterMetricsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,7 +20,6 @@ import { getResponse } from 'lib/errorHandling';
|
||||||
|
|
||||||
const apiClientConf = new Configuration(BASE_PARAMS);
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
|
export const kafkaConnectApiClient = new KafkaConnectApi(apiClientConf);
|
||||||
|
|
||||||
export const fetchConnects =
|
export const fetchConnects =
|
||||||
(clusterName: ClusterName): PromiseThunkResult<void> =>
|
(clusterName: ClusterName): PromiseThunkResult<void> =>
|
||||||
async (dispatch) => {
|
async (dispatch) => {
|
||||||
|
|
|
@ -10,9 +10,9 @@ import * as actions from 'redux/actions/actions';
|
||||||
const apiClientConf = new Configuration(BASE_PARAMS);
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf);
|
export const consumerGroupsApiClient = new ConsumerGroupsApi(apiClientConf);
|
||||||
|
|
||||||
export const fetchConsumerGroupsList =
|
export const fetchConsumerGroupsList = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.fetchConsumerGroupsAction.request());
|
dispatch(actions.fetchConsumerGroupsAction.request());
|
||||||
try {
|
try {
|
||||||
const consumerGroups = await consumerGroupsApiClient.getConsumerGroups({
|
const consumerGroups = await consumerGroupsApiClient.getConsumerGroups({
|
||||||
|
@ -22,21 +22,20 @@ export const fetchConsumerGroupsList =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchConsumerGroupsAction.failure());
|
dispatch(actions.fetchConsumerGroupsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchConsumerGroupDetails =
|
export const fetchConsumerGroupDetails = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
consumerGroupID: ConsumerGroupID
|
consumerGroupID: ConsumerGroupID
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.fetchConsumerGroupDetailsAction.request());
|
dispatch(actions.fetchConsumerGroupDetailsAction.request());
|
||||||
try {
|
try {
|
||||||
const consumerGroupDetails =
|
const consumerGroupDetails = await consumerGroupsApiClient.getConsumerGroup(
|
||||||
await consumerGroupsApiClient.getConsumerGroup({
|
{
|
||||||
clusterName,
|
clusterName,
|
||||||
id: consumerGroupID,
|
id: consumerGroupID,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
dispatch(
|
dispatch(
|
||||||
actions.fetchConsumerGroupDetailsAction.success({
|
actions.fetchConsumerGroupDetailsAction.success({
|
||||||
consumerGroupID,
|
consumerGroupID,
|
||||||
|
@ -46,4 +45,4 @@ export const fetchConsumerGroupDetails =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchConsumerGroupDetailsAction.failure());
|
dispatch(actions.fetchConsumerGroupDetailsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,9 +20,9 @@ import { isEqual } from 'lodash';
|
||||||
const apiClientConf = new Configuration(BASE_PARAMS);
|
const apiClientConf = new Configuration(BASE_PARAMS);
|
||||||
export const schemasApiClient = new SchemasApi(apiClientConf);
|
export const schemasApiClient = new SchemasApi(apiClientConf);
|
||||||
|
|
||||||
export const fetchSchemasByClusterName =
|
export const fetchSchemasByClusterName = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult<void> =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult<void> => async (dispatch) => {
|
||||||
dispatch(actions.fetchSchemasByClusterNameAction.request());
|
dispatch(actions.fetchSchemasByClusterNameAction.request());
|
||||||
try {
|
try {
|
||||||
const schemas = await schemasApiClient.getSchemas({ clusterName });
|
const schemas = await schemasApiClient.getSchemas({ clusterName });
|
||||||
|
@ -30,11 +30,12 @@ export const fetchSchemasByClusterName =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchSchemasByClusterNameAction.failure());
|
dispatch(actions.fetchSchemasByClusterNameAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchSchemaVersions =
|
export const fetchSchemaVersions = (
|
||||||
(clusterName: ClusterName, subject: SchemaName): PromiseThunkResult<void> =>
|
clusterName: ClusterName,
|
||||||
async (dispatch) => {
|
subject: SchemaName
|
||||||
|
): PromiseThunkResult<void> => async (dispatch) => {
|
||||||
if (!subject) return;
|
if (!subject) return;
|
||||||
dispatch(actions.fetchSchemaVersionsAction.request());
|
dispatch(actions.fetchSchemaVersionsAction.request());
|
||||||
try {
|
try {
|
||||||
|
@ -46,11 +47,11 @@ export const fetchSchemaVersions =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchSchemaVersionsAction.failure());
|
dispatch(actions.fetchSchemaVersionsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchGlobalSchemaCompatibilityLevel =
|
export const fetchGlobalSchemaCompatibilityLevel = (
|
||||||
(clusterName: ClusterName): PromiseThunkResult<void> =>
|
clusterName: ClusterName
|
||||||
async (dispatch) => {
|
): PromiseThunkResult<void> => async (dispatch) => {
|
||||||
dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.request());
|
dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.request());
|
||||||
try {
|
try {
|
||||||
const result = await schemasApiClient.getGlobalSchemaCompatibilityLevel({
|
const result = await schemasApiClient.getGlobalSchemaCompatibilityLevel({
|
||||||
|
@ -64,14 +65,12 @@ export const fetchGlobalSchemaCompatibilityLevel =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.failure());
|
dispatch(actions.fetchGlobalSchemaCompatibilityLevelAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateGlobalSchemaCompatibilityLevel =
|
export const updateGlobalSchemaCompatibilityLevel = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
compatibilityLevel: CompatibilityLevelCompatibilityEnum
|
compatibilityLevel: CompatibilityLevelCompatibilityEnum
|
||||||
): PromiseThunkResult<void> =>
|
): PromiseThunkResult<void> => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.request());
|
dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.request());
|
||||||
try {
|
try {
|
||||||
await schemasApiClient.updateGlobalSchemaCompatibilityLevel({
|
await schemasApiClient.updateGlobalSchemaCompatibilityLevel({
|
||||||
|
@ -86,14 +85,12 @@ export const updateGlobalSchemaCompatibilityLevel =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.failure());
|
dispatch(actions.updateGlobalSchemaCompatibilityLevelAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSchema =
|
export const createSchema = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
newSchemaSubject: NewSchemaSubject
|
newSchemaSubject: NewSchemaSubject
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.createSchemaAction.request());
|
dispatch(actions.createSchemaAction.request());
|
||||||
try {
|
try {
|
||||||
const schema: SchemaSubject = await schemasApiClient.createNewSchema({
|
const schema: SchemaSubject = await schemasApiClient.createNewSchema({
|
||||||
|
@ -111,18 +108,16 @@ export const createSchema =
|
||||||
dispatch(actions.createSchemaAction.failure({ alert }));
|
dispatch(actions.createSchemaAction.failure({ alert }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSchema =
|
export const updateSchema = (
|
||||||
(
|
|
||||||
latestSchema: SchemaSubject,
|
latestSchema: SchemaSubject,
|
||||||
newSchema: string,
|
newSchema: string,
|
||||||
newSchemaType: SchemaType,
|
newSchemaType: SchemaType,
|
||||||
newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
|
newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
|
||||||
clusterName: string,
|
clusterName: string,
|
||||||
subject: string
|
subject: string
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.updateSchemaAction.request());
|
dispatch(actions.updateSchemaAction.request());
|
||||||
try {
|
try {
|
||||||
let schema: SchemaSubject = latestSchema;
|
let schema: SchemaSubject = latestSchema;
|
||||||
|
@ -160,10 +155,11 @@ export const updateSchema =
|
||||||
dispatch(actions.updateSchemaAction.failure({ alert }));
|
dispatch(actions.updateSchemaAction.failure({ alert }));
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const deleteSchema =
|
export const deleteSchema = (
|
||||||
(clusterName: ClusterName, subject: string): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch) => {
|
subject: string
|
||||||
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.deleteSchemaAction.request());
|
dispatch(actions.deleteSchemaAction.request());
|
||||||
try {
|
try {
|
||||||
await schemasApiClient.deleteSchema({
|
await schemasApiClient.deleteSchema({
|
||||||
|
@ -180,4 +176,4 @@ export const deleteSchema =
|
||||||
};
|
};
|
||||||
dispatch(actions.deleteSchemaAction.failure({ alert }));
|
dispatch(actions.deleteSchemaAction.failure({ alert }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
TopicCreation,
|
TopicCreation,
|
||||||
TopicUpdate,
|
TopicUpdate,
|
||||||
TopicConfig,
|
TopicConfig,
|
||||||
|
TopicColumnsToSort,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
import {
|
import {
|
||||||
PromiseThunkResult,
|
PromiseThunkResult,
|
||||||
|
@ -30,11 +31,14 @@ export interface FetchTopicsListParams {
|
||||||
clusterName: ClusterName;
|
clusterName: ClusterName;
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
|
showInternal?: boolean;
|
||||||
|
search?: string;
|
||||||
|
orderBy?: TopicColumnsToSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchTopicsList =
|
export const fetchTopicsList = (
|
||||||
(params: FetchTopicsListParams): PromiseThunkResult =>
|
params: FetchTopicsListParams
|
||||||
async (dispatch, getState) => {
|
): PromiseThunkResult => async (dispatch, getState) => {
|
||||||
dispatch(actions.fetchTopicsListAction.request());
|
dispatch(actions.fetchTopicsListAction.request());
|
||||||
try {
|
try {
|
||||||
const { topics, pageCount } = await topicsApiClient.getTopics(params);
|
const { topics, pageCount } = await topicsApiClient.getTopics(params);
|
||||||
|
@ -61,15 +65,13 @@ export const fetchTopicsList =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchTopicsListAction.failure());
|
dispatch(actions.fetchTopicsListAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTopicMessages =
|
export const fetchTopicMessages = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
topicName: TopicName,
|
topicName: TopicName,
|
||||||
queryParams: Partial<TopicMessageQueryParams>
|
queryParams: Partial<TopicMessageQueryParams>
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.fetchTopicMessagesAction.request());
|
dispatch(actions.fetchTopicMessagesAction.request());
|
||||||
try {
|
try {
|
||||||
const messages = await messagesApiClient.getTopicMessages({
|
const messages = await messagesApiClient.getTopicMessages({
|
||||||
|
@ -81,15 +83,13 @@ export const fetchTopicMessages =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchTopicMessagesAction.failure());
|
dispatch(actions.fetchTopicMessagesAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearTopicMessages =
|
export const clearTopicMessages = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
topicName: TopicName,
|
topicName: TopicName,
|
||||||
partitions?: number[]
|
partitions?: number[]
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
async (dispatch) => {
|
|
||||||
dispatch(actions.clearMessagesTopicAction.request());
|
dispatch(actions.clearMessagesTopicAction.request());
|
||||||
try {
|
try {
|
||||||
await messagesApiClient.deleteTopicMessages({
|
await messagesApiClient.deleteTopicMessages({
|
||||||
|
@ -107,11 +107,12 @@ export const clearTopicMessages =
|
||||||
};
|
};
|
||||||
dispatch(actions.clearMessagesTopicAction.failure({ alert }));
|
dispatch(actions.clearMessagesTopicAction.failure({ alert }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTopicDetails =
|
export const fetchTopicDetails = (
|
||||||
(clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch, getState) => {
|
topicName: TopicName
|
||||||
|
): PromiseThunkResult => async (dispatch, getState) => {
|
||||||
dispatch(actions.fetchTopicDetailsAction.request());
|
dispatch(actions.fetchTopicDetailsAction.request());
|
||||||
try {
|
try {
|
||||||
const topicDetails = await topicsApiClient.getTopicDetails({
|
const topicDetails = await topicsApiClient.getTopicDetails({
|
||||||
|
@ -133,11 +134,12 @@ export const fetchTopicDetails =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchTopicDetailsAction.failure());
|
dispatch(actions.fetchTopicDetailsAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTopicConfig =
|
export const fetchTopicConfig = (
|
||||||
(clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch, getState) => {
|
topicName: TopicName
|
||||||
|
): PromiseThunkResult => async (dispatch, getState) => {
|
||||||
dispatch(actions.fetchTopicConfigAction.request());
|
dispatch(actions.fetchTopicConfigAction.request());
|
||||||
try {
|
try {
|
||||||
const config = await topicsApiClient.getTopicConfigs({
|
const config = await topicsApiClient.getTopicConfigs({
|
||||||
|
@ -163,7 +165,7 @@ export const fetchTopicConfig =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.fetchTopicConfigAction.failure());
|
dispatch(actions.fetchTopicConfigAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTopicCreation = (form: TopicFormDataRaw): TopicCreation => {
|
const formatTopicCreation = (form: TopicFormDataRaw): TopicCreation => {
|
||||||
const {
|
const {
|
||||||
|
@ -231,9 +233,10 @@ const formatTopicUpdate = (form: TopicFormDataRaw): TopicUpdate => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTopic =
|
export const createTopic = (
|
||||||
(clusterName: ClusterName, form: TopicFormDataRaw): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch, getState) => {
|
form: TopicFormDataRaw
|
||||||
|
): PromiseThunkResult => async (dispatch, getState) => {
|
||||||
dispatch(actions.createTopicAction.request());
|
dispatch(actions.createTopicAction.request());
|
||||||
try {
|
try {
|
||||||
const topic: Topic = await topicsApiClient.createTopic({
|
const topic: Topic = await topicsApiClient.createTopic({
|
||||||
|
@ -263,15 +266,13 @@ export const createTopic =
|
||||||
};
|
};
|
||||||
dispatch(actions.createTopicAction.failure({ alert }));
|
dispatch(actions.createTopicAction.failure({ alert }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTopic =
|
export const updateTopic = (
|
||||||
(
|
|
||||||
clusterName: ClusterName,
|
clusterName: ClusterName,
|
||||||
topicName: TopicName,
|
topicName: TopicName,
|
||||||
form: TopicFormDataRaw
|
form: TopicFormDataRaw
|
||||||
): PromiseThunkResult =>
|
): PromiseThunkResult => async (dispatch, getState) => {
|
||||||
async (dispatch, getState) => {
|
|
||||||
dispatch(actions.updateTopicAction.request());
|
dispatch(actions.updateTopicAction.request());
|
||||||
try {
|
try {
|
||||||
const topic: Topic = await topicsApiClient.updateTopic({
|
const topic: Topic = await topicsApiClient.updateTopic({
|
||||||
|
@ -296,11 +297,12 @@ export const updateTopic =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.updateTopicAction.failure());
|
dispatch(actions.updateTopicAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTopic =
|
export const deleteTopic = (
|
||||||
(clusterName: ClusterName, topicName: TopicName): PromiseThunkResult =>
|
clusterName: ClusterName,
|
||||||
async (dispatch) => {
|
topicName: TopicName
|
||||||
|
): PromiseThunkResult => async (dispatch) => {
|
||||||
dispatch(actions.deleteTopicAction.request());
|
dispatch(actions.deleteTopicAction.request());
|
||||||
try {
|
try {
|
||||||
await topicsApiClient.deleteTopic({
|
await topicsApiClient.deleteTopic({
|
||||||
|
@ -311,4 +313,4 @@ export const deleteTopic =
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch(actions.deleteTopicAction.failure());
|
dispatch(actions.deleteTopicAction.failure());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
TopicConfig,
|
TopicConfig,
|
||||||
TopicCreation,
|
TopicCreation,
|
||||||
GetTopicMessagesRequest,
|
GetTopicMessagesRequest,
|
||||||
|
TopicColumnsToSort,
|
||||||
} from 'generated-sources';
|
} from 'generated-sources';
|
||||||
|
|
||||||
export type TopicName = Topic['name'];
|
export type TopicName = Topic['name'];
|
||||||
|
@ -45,6 +46,8 @@ export interface TopicsState {
|
||||||
allNames: TopicName[];
|
allNames: TopicName[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
messages: TopicMessage[];
|
messages: TopicMessage[];
|
||||||
|
search: string;
|
||||||
|
orderBy: TopicColumnsToSort | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TopicFormFormattedParams = TopicCreation['configs'];
|
export type TopicFormFormattedParams = TopicCreation['configs'];
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import { deleteTopicAction, clearMessagesTopicAction } from 'redux/actions';
|
import { TopicColumnsToSort } from 'generated-sources';
|
||||||
|
import {
|
||||||
|
deleteTopicAction,
|
||||||
|
clearMessagesTopicAction,
|
||||||
|
setTopicsSearchAction,
|
||||||
|
setTopicsOrderByAction,
|
||||||
|
} from 'redux/actions';
|
||||||
import reducer from 'redux/reducers/topics/reducer';
|
import reducer from 'redux/reducers/topics/reducer';
|
||||||
|
|
||||||
const topic = {
|
const topic = {
|
||||||
|
@ -13,15 +19,17 @@ const state = {
|
||||||
allNames: [topic.name],
|
allNames: [topic.name],
|
||||||
messages: [],
|
messages: [],
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
|
search: '',
|
||||||
|
orderBy: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('topics reducer', () => {
|
describe('topics reducer', () => {
|
||||||
|
describe('delete topic', () => {
|
||||||
it('deletes the topic from the list on DELETE_TOPIC__SUCCESS', () => {
|
it('deletes the topic from the list on DELETE_TOPIC__SUCCESS', () => {
|
||||||
expect(reducer(state, deleteTopicAction.success(topic.name))).toEqual({
|
expect(reducer(state, deleteTopicAction.success(topic.name))).toEqual({
|
||||||
|
...state,
|
||||||
byName: {},
|
byName: {},
|
||||||
allNames: [],
|
allNames: [],
|
||||||
messages: [],
|
|
||||||
totalPages: 1,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -30,4 +38,25 @@ describe('topics reducer', () => {
|
||||||
reducer(state, clearMessagesTopicAction.success(topic.name))
|
reducer(state, clearMessagesTopicAction.success(topic.name))
|
||||||
).toEqual(state);
|
).toEqual(state);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search topics', () => {
|
||||||
|
it('sets the search string', () => {
|
||||||
|
expect(reducer(state, setTopicsSearchAction('test'))).toEqual({
|
||||||
|
...state,
|
||||||
|
search: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('order topics', () => {
|
||||||
|
it('sets the orderBy', () => {
|
||||||
|
expect(
|
||||||
|
reducer(state, setTopicsOrderByAction(TopicColumnsToSort.NAME))
|
||||||
|
).toEqual({
|
||||||
|
...state,
|
||||||
|
orderBy: TopicColumnsToSort.NAME,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,8 @@ export const initialState: TopicsState = {
|
||||||
allNames: [],
|
allNames: [],
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
search: '',
|
||||||
|
orderBy: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const transformTopicMessages = (
|
const transformTopicMessages = (
|
||||||
|
@ -59,6 +61,18 @@ const reducer = (state = initialState, action: Action): TopicsState => {
|
||||||
messages: [],
|
messages: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case getType(actions.setTopicsSearchAction): {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
search: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case getType(actions.setTopicsOrderByAction): {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
orderBy: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,10 +18,12 @@ export const getTopicListTotalPages = (state: RootState) =>
|
||||||
topicsState(state).totalPages;
|
topicsState(state).totalPages;
|
||||||
|
|
||||||
const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
|
const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
|
||||||
const getTopicDetailsFetchingStatus =
|
const getTopicDetailsFetchingStatus = createFetchingSelector(
|
||||||
createFetchingSelector('GET_TOPIC_DETAILS');
|
'GET_TOPIC_DETAILS'
|
||||||
const getTopicMessagesFetchingStatus =
|
);
|
||||||
createFetchingSelector('GET_TOPIC_MESSAGES');
|
const getTopicMessagesFetchingStatus = createFetchingSelector(
|
||||||
|
'GET_TOPIC_MESSAGES'
|
||||||
|
);
|
||||||
const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
|
const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
|
||||||
const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
|
const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
|
||||||
const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
|
const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
|
||||||
|
@ -122,6 +124,16 @@ export const getTopicConfigByParamName = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getTopicsSearch = createSelector(
|
||||||
|
topicsState,
|
||||||
|
(state) => state.search
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getTopicsOrderBy = createSelector(
|
||||||
|
topicsState,
|
||||||
|
(state) => state.orderBy
|
||||||
|
);
|
||||||
|
|
||||||
export const getIsTopicInternal = createSelector(
|
export const getIsTopicInternal = createSelector(
|
||||||
getTopicByName,
|
getTopicByName,
|
||||||
({ internal }) => !!internal
|
({ internal }) => !!internal
|
||||||
|
|
|
@ -6,7 +6,9 @@ import { RootState, Action } from 'redux/interfaces';
|
||||||
const middlewares: Array<Middleware> = [thunk];
|
const middlewares: Array<Middleware> = [thunk];
|
||||||
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
|
type DispatchExts = ThunkDispatch<RootState, undefined, Action>;
|
||||||
|
|
||||||
const mockStoreCreator: MockStoreCreator<RootState, DispatchExts> =
|
const mockStoreCreator: MockStoreCreator<
|
||||||
configureMockStore<RootState, DispatchExts>(middlewares);
|
RootState,
|
||||||
|
DispatchExts
|
||||||
|
> = configureMockStore<RootState, DispatchExts>(middlewares);
|
||||||
|
|
||||||
export default mockStoreCreator();
|
export default mockStoreCreator();
|
||||||
|
|
Loading…
Add table
Reference in a new issue