Issue#207 Requests to KsqlDb from UI (#786)

* Add ksql page

* Add streams to table

Add tables and streams count

* Add query execution modal

* Add tabs tests

* Adjust SQLEditor test

* Remove unused command

* Add QueryModal tests

* Remove excess box wrapper

* Add KsqlDb component tests

* Refactor SQLEditor displayName

* Refactor setIsShown naming

* Add empty placeholder for table

* Refactor use shortcut for Dictionary interface

* Refactor raw data to object transformation

* Add KsqlDb List component tests

* Remove excess import

* Add ksqlDb reducer tests

* Add ksqlDb fetch tables error alert

* Refactor remove code smell

* Add ksqlDb actions tests

* Add KsqlDb List component test for non-empty store

* Add streamProperties JSON field

* Adjust QueryModal test to new field

* Fix query with empty streamProperties

* Refactor query to modal to page form

* Add expanding to tables and streams table

* Refactor ksql execution to redux store

* Add Query component tests

* Add Breadcrumbs component tests

* Add ksqldb reducer tests

* Add result renderer test

* Add ksqlDb thunk tests

* Resolve PR comments
This commit is contained in:
Azat Belgibayev 2021-08-18 13:24:17 +03:00 committed by GitHub
parent 7f66f00008
commit 03ed67db89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1426 additions and 0 deletions

View file

@ -11,6 +11,7 @@ import {
clusterConnectorsPath,
clusterConnectsPath,
clusterConsumerGroupsPath,
clusterKsqlDbPath,
clusterSchemasPath,
clusterTopicsPath,
} from 'lib/paths';
@ -20,6 +21,7 @@ import Connect from 'components/Connect/Connect';
import ClusterContext from 'components/contexts/ClusterContext';
import BrokersContainer from 'components/Brokers/BrokersContainer';
import ConsumersGroupsContainer from 'components/ConsumerGroups/ConsumersGroupsContainer';
import KsqlDb from 'components/KsqlDb/KsqlDb';
const Cluster: React.FC = () => {
const { clusterName } = useParams<{ clusterName: string }>();
@ -32,6 +34,7 @@ const Cluster: React.FC = () => {
const hasSchemaRegistryConfigured = features.includes(
ClusterFeaturesEnum.SCHEMA_REGISTRY
);
const hasKsqlDbConfigured = features.includes(ClusterFeaturesEnum.KSQL_DB);
const contextValue = React.useMemo(
() => ({
@ -72,6 +75,9 @@ const Cluster: React.FC = () => {
component={Connect}
/>
)}
{hasKsqlDbConfigured && (
<Route path={clusterKsqlDbPath(':clusterName')} component={KsqlDb} />
)}
<Redirect
from="/ui/clusters/:clusterName"
to="/ui/clusters/:clusterName/brokers"

View file

@ -0,0 +1,30 @@
import React from 'react';
import Breadcrumb, {
BreadcrumbItem,
} from 'components/common/Breadcrumb/Breadcrumb';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
import { useParams, useRouteMatch } from 'react-router';
interface RouteParams {
clusterName: string;
}
const Breadcrumbs: React.FC = () => {
const { clusterName } = useParams<RouteParams>();
const isQuery = useRouteMatch(clusterKsqlDbQueryPath(clusterName));
if (!isQuery) {
return <Breadcrumb>KSQLDB</Breadcrumb>;
}
const links: BreadcrumbItem[] = [
{
label: 'KSQLDB',
href: clusterKsqlDbPath(clusterName),
},
];
return <Breadcrumb links={links}>Query</Breadcrumb>;
};
export default Breadcrumbs;

View file

@ -0,0 +1,33 @@
import React from 'react';
import { StaticRouter } from 'react-router';
import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
import { mount } from 'enzyme';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
describe('BreadCrumbs', () => {
const clusterName = 'local';
const rootPathname = clusterKsqlDbPath(clusterName);
const queryPathname = clusterKsqlDbQueryPath(clusterName);
const setupComponent = (pathname: string) => (
<StaticRouter location={{ pathname }} context={{}}>
<Breadcrumbs />
</StaticRouter>
);
it('Renders root path', () => {
const component = mount(setupComponent(rootPathname));
expect(component.find({ children: 'KSQLDB' }).exists()).toBeTruthy();
expect(component.find({ children: 'Query' }).exists()).toBeFalsy();
});
it('Renders query path', () => {
const component = mount(setupComponent(queryPathname));
expect(
component.find('a').find({ children: 'KSQLDB' }).exists()
).toBeTruthy();
expect(component.find({ children: 'Query' }).exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { clusterKsqlDbPath, clusterKsqlDbQueryPath } from 'lib/paths';
import List from 'components/KsqlDb/List/List';
import Query from 'components/KsqlDb/Query/Query';
import Breadcrumbs from 'components/KsqlDb/BreadCrumbs/BreadCrumbs';
const KsqlDb: React.FC = () => {
return (
<div className="section">
<Switch>
<Route path={clusterKsqlDbPath()} component={Breadcrumbs} />
</Switch>
<Switch>
<Route exact path={clusterKsqlDbPath()} component={List} />
<Route exact path={clusterKsqlDbQueryPath()} component={Query} />
</Switch>
</div>
);
};
export default KsqlDb;

View file

@ -0,0 +1,93 @@
import Indicator from 'components/common/Dashboard/Indicator';
import MetricsWrapper from 'components/common/Dashboard/MetricsWrapper';
import PageLoader from 'components/common/PageLoader/PageLoader';
import ListItem from 'components/KsqlDb/List/ListItem';
import React, { FC, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { fetchKsqlDbTables } from 'redux/actions/thunks/ksqlDb';
import { getKsqlDbTables } from 'redux/reducers/ksqlDb/selectors';
import { Link } from 'react-router-dom';
import { clusterKsqlDbQueryPath } from 'lib/paths';
const headers = [
{ Header: 'Type', accessor: 'type' },
{ Header: 'Name', accessor: 'name' },
{ Header: 'Topic', accessor: 'topic' },
{ Header: 'Key Format', accessor: 'keyFormat' },
{ Header: 'Value Format', accessor: 'valueFormat' },
];
const accessors = headers.map((header) => header.accessor);
const List: FC = () => {
const dispatch = useDispatch();
const { clusterName } = useParams<{ clusterName: string }>();
const { rows, fetching, tablesCount, streamsCount } =
useSelector(getKsqlDbTables);
useEffect(() => {
dispatch(fetchKsqlDbTables(clusterName));
}, []);
return (
<>
<MetricsWrapper wrapperClassName="is-justify-content-space-between">
<div className="column is-flex m-0 p-0">
<Indicator
className="level-left is-one-third mr-3"
label="Tables"
title="Tables"
fetching={fetching}
>
{tablesCount}
</Indicator>
<Indicator
className="level-left is-one-third ml-3"
label="Streams"
title="Streams"
fetching={fetching}
>
{streamsCount}
</Indicator>
</div>
<Link
to={clusterKsqlDbQueryPath(clusterName)}
className="button is-primary"
>
Execute ksql
</Link>
</MetricsWrapper>
<div className="box">
{fetching ? (
<PageLoader />
) : (
<table className="table is-fullwidth">
<thead>
<tr>
<th> </th>
{headers.map(({ Header, accessor }) => (
<th key={accessor}>{Header}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<ListItem key={row.name} accessors={accessors} data={row} />
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length}>No tables or streams found</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</>
);
};
export default List;

View file

@ -0,0 +1,40 @@
import React from 'react';
interface Props {
accessors: string[];
data: Record<string, string>;
}
const ListItem: React.FC<Props> = ({ accessors, data }) => {
const [isOpen, setIsOpen] = React.useState(false);
const toggleIsOpen = React.useCallback(() => {
setIsOpen((prevState) => !prevState);
}, []);
return (
<>
<tr>
<td>
<span
className="icon has-text-link is-size-7 is-small is-clickable"
onClick={toggleIsOpen}
aria-hidden
>
<i className={`fas fa-${isOpen ? 'minus' : 'plus'}`} />
</span>
</td>
{accessors.map((accessor) => (
<td key={accessor}>{data[accessor]}</td>
))}
</tr>
{isOpen && (
<tr>
<td colSpan={accessors.length + 1}>Expanding content</td>
</tr>
)}
</>
);
};
export default ListItem;

View file

@ -0,0 +1,63 @@
import React from 'react';
import List from 'components/KsqlDb/List/List';
import { mount } from 'enzyme';
import { StaticRouter } from 'react-router';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import { RootState } from 'redux/interfaces';
import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
const emptyPlaceholder = 'No tables or streams found';
const mockStore = configureStore();
describe('KsqlDb List', () => {
const pathname = `ui/clusters/local/ksql-db`;
it('Renders placeholder on empty data', () => {
const initialState: Partial<RootState> = {
ksqlDb: {
tables: [],
streams: [],
executionResult: null,
},
loader: {
GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<List />
</Provider>
</StaticRouter>
);
expect(
component.find({ children: emptyPlaceholder }).exists()
).toBeTruthy();
});
it('Renders rows', () => {
const initialState: Partial<RootState> = {
ksqlDb: { ...fetchKsqlDbTablesPayload, executionResult: null },
loader: {
GET_KSQL_DB_TABLES_AND_STREAMS: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<List />
</Provider>
</StaticRouter>
);
// 2 streams, 2 tables and 1 head tr
expect(component.find('tr').length).toEqual(5);
});
});

View file

@ -0,0 +1,114 @@
import React, { useCallback, useEffect, FC } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import JSONEditor from 'components/common/JSONEditor/JSONEditor';
import SQLEditor from 'components/common/SQLEditor/SQLEditor';
import yup from 'lib/yupExtended';
import { useForm, Controller } from 'react-hook-form';
import { useParams } from 'react-router';
import { executeKsql } from 'redux/actions/thunks/ksqlDb';
import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
import { useDispatch, useSelector } from 'react-redux';
import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors';
import { resetExecutionResult } from 'redux/actions';
type FormValues = {
ksql: string;
streamsProperties: string;
};
const validationSchema = yup.object({
ksql: yup.string().trim().required(),
});
const Query: FC = () => {
const { clusterName } = useParams<{ clusterName: string }>();
const dispatch = useDispatch();
const { executionResult, fetching } = useSelector(getKsqlExecution);
const reset = useCallback(() => {
dispatch(resetExecutionResult());
}, [dispatch]);
useEffect(() => {
return reset;
}, []);
const { handleSubmit, control } = useForm<FormValues>({
mode: 'onTouched',
resolver: yupResolver(validationSchema),
defaultValues: {
ksql: '',
streamsProperties: '',
},
});
const submitHandler = useCallback(async (values: FormValues) => {
dispatch(
executeKsql({
clusterName,
ksqlCommand: {
...values,
streamsProperties: values.streamsProperties
? JSON.parse(values.streamsProperties)
: undefined,
},
})
);
}, []);
return (
<>
<div className="box">
<form onSubmit={handleSubmit(submitHandler)}>
<div className="columns">
<div className="control column m-0">
<label className="label">KSQL</label>
<Controller
control={control}
name="ksql"
render={({ field }) => (
<SQLEditor {...field} readOnly={fetching} />
)}
/>
</div>
<div className="control column m-0">
<label className="label">Stream properties</label>
<Controller
control={control}
name="streamsProperties"
render={({ field }) => (
<JSONEditor {...field} readOnly={fetching} />
)}
/>
</div>
</div>
<div className="columns">
<div className="column is-flex-grow-0">
<button
className="button is-primary"
type="submit"
disabled={fetching}
>
Execute
</button>
</div>
<div className="column is-flex-grow-0">
<button
className="button is-danger"
type="button"
disabled={!executionResult}
onClick={reset}
>
Clear
</button>
</div>
</div>
</form>
</div>
<ResultRenderer result={executionResult} />
</>
);
};
export default Query;

View file

@ -0,0 +1,61 @@
import React from 'react';
import { KsqlCommandResponse, Table } from 'generated-sources';
const ResultRenderer: React.FC<{ result: KsqlCommandResponse | null }> = ({
result,
}) => {
if (!result) return null;
const isMessage = !!result.message;
if (isMessage) return <div className="box">{result.message}</div>;
const isTable = result.data !== undefined;
if (!isTable) return null;
const rawTable = result.data as Table;
const { headers, rows } = rawTable;
const transformedRows = React.useMemo(
() =>
rows.map((row) =>
row.reduce((res, acc, index) => {
res[rawTable.headers[index]] = acc;
return res;
}, {} as Dictionary<string>)
),
[]
);
return (
<div className="box">
<table className="table is-fullwidth">
<thead>
<tr>
{headers.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
<tbody>
{transformedRows.map((row) => (
<tr key={row.name}>
{headers.map((header) => (
<td key={header}>{row[header]}</td>
))}
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={headers.length}>No tables or streams found</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default ResultRenderer;

View file

@ -0,0 +1,67 @@
import React from 'react';
import { mount } from 'enzyme';
import Query from 'components/KsqlDb/Query/Query';
import { StaticRouter } from 'react-router';
import configureStore from 'redux-mock-store';
import { RootState } from 'redux/interfaces';
import { ksqlCommandResponse } from 'redux/reducers/ksqlDb/__test__/fixtures';
import { Provider } from 'react-redux';
const mockStore = configureStore();
describe('KsqlDb Query Component', () => {
const pathname = `ui/clusters/local/ksql-db/query`;
it('Renders result', () => {
const initialState: Partial<RootState> = {
ksqlDb: {
streams: [],
tables: [],
executionResult: ksqlCommandResponse,
},
loader: {
EXECUTE_KSQL: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<Query />
</Provider>
</StaticRouter>
);
// 2 streams and 1 head tr
expect(component.find('tr').length).toEqual(3);
});
it('Renders result message', () => {
const initialState: Partial<RootState> = {
ksqlDb: {
streams: [],
tables: [],
executionResult: {
message: 'No available data',
},
},
loader: {
EXECUTE_KSQL: 'fetched',
},
};
const store = mockStore(initialState);
const component = mount(
<StaticRouter location={{ pathname }} context={{}}>
<Provider store={store}>
<Query />
</Provider>
</StaticRouter>
);
expect(
component.find({ children: 'No available data' }).exists()
).toBeTruthy();
});
});

View file

@ -0,0 +1,14 @@
import { mount } from 'enzyme';
import { KsqlCommandResponse } from 'generated-sources';
import React from 'react';
import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
describe('Result Renderer', () => {
const result: KsqlCommandResponse = {};
it('Matches snapshot', () => {
const component = mount(<ResultRenderer result={result} />);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Result Renderer Matches snapshot 1`] = `
<ResultRenderer
result={Object {}}
/>
`;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { mount } from 'enzyme';
import KsqlDb from 'components/KsqlDb/KsqlDb';
import { StaticRouter } from 'react-router';
describe('KsqlDb Component', () => {
const pathname = `ui/clusters/local/ksql-db`;
describe('KsqlDb', () => {
const setupComponent = () => (
<StaticRouter location={{ pathname }} context={{}}>
<KsqlDb />
</StaticRouter>
);
it('matches snapshot', () => {
expect(mount(setupComponent())).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`KsqlDb Component KsqlDb matches snapshot 1`] = `
<StaticRouter
context={Object {}}
location={
Object {
"pathname": "ui/clusters/local/ksql-db",
}
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "ui/clusters/local/ksql-db",
"search": "",
},
"push": [Function],
"replace": [Function],
}
}
staticContext={Object {}}
>
<KsqlDb>
<div
className="section"
>
<Switch />
<Switch />
</div>
</KsqlDb>
</Router>
</StaticRouter>
`;

View file

@ -8,6 +8,7 @@ import {
clusterSchemasPath,
clusterConnectorsPath,
clusterConnectsPath,
clusterKsqlDbPath,
} from 'lib/paths';
import DefaultClusterIcon from './DefaultClusterIcon';
@ -92,6 +93,17 @@ const ClusterMenu: React.FC<Props> = ({
</NavLink>
</li>
)}
{hasFeatureConfigured(ClusterFeaturesEnum.KSQL_DB) && (
<li>
<NavLink
to={clusterKsqlDbPath(name)}
activeClassName="is-active"
title="KSQL DB"
>
KSQL DB
</NavLink>
</li>
)}
</ul>
</li>
</ul>

View file

@ -0,0 +1,34 @@
/* eslint-disable react/jsx-props-no-spreading */
import AceEditor, { IAceEditorProps } from 'react-ace';
import 'ace-builds/src-noconflict/mode-sql';
import 'ace-builds/src-noconflict/theme-textmate';
import React from 'react';
import ReactAce from 'react-ace/lib/ace';
interface SQLEditorProps extends IAceEditorProps {
isFixedHeight?: boolean;
}
const SQLEditor = React.forwardRef<ReactAce | null, SQLEditorProps>(
(props, ref) => {
const { isFixedHeight, ...rest } = props;
return (
<AceEditor
ref={ref}
mode="sql"
theme="textmate"
tabSize={2}
width="100%"
height={
isFixedHeight
? `${(props.value?.split('\n').length || 32) * 16}px`
: '500px'
}
wrapEnabled
{...rest}
/>
);
}
);
export default SQLEditor;

View file

@ -0,0 +1,20 @@
import { shallow } from 'enzyme';
import React from 'react';
import SQLEditor from 'components/common/SQLEditor/SQLEditor';
describe('JSONEditor component', () => {
it('matches the snapshot', () => {
const component = shallow(<SQLEditor value="" name="name" />);
expect(component).toMatchSnapshot();
});
it('matches the snapshot with fixed height', () => {
const component = shallow(<SQLEditor value="" name="name" isFixedHeight />);
expect(component).toMatchSnapshot();
});
it('matches the snapshot with fixed height with no value', () => {
const component = shallow(<SQLEditor name="name" isFixedHeight />);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JSONEditor component matches the snapshot 1`] = `
<ReactAce
cursorStart={1}
editorProps={Object {}}
enableBasicAutocompletion={false}
enableLiveAutocompletion={false}
enableSnippets={false}
focus={false}
fontSize={12}
height="500px"
highlightActiveLine={true}
maxLines={null}
minLines={null}
mode="sql"
name="name"
navigateToFileEnd={true}
onChange={null}
onLoad={null}
onPaste={null}
onScroll={null}
placeholder={null}
readOnly={false}
scrollMargin={
Array [
0,
0,
0,
0,
]
}
setOptions={Object {}}
showGutter={true}
showPrintMargin={true}
style={Object {}}
tabSize={2}
theme="textmate"
value=""
width="100%"
wrapEnabled={true}
/>
`;
exports[`JSONEditor component matches the snapshot with fixed height 1`] = `
<ReactAce
cursorStart={1}
editorProps={Object {}}
enableBasicAutocompletion={false}
enableLiveAutocompletion={false}
enableSnippets={false}
focus={false}
fontSize={12}
height="16px"
highlightActiveLine={true}
maxLines={null}
minLines={null}
mode="sql"
name="name"
navigateToFileEnd={true}
onChange={null}
onLoad={null}
onPaste={null}
onScroll={null}
placeholder={null}
readOnly={false}
scrollMargin={
Array [
0,
0,
0,
0,
]
}
setOptions={Object {}}
showGutter={true}
showPrintMargin={true}
style={Object {}}
tabSize={2}
theme="textmate"
value=""
width="100%"
wrapEnabled={true}
/>
`;
exports[`JSONEditor component matches the snapshot with fixed height with no value 1`] = `
<ReactAce
cursorStart={1}
editorProps={Object {}}
enableBasicAutocompletion={false}
enableLiveAutocompletion={false}
enableSnippets={false}
focus={false}
fontSize={12}
height="512px"
highlightActiveLine={true}
maxLines={null}
minLines={null}
mode="sql"
name="name"
navigateToFileEnd={true}
onChange={null}
onLoad={null}
onPaste={null}
onScroll={null}
placeholder={null}
readOnly={false}
scrollMargin={
Array [
0,
0,
0,
0,
]
}
setOptions={Object {}}
showGutter={true}
showPrintMargin={true}
style={Object {}}
tabSize={2}
theme="textmate"
width="100%"
wrapEnabled={true}
/>
`;

View file

@ -0,0 +1,55 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
import React from 'react';
import classNames from 'classnames';
interface TabsProps {
tabs: string[];
defaultSelectedIndex?: number;
onChange?(index: number): void;
}
const Tabs: React.FC<TabsProps> = ({
tabs,
defaultSelectedIndex = 0,
onChange,
children,
}) => {
const [selectedIndex, setSelectedIndex] =
React.useState(defaultSelectedIndex);
React.useEffect(() => {
setSelectedIndex(defaultSelectedIndex);
}, [defaultSelectedIndex]);
const handleChange = React.useCallback((index: number) => {
setSelectedIndex(index);
onChange?.(index);
}, []);
return (
<>
<div className="tabs">
<ul>
{tabs.map((tab, index) => (
<li
key={tab}
className={classNames({ 'is-active': index === selectedIndex })}
>
<a
role="button"
tabIndex={index}
onClick={() => handleChange(index)}
onKeyDown={() => handleChange(index)}
>
{tab}
</a>
</li>
))}
</ul>
</div>
{React.Children.toArray(children)[selectedIndex]}
</>
);
};
export default Tabs;

View file

@ -0,0 +1,43 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import Tabs from 'components/common/Tabs/Tabs';
describe('Tabs component', () => {
const tabs: string[] = ['Tab 1', 'Tab 2', 'Tab 3'];
const child1 = <div className="child_1" />;
const child2 = <div className="child_2" />;
const child3 = <div className="child_3" />;
const component = mount(
<Tabs tabs={tabs}>
{child1}
{child2}
{child3}
</Tabs>
);
it('renders the tabs with default index 0', () =>
expect(component.find(`li`).at(0).hasClass('is-active')).toBeTruthy());
it('renders the list of tabs', () => {
component.find(`a`).forEach((link, idx) => {
expect(link.contains(tabs[idx])).toBeTruthy();
});
});
it('renders the children', () => {
component.find(`a`).forEach((link, idx) => {
link.simulate('click');
expect(component.find(`.child_${idx + 1}`).exists()).toBeTruthy();
});
});
it('matches the snapshot', () => {
const shallowComponent = shallow(
<Tabs tabs={tabs}>
{child1}
{child2}
{child3}
</Tabs>
);
expect(shallowComponent).toMatchSnapshot();
});
});

View file

@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Tabs component matches the snapshot 1`] = `
<Fragment>
<div
className="tabs"
>
<ul>
<li
className="is-active"
key="Tab 1"
>
<a
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
Tab 1
</a>
</li>
<li
className=""
key="Tab 2"
>
<a
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={1}
>
Tab 2
</a>
</li>
<li
className=""
key="Tab 3"
>
<a
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={2}
>
Tab 3
</a>
</li>
</ul>
</div>
<div
className="child_1"
key=".0"
/>
</Fragment>
`;

1
kafka-ui-react-app/src/custom.d.ts vendored Normal file
View file

@ -0,0 +1 @@
type Dictionary<T> = Record<string, T>;

View file

@ -119,3 +119,11 @@ export const clusterConnectConnectorConfigPath = (
connectName,
connectorName
)}/config`;
// KsqlDb
export const clusterKsqlDbPath = (clusterName: ClusterName = ':clusterName') =>
`${clusterPath(clusterName)}/ksql-db`;
export const clusterKsqlDbQueryPath = (
clusterName: ClusterName = ':clusterName'
) => `${clusterPath(clusterName)}/ksql-db/query`;

View file

@ -13,6 +13,7 @@ import {
topicMessagePayload,
topicMessagesMetaPayload,
} from 'redux/reducers/topicMessages/__test__/fixtures';
import { fetchKsqlDbTablesPayload } from 'redux/reducers/ksqlDb/__test__/fixtures';
import { mockTopicsState } from './fixtures';
@ -326,3 +327,25 @@ describe('Actions', () => {
});
});
});
describe('ksqlDb', () => {
it('creates GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST', () => {
expect(actions.fetchKsqlDbTablesAction.request()).toEqual({
type: 'GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST',
});
});
it('creates GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS', () => {
expect(
actions.fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload)
).toEqual({
type: 'GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS',
payload: fetchKsqlDbTablesPayload,
});
});
it('creates GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE', () => {
expect(actions.fetchKsqlDbTablesAction.failure({})).toEqual({
type: 'GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE',
payload: {},
});
});
});

View file

@ -0,0 +1,53 @@
import fetchMock from 'fetch-mock-jest';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import * as thunks from 'redux/actions/thunks';
import * as actions from 'redux/actions';
import { ksqlCommandResponse } from 'redux/reducers/ksqlDb/__test__/fixtures';
import { transformKsqlResponse } from 'redux/actions/thunks';
const store = mockStoreCreator;
const clusterName = 'local';
describe('Thunks', () => {
afterEach(() => {
fetchMock.restore();
store.clearActions();
});
describe('fetchKsqlDbTables', () => {
it('creates GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS when fetching streams', async () => {
fetchMock.post(`/api/clusters/${clusterName}/ksql`, ksqlCommandResponse);
await store.dispatch(thunks.fetchKsqlDbTables(clusterName));
expect(store.getActions()).toEqual([
actions.fetchKsqlDbTablesAction.request(),
actions.fetchKsqlDbTablesAction.success({
streams: transformKsqlResponse(ksqlCommandResponse.data),
tables: transformKsqlResponse(ksqlCommandResponse.data),
}),
]);
});
it('creates GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE', async () => {
fetchMock.post(`/api/clusters/${clusterName}/ksql`, 422);
await store.dispatch(thunks.fetchKsqlDbTables(clusterName));
expect(store.getActions()).toEqual([
actions.fetchKsqlDbTablesAction.request(),
actions.fetchKsqlDbTablesAction.failure({
alert: {
subject: 'ksqlDb',
title: 'Failed to fetch tables and streams',
response: {
status: 422,
statusText: 'Unprocessable Entity',
body: undefined,
},
},
}),
]);
});
});
});

View file

@ -25,6 +25,7 @@ import {
TopicMessage,
TopicMessageConsuming,
TopicMessageSchema,
KsqlCommandResponse,
} from 'generated-sources';
export const fetchClusterStatsAction = createAsyncAction(
@ -295,6 +296,27 @@ export const updateTopicReplicationFactorAction = createAsyncAction(
'UPDATE_REPLICATION_FACTOR__FAILURE'
)<undefined, undefined, { alert?: FailurePayload }>();
export const fetchKsqlDbTablesAction = createAsyncAction(
'GET_KSQL_DB_TABLES_AND_STREAMS__REQUEST',
'GET_KSQL_DB_TABLES_AND_STREAMS__SUCCESS',
'GET_KSQL_DB_TABLES_AND_STREAMS__FAILURE'
)<
undefined,
{
tables: Dictionary<string>[];
streams: Dictionary<string>[];
},
{ alert?: FailurePayload }
>();
export const executeKsqlAction = createAsyncAction(
'EXECUTE_KSQL__REQUEST',
'EXECUTE_KSQL__SUCCESS',
'EXECUTE_KSQL__FAILURE'
)<undefined, KsqlCommandResponse, { alert?: FailurePayload }>();
export const resetExecutionResult = createAction('RESET_EXECUTE_KSQL')();
export const resetConsumerGroupOffsetsAction = createAsyncAction(
'RESET_OFFSETS__REQUEST',
'RESET_OFFSETS__SUCCESS',

View file

@ -4,3 +4,4 @@ export * from './consumerGroups';
export * from './schemas';
export * from './topics';
export * from './connectors';
export * from './ksqlDb';

View file

@ -0,0 +1,85 @@
import {
Configuration,
ExecuteKsqlCommandRequest,
KsqlApi,
Table as KsqlTable,
} from 'generated-sources';
import {
PromiseThunkResult,
ClusterName,
FailurePayload,
} from 'redux/interfaces';
import { BASE_PARAMS } from 'lib/constants';
import * as actions from 'redux/actions/actions';
import { getResponse } from 'lib/errorHandling';
const apiClientConf = new Configuration(BASE_PARAMS);
export const ksqlDbApiClient = new KsqlApi(apiClientConf);
export const transformKsqlResponse = (
rawTable: Required<KsqlTable>
): Dictionary<string>[] =>
rawTable.rows.map((row) =>
row.reduce((res, acc, index) => {
res[rawTable.headers[index]] = acc;
return res;
}, {} as Dictionary<string>)
);
const getTables = (clusterName: ClusterName) =>
ksqlDbApiClient.executeKsqlCommand({
clusterName,
ksqlCommand: { ksql: 'SHOW TABLES;' },
});
const getStreams = (clusterName: ClusterName) =>
ksqlDbApiClient.executeKsqlCommand({
clusterName,
ksqlCommand: { ksql: 'SHOW STREAMS;' },
});
export const fetchKsqlDbTables =
(clusterName: ClusterName): PromiseThunkResult =>
async (dispatch) => {
dispatch(actions.fetchKsqlDbTablesAction.request());
try {
const tables = await getTables(clusterName);
const streams = await getStreams(clusterName);
dispatch(
actions.fetchKsqlDbTablesAction.success({
tables: tables.data ? transformKsqlResponse(tables.data) : [],
streams: streams.data ? transformKsqlResponse(streams.data) : [],
})
);
} catch (error) {
const response = await getResponse(error);
const alert: FailurePayload = {
subject: 'ksqlDb',
title: `Failed to fetch tables and streams`,
response,
};
dispatch(actions.fetchKsqlDbTablesAction.failure({ alert }));
}
};
export const executeKsql =
(params: ExecuteKsqlCommandRequest): PromiseThunkResult =>
async (dispatch) => {
dispatch(actions.executeKsqlAction.request());
try {
const response = await ksqlDbApiClient.executeKsqlCommand(params);
dispatch(actions.executeKsqlAction.success(response));
} catch (error) {
const response = await getResponse(error);
const alert: FailurePayload = {
subject: 'ksql execution',
title: `Failed to execute command ${params.ksqlCommand?.ksql}`,
response,
};
dispatch(actions.executeKsqlAction.failure({ alert }));
}
};

View file

@ -10,6 +10,7 @@ import { ConsumerGroupsState } from './consumerGroup';
import { SchemasState } from './schema';
import { AlertsState } from './alerts';
import { ConnectState } from './connect';
import { KsqlState } from './ksqlDb';
export * from './topic';
export * from './cluster';
@ -30,6 +31,7 @@ export interface RootState {
connect: ConnectState;
loader: LoaderState;
alerts: AlertsState;
ksqlDb: KsqlState;
}
export type Action = ActionType<typeof actions>;

View file

@ -0,0 +1,14 @@
import { KsqlCommandResponse } from 'generated-sources';
export interface KsqlTables {
data: {
headers: string[];
rows: string[][];
};
}
export interface KsqlState {
tables: Dictionary<string>[];
streams: Dictionary<string>[];
executionResult: KsqlCommandResponse | null;
}

View file

@ -10,6 +10,7 @@ import schemas from './schemas/reducer';
import connect from './connect/reducer';
import loader from './loader/reducer';
import alerts from './alerts/reducer';
import ksqlDb from './ksqlDb/reducer';
export default combineReducers<RootState>({
topics,
@ -21,4 +22,5 @@ export default combineReducers<RootState>({
connect,
loader,
alerts,
ksqlDb,
});

View file

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`KsqlDb reducer Fetches tables and streams 1`] = `
Object {
"executionResult": null,
"streams": Array [
Object {
"isWindowed": "false",
"keyFormat": "KAFKA",
"name": "KSQL_PROCESSING_LOG",
"topic": "default_ksql_processing_log",
"type": "STREAM",
"valueFormat": "JSON",
},
Object {
"isWindowed": "false",
"keyFormat": "KAFKA",
"name": "PAGEVIEWS",
"topic": "pageviews",
"type": "STREAM",
"valueFormat": "AVRO",
},
],
"tables": Array [
Object {
"isWindowed": "false",
"keyFormat": "KAFKA",
"name": "USERS",
"topic": "users",
"type": "TABLE",
"valueFormat": "AVRO",
},
Object {
"isWindowed": "false",
"keyFormat": "KAFKA",
"name": "USERS2",
"topic": "users",
"type": "TABLE",
"valueFormat": "AVRO",
},
],
}
`;
exports[`KsqlDb reducer Resets execution result 1`] = `
Object {
"executionResult": null,
"streams": Array [],
"tables": Array [],
}
`;

View file

@ -0,0 +1,65 @@
export const fetchKsqlDbTablesPayload: {
tables: Dictionary<string>[];
streams: Dictionary<string>[];
} = {
tables: [
{
type: 'TABLE',
name: 'USERS',
topic: 'users',
keyFormat: 'KAFKA',
valueFormat: 'AVRO',
isWindowed: 'false',
},
{
type: 'TABLE',
name: 'USERS2',
topic: 'users',
keyFormat: 'KAFKA',
valueFormat: 'AVRO',
isWindowed: 'false',
},
],
streams: [
{
type: 'STREAM',
name: 'KSQL_PROCESSING_LOG',
topic: 'default_ksql_processing_log',
keyFormat: 'KAFKA',
valueFormat: 'JSON',
isWindowed: 'false',
},
{
type: 'STREAM',
name: 'PAGEVIEWS',
topic: 'pageviews',
keyFormat: 'KAFKA',
valueFormat: 'AVRO',
isWindowed: 'false',
},
],
};
export const ksqlCommandResponse = {
data: {
headers: [
'type',
'name',
'topic',
'keyFormat',
'valueFormat',
'isWindowed',
],
rows: [
[
'STREAM',
'KSQL_PROCESSING_LOG',
'default_ksql_processing_log',
'KAFKA',
'JSON',
'false',
],
['STREAM', 'PAGEVIEWS', 'pageviews', 'KAFKA', 'AVRO', 'false'],
],
},
};

View file

@ -0,0 +1,35 @@
import { fetchKsqlDbTablesAction, resetExecutionResult } from 'redux/actions';
import reducer, { initialState } from 'redux/reducers/ksqlDb/reducer';
import { fetchKsqlDbTablesPayload } from './fixtures';
describe('KsqlDb reducer', () => {
it('returns the initial state', () => {
expect(reducer(undefined, fetchKsqlDbTablesAction.request())).toEqual(
initialState
);
});
it('Fetches tables and streams', () => {
const state = reducer(
undefined,
fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload)
);
expect(state.tables.length).toEqual(2);
expect(state.streams.length).toEqual(2);
expect(state).toMatchSnapshot();
});
it('Resets execution result', () => {
const state = reducer(
{
tables: [],
streams: [],
executionResult: {
message: 'No available data',
},
},
resetExecutionResult()
);
expect(state.executionResult).toEqual(null);
expect(state).toMatchSnapshot();
});
});

View file

@ -0,0 +1,40 @@
import configureStore from 'redux/store/configureStore';
import * as selectors from 'redux/reducers/ksqlDb/selectors';
import { fetchKsqlDbTablesAction } from 'redux/actions';
import { fetchKsqlDbTablesPayload } from './fixtures';
const store = configureStore();
describe('TopicMessages selectors', () => {
describe('Initial state', () => {
it('Returns empty state', () => {
expect(selectors.getKsqlDbTables(store.getState())).toEqual({
rows: [],
fetched: false,
fetching: true,
tablesCount: 0,
streamsCount: 0,
});
});
});
describe('State', () => {
beforeAll(() => {
store.dispatch(fetchKsqlDbTablesAction.success(fetchKsqlDbTablesPayload));
});
it('Returns tables and streams', () => {
expect(selectors.getKsqlDbTables(store.getState())).toEqual({
rows: [
...fetchKsqlDbTablesPayload.streams,
...fetchKsqlDbTablesPayload.tables,
],
fetched: true,
fetching: false,
tablesCount: 2,
streamsCount: 2,
});
});
});
});

View file

@ -0,0 +1,34 @@
import { Action } from 'redux/interfaces';
import { getType } from 'typesafe-actions';
import * as actions from 'redux/actions';
import { KsqlState } from 'redux/interfaces/ksqlDb';
export const initialState: KsqlState = {
streams: [],
tables: [],
executionResult: null,
};
const reducer = (state = initialState, action: Action): KsqlState => {
switch (action.type) {
case getType(actions.fetchKsqlDbTablesAction.success):
return {
...state,
...action.payload,
};
case getType(actions.executeKsqlAction.success):
return {
...state,
executionResult: action.payload,
};
case getType(actions.resetExecutionResult):
return {
...state,
executionResult: null,
};
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,32 @@
import { createSelector } from 'reselect';
import { RootState } from 'redux/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
import { KsqlState } from 'redux/interfaces/ksqlDb';
const ksqlDbState = ({ ksqlDb }: RootState): KsqlState => ksqlDb;
const getKsqlDbFetchTablesAndStreamsFetchingStatus = createFetchingSelector(
'GET_KSQL_DB_TABLES_AND_STREAMS'
);
const getKsqlExecutionStatus = createFetchingSelector('EXECUTE_KSQL');
export const getKsqlDbTables = createSelector(
[ksqlDbState, getKsqlDbFetchTablesAndStreamsFetchingStatus],
(state, status) => ({
rows: [...state.streams, ...state.tables],
fetched: status === 'fetched',
fetching: status === 'fetching' || status === 'notFetched',
tablesCount: state.tables.length,
streamsCount: state.streams.length,
})
);
export const getKsqlExecution = createSelector(
[ksqlDbState, getKsqlExecutionStatus],
(state, status) => ({
executionResult: state.executionResult,
fetched: status === 'fetched',
fetching: status === 'fetching',
})
);