Schema Registry Views (#195)

* Schema Registry index page https://github.com/provectus/kafka-ui/pull/183

* Schema Registry show page https://github.com/provectus/kafka-ui/pull/196

* Specs https://github.com/provectus/kafka-ui/pull/208

* New JsonViewer common component
This commit is contained in:
Oleg Shur 2021-02-24 15:05:05 +03:00 committed by GitHub
parent 2c95928607
commit 3bc9447cc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 2092 additions and 83 deletions

View file

@ -30,6 +30,8 @@ services:
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- 2181:2181
kafka0:
image: confluentinc/cp-kafka:5.1.0

View file

@ -2725,12 +2725,14 @@
"@types/node": {
"version": "12.20.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.2.tgz",
"integrity": "sha512-djoyN0pvTje9Lpu25ZwZwlLQICPiuv42omiydLhl7om+og1RhQboGmar12KaKls8soTUQ893TuWCrlyt8B1pVg=="
"integrity": "sha512-djoyN0pvTje9Lpu25ZwZwlLQICPiuv42omiydLhl7om+og1RhQboGmar12KaKls8soTUQ893TuWCrlyt8B1pVg==",
"dev": true
},
"@types/node-fetch": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz",
"integrity": "sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==",
"dev": true,
"requires": {
"@types/node": "*",
"form-data": "^3.0.0"
@ -2740,6 +2742,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@ -4214,7 +4217,8 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
},
"at-least-node": {
"version": "1.0.0",
@ -5693,6 +5697,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@ -6643,7 +6648,8 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
},
"delegates": {
"version": "1.0.0",
@ -9332,19 +9338,6 @@
"integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==",
"dev": true
},
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"requires": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
},
"hmac-drbg": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@ -12576,12 +12569,14 @@
"mime-db": {
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz",
"integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w=="
"integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==",
"dev": true
},
"mime-types": {
"version": "2.1.28",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz",
"integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==",
"dev": true,
"requires": {
"mime-db": "1.45.0"
}
@ -13570,9 +13565,9 @@
}
},
"open": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.1.tgz",
"integrity": "sha512-Pxv+fKRsd/Ozflgn2Gjev1HZveJJeKR6hKKmdaImJMuEZ6htAvCTbcMABJo+qevlAelTLCrEK3YTKZ9fVTcSPw==",
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"requires": {
"is-docker": "^2.0.0",
@ -15601,9 +15596,9 @@
}
},
"react-dev-utils": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.2.tgz",
"integrity": "sha512-xG7GlMoYkrgc2M1kDCHKRywXMDbFnjOB+/VzpytQyYBusEzR8NlGTMmUbvN86k94yyKu5XReHB8eZC2JZrNchQ==",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.3.tgz",
"integrity": "sha512-4lEA5gF4OHrcJLMUV1t+4XbNDiJbsAWCH5Z2uqlTqW6dD7Cf5nEASkeXrCI/Mz83sI2o527oBIFKVMXtRf1Vtg==",
"dev": true,
"requires": {
"@babel/code-frame": "7.10.4",
@ -15619,7 +15614,7 @@
"global-modules": "2.0.0",
"globby": "11.0.1",
"gzip-size": "5.1.1",
"immer": "7.0.9",
"immer": "8.0.1",
"is-root": "2.1.0",
"loader-utils": "2.0.0",
"open": "^7.0.2",
@ -15732,12 +15727,6 @@
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"dev": true
},
"immer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz",
"integrity": "sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A==",
"dev": true
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -15894,6 +15883,19 @@
"tiny-warning": "^1.0.0"
},
"dependencies": {
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"requires": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
},
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -15913,6 +15915,21 @@
"react-router": "5.2.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
},
"dependencies": {
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"requires": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
}
}
}
},
"react-scripts": {

View file

@ -4,7 +4,6 @@
"private": true,
"dependencies": {
"@hookform/error-message": "0.0.5",
"@types/node-fetch": "^2.5.8",
"bulma": "^0.9.2",
"bulma-switch": "^2.0.0",
"classnames": "^2.2.6",
@ -22,6 +21,7 @@
"react-json-tree": "^0.13.0",
"react-multi-select-component": "^2.0.14",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
@ -77,6 +77,7 @@
"@types/jest": "^26.0.20",
"@types/lodash": "^4.14.165",
"@types/node": "^12.20.2",
"@types/node-fetch": "^2.5.8",
"@types/react": "^17.0.2",
"@types/react-datepicker": "^3.1.1",
"@types/react-dom": "^17.0.1",

View file

@ -3,10 +3,11 @@ import { Switch, Route, Redirect } from 'react-router-dom';
import './App.scss';
import BrokersContainer from './Brokers/BrokersContainer';
import TopicsContainer from './Topics/TopicsContainer';
import NavConatiner from './Nav/NavConatiner';
import NavContainer from './Nav/NavContainer';
import PageLoader from './common/PageLoader/PageLoader';
import Dashboard from './Dashboard/Dashboard';
import ConsumersGroupsContainer from './ConsumerGroups/ConsumersGroupsContainer';
import SchemasContainer from './Schemas/SchemasContainer';
interface AppProps {
isClusterListFetched: boolean;
@ -35,7 +36,7 @@ const App: React.FC<AppProps> = ({
</div>
</nav>
<main className="Layout__container">
<NavConatiner className="Layout__navbar" />
<NavContainer className="Layout__navbar" />
{isClusterListFetched ? (
<Switch>
<Route
@ -55,6 +56,10 @@ const App: React.FC<AppProps> = ({
path="/ui/clusters/:clusterName/consumer-groups"
component={ConsumersGroupsContainer}
/>
<Route
path="/ui/clusters/:clusterName/schemas"
component={SchemasContainer}
/>
<Redirect
from="/ui/clusters/:clusterName"
to="/ui/clusters/:clusterName/brokers"

View file

@ -4,6 +4,7 @@ import {
clusterBrokersPath,
clusterTopicsPath,
clusterConsumerGroupsPath,
clusterSchemasPath,
} from 'lib/paths';
import { Cluster, ServerStatus } from 'generated-sources';
@ -85,6 +86,13 @@ const ClusterMenu: React.FC<Props> = ({ cluster }) => (
>
Consumers
</NavLink>
<NavLink
to={clusterSchemasPath(cluster.name)}
activeClassName="is-active"
title="Schema Registry"
>
Schema Registry
</NavLink>
</ul>
</li>
</ul>

View file

@ -0,0 +1,110 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import { ClusterName, SchemaName } from 'redux/interfaces';
import { clusterSchemasPath } from 'lib/paths';
import Breadcrumb from '../../common/Breadcrumb/Breadcrumb';
import SchemaVersion from './SchemaVersion';
import LatestVersionItem from './LatestVersionItem';
import PageLoader from '../../common/PageLoader/PageLoader';
export interface DetailsProps {
schema: SchemaSubject;
clusterName: ClusterName;
versions: SchemaSubject[];
isFetched: boolean;
fetchSchemaVersions: (
clusterName: ClusterName,
schemaName: SchemaName
) => void;
}
const Details: React.FC<DetailsProps> = ({
schema,
clusterName,
fetchSchemaVersions,
versions,
isFetched,
}) => {
React.useEffect(() => {
fetchSchemaVersions(clusterName, schema.subject as SchemaName);
}, [fetchSchemaVersions, clusterName]);
return (
<div className="section">
<div className="level">
<Breadcrumb
links={[
{
href: clusterSchemasPath(clusterName),
label: 'Schema Registry',
},
]}
>
{schema.subject}
</Breadcrumb>
</div>
<div className="box">
<div className="level">
<div className="level-left">
<div className="level-item">
<div className="mr-1">
<b>Latest Version</b>
</div>
<div className="tag is-info is-light" title="Version">
#{schema.version}
</div>
</div>
</div>
<div className="level-right">
<button
className="button is-primary is-small level-item"
type="button"
title="in development"
disabled
>
Create Schema
</button>
<button
className="button is-warning is-small level-item"
type="button"
title="in development"
disabled
>
Update Schema
</button>
<button
className="button is-danger is-small level-item"
type="button"
title="in development"
disabled
>
Delete
</button>
</div>
</div>
<LatestVersionItem schema={schema} />
</div>
{isFetched ? (
<div className="box">
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Version</th>
<th>ID</th>
<th>Schema</th>
</tr>
</thead>
<tbody>
{versions.map((version) => (
<SchemaVersion key={version.id} version={version} />
))}
</tbody>
</table>
</div>
) : (
<PageLoader />
)}
</div>
);
};
export default Details;

View file

@ -0,0 +1,39 @@
import { connect } from 'react-redux';
import { ClusterName, RootState } from 'redux/interfaces';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
getIsSchemaVersionFetched,
getSchema,
getSortedSchemaVersions,
} from 'redux/reducers/schemas/selectors';
import { fetchSchemaVersions } from 'redux/actions';
import Details from './Details';
interface RouteProps {
clusterName: ClusterName;
subject: string;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { clusterName, subject },
},
}: OwnProps
) => ({
schema: getSchema(state, subject),
versions: getSortedSchemaVersions(state),
isFetched: getIsSchemaVersionFetched(state),
clusterName,
});
const mapDispatchToProps = {
fetchSchemaVersions,
};
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Details)
);

View file

@ -0,0 +1,43 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import JSONViewer from 'components/common/JSONViewer/JSONViewer';
interface LatestVersionProps {
schema: SchemaSubject;
}
const LatestVersionItem: React.FC<LatestVersionProps> = ({
schema: { id, subject, schema, compatibilityLevel },
}) => {
return (
<div className="tile is-ancestor mt-1">
<div className="tile is-4 is-parent">
<div className="tile is-child">
<table className="table is-fullwidth">
<tbody>
<tr>
<td>ID</td>
<td>{id}</td>
</tr>
<tr>
<td>Subject</td>
<td>{subject}</td>
</tr>
<tr>
<td>Compatibility</td>
<td>{compatibilityLevel}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="tile is-parent">
<div className="tile is-child box py-1">
<JSONViewer data={JSON.parse(schema as string)} />
</div>
</div>
</div>
);
};
export default LatestVersionItem;

View file

@ -0,0 +1,23 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import JSONViewer from 'components/common/JSONViewer/JSONViewer';
interface SchemaVersionProps {
version: SchemaSubject;
}
const SchemaVersion: React.FC<SchemaVersionProps> = ({
version: { version, id, schema },
}) => {
return (
<tr>
<td>{version}</td>
<td>{id}</td>
<td className="py-0">
<JSONViewer data={JSON.parse(schema as string)} />
</td>
</tr>
);
};
export default SchemaVersion;

View file

@ -0,0 +1,108 @@
import React from 'react';
import { Provider } from 'react-redux';
import { shallow } from 'enzyme';
import configureStore from 'redux/store/configureStore';
import DetailsContainer from '../DetailsContainer';
import Details, { DetailsProps } from '../Details';
import { schema, versions } from './fixtures';
describe('Details', () => {
describe('Container', () => {
const store = configureStore();
it('renders view', () => {
const component = shallow(
<Provider store={store}>
<DetailsContainer />
</Provider>
);
expect(component.exists()).toBeTruthy();
});
});
describe('View', () => {
const setupWrapper = (props: Partial<DetailsProps> = {}) => (
<Details
schema={schema}
clusterName="Test cluster"
fetchSchemaVersions={jest.fn()}
isFetched
versions={[]}
{...props}
/>
);
describe('Initial state', () => {
let useEffect: jest.SpyInstance<
void,
[effect: React.EffectCallback, deps?: React.DependencyList | undefined]
>;
let wrapper;
const mockedFn = jest.fn();
const mockedUseEffect = () => {
useEffect.mockImplementationOnce(mockedFn);
};
beforeEach(() => {
useEffect = jest.spyOn(React, 'useEffect');
mockedUseEffect();
wrapper = shallow(setupWrapper({ fetchSchemaVersions: mockedFn }));
});
it('should call fetchSchemaVersions every render', () => {
expect(mockedFn).toHaveBeenCalled();
});
it('matches snapshot', () => {
expect(
shallow(setupWrapper({ fetchSchemaVersions: mockedFn }))
).toMatchSnapshot();
});
});
describe('when page with schema versions is loading', () => {
const wrapper = shallow(setupWrapper({ isFetched: false }));
it('renders PageLoader', () => {
expect(wrapper.exists('PageLoader')).toBeTruthy();
});
it('matches snapshot', () => {
expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
});
});
describe('when page with schema versions loaded', () => {
describe('when versions are empty', () => {
it('renders table heading without SchemaVersion', () => {
const wrapper = shallow(setupWrapper());
expect(wrapper.exists('LatestVersionItem')).toBeTruthy();
expect(wrapper.exists('button')).toBeTruthy();
expect(wrapper.exists('thead')).toBeTruthy();
expect(wrapper.exists('SchemaVersion')).toBeFalsy();
});
it('matches snapshot', () => {
expect(shallow(setupWrapper())).toMatchSnapshot();
});
});
describe('when schema has versions', () => {
const wrapper = shallow(setupWrapper({ versions }));
it('renders table heading with SchemaVersion', () => {
expect(wrapper.exists('LatestVersionItem')).toBeTruthy();
expect(wrapper.exists('button')).toBeTruthy();
expect(wrapper.exists('thead')).toBeTruthy();
expect(wrapper.find('SchemaVersion').length).toEqual(2);
});
it('matches snapshot', () => {
expect(shallow(setupWrapper({ versions }))).toMatchSnapshot();
});
});
});
});
});

View file

@ -0,0 +1,18 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { schema } from './fixtures';
import LatestVersionItem from '../LatestVersionItem';
describe('LatestVersionItem', () => {
it('renders latest version of schema', () => {
const wrapper = mount(<LatestVersionItem schema={schema} />);
expect(wrapper.find('table').length).toEqual(1);
expect(wrapper.find('td').at(1).text()).toEqual('1');
expect(wrapper.exists('JSONViewer')).toBeTruthy();
});
it('matches snapshot', () => {
expect(shallow(<LatestVersionItem schema={schema} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,17 @@
import React from 'react';
import { shallow } from 'enzyme';
import SchemaVersion from '../SchemaVersion';
import { versions } from './fixtures';
describe('SchemaVersion', () => {
it('renders versions', () => {
const wrapper = shallow(<SchemaVersion version={versions[0]} />);
expect(wrapper.find('td').length).toEqual(3);
expect(wrapper.exists('JSONViewer')).toBeTruthy();
});
it('matches snapshot', () => {
expect(shallow(<SchemaVersion version={versions[0]} />)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,461 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Details View Initial state matches snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/Test cluster/schemas",
"label": "Schema Registry",
},
]
}
>
test
</Breadcrumb>
</div>
<div
className="box"
>
<div
className="level"
>
<div
className="level-left"
>
<div
className="level-item"
>
<div
className="mr-1"
>
<b>
Latest Version
</b>
</div>
<div
className="tag is-info is-light"
title="Version"
>
#
1
</div>
</div>
</div>
<div
className="level-right"
>
<button
className="button is-primary is-small level-item"
disabled={true}
title="in development"
type="button"
>
Create Schema
</button>
<button
className="button is-warning is-small level-item"
disabled={true}
title="in development"
type="button"
>
Update Schema
</button>
<button
className="button is-danger is-small level-item"
disabled={true}
title="in development"
type="button"
>
Delete
</button>
</div>
</div>
<LatestVersionItem
schema={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
</div>
<div
className="box"
>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Version
</th>
<th>
ID
</th>
<th>
Schema
</th>
</tr>
</thead>
<tbody />
</table>
</div>
</div>
`;
exports[`Details View when page with schema versions is loading matches snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/Test cluster/schemas",
"label": "Schema Registry",
},
]
}
>
test
</Breadcrumb>
</div>
<div
className="box"
>
<div
className="level"
>
<div
className="level-left"
>
<div
className="level-item"
>
<div
className="mr-1"
>
<b>
Latest Version
</b>
</div>
<div
className="tag is-info is-light"
title="Version"
>
#
1
</div>
</div>
</div>
<div
className="level-right"
>
<button
className="button is-primary is-small level-item"
disabled={true}
title="in development"
type="button"
>
Create Schema
</button>
<button
className="button is-warning is-small level-item"
disabled={true}
title="in development"
type="button"
>
Update Schema
</button>
<button
className="button is-danger is-small level-item"
disabled={true}
title="in development"
type="button"
>
Delete
</button>
</div>
</div>
<LatestVersionItem
schema={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
</div>
<PageLoader />
</div>
`;
exports[`Details View when page with schema versions loaded when schema has versions matches snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/Test cluster/schemas",
"label": "Schema Registry",
},
]
}
>
test
</Breadcrumb>
</div>
<div
className="box"
>
<div
className="level"
>
<div
className="level-left"
>
<div
className="level-item"
>
<div
className="mr-1"
>
<b>
Latest Version
</b>
</div>
<div
className="tag is-info is-light"
title="Version"
>
#
1
</div>
</div>
</div>
<div
className="level-right"
>
<button
className="button is-primary is-small level-item"
disabled={true}
title="in development"
type="button"
>
Create Schema
</button>
<button
className="button is-warning is-small level-item"
disabled={true}
title="in development"
type="button"
>
Update Schema
</button>
<button
className="button is-danger is-small level-item"
disabled={true}
title="in development"
type="button"
>
Delete
</button>
</div>
</div>
<LatestVersionItem
schema={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
</div>
<div
className="box"
>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Version
</th>
<th>
ID
</th>
<th>
Schema
</th>
</tr>
</thead>
<tbody>
<SchemaVersion
key="1"
version={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
<SchemaVersion
key="2"
version={
Object {
"compatibilityLevel": "BACKWARD",
"id": 2,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "2",
}
}
/>
</tbody>
</table>
</div>
</div>
`;
exports[`Details View when page with schema versions loaded when versions are empty matches snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/Test cluster/schemas",
"label": "Schema Registry",
},
]
}
>
test
</Breadcrumb>
</div>
<div
className="box"
>
<div
className="level"
>
<div
className="level-left"
>
<div
className="level-item"
>
<div
className="mr-1"
>
<b>
Latest Version
</b>
</div>
<div
className="tag is-info is-light"
title="Version"
>
#
1
</div>
</div>
</div>
<div
className="level-right"
>
<button
className="button is-primary is-small level-item"
disabled={true}
title="in development"
type="button"
>
Create Schema
</button>
<button
className="button is-warning is-small level-item"
disabled={true}
title="in development"
type="button"
>
Update Schema
</button>
<button
className="button is-danger is-small level-item"
disabled={true}
title="in development"
type="button"
>
Delete
</button>
</div>
</div>
<LatestVersionItem
schema={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
</div>
<div
className="box"
>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Version
</th>
<th>
ID
</th>
<th>
Schema
</th>
</tr>
</thead>
<tbody />
</table>
</div>
</div>
`;

View file

@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LatestVersionItem matches snapshot 1`] = `
<div
className="tile is-ancestor mt-1"
>
<div
className="tile is-4 is-parent"
>
<div
className="tile is-child"
>
<table
className="table is-fullwidth"
>
<tbody>
<tr>
<td>
ID
</td>
<td>
1
</td>
</tr>
<tr>
<td>
Subject
</td>
<td>
test
</td>
</tr>
<tr>
<td>
Compatibility
</td>
<td>
BACKWARD
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
className="tile is-parent"
>
<div
className="tile is-child box py-1"
>
<JSONViewer
data={
Object {
"fields": Array [
Object {
"name": "id",
"type": "long",
},
],
"name": "MyRecord1",
"namespace": "com.mycompany",
"type": "record",
}
}
/>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SchemaVersion matches snapshot 1`] = `
<tr>
<td>
1
</td>
<td>
1
</td>
<td
className="py-0"
>
<JSONViewer
data={
Object {
"fields": Array [
Object {
"name": "id",
"type": "long",
},
],
"name": "MyRecord1",
"namespace": "com.mycompany",
"type": "record",
}
}
/>
</td>
</tr>
`;

View file

@ -0,0 +1,29 @@
import { SchemaSubject } from 'generated-sources';
export const schema: SchemaSubject = {
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
};
export const versions: SchemaSubject[] = [
{
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test',
version: '2',
id: 2,
schema:
'{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
];

View file

@ -0,0 +1,34 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import Breadcrumb from '../../common/Breadcrumb/Breadcrumb';
import ListItem from './ListItem';
export interface ListProps {
schemas: SchemaSubject[];
}
const List: React.FC<ListProps> = ({ schemas }) => {
return (
<div className="section">
<Breadcrumb>Schema Registry</Breadcrumb>
<div className="box">
<table className="table is-striped is-fullwidth">
<thead>
<tr>
<th>Schema Name</th>
<th>Version</th>
<th>Compatibility</th>
</tr>
</thead>
<tbody>
{schemas.map((subject) => (
<ListItem key={subject.id} subject={subject} />
))}
</tbody>
</table>
</div>
</div>
);
};
export default List;

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import { RootState } from 'redux/interfaces';
import { withRouter } from 'react-router-dom';
import { getSchemaList } from 'redux/reducers/schemas/selectors';
import List from './List';
const mapStateToProps = (state: RootState) => ({
schemas: getSchemaList(state),
});
export default withRouter(connect(mapStateToProps)(List));

View file

@ -0,0 +1,32 @@
import React from 'react';
import { SchemaSubject } from 'generated-sources';
import { NavLink } from 'react-router-dom';
export interface ListItemProps {
subject: SchemaSubject;
}
const ListItem: React.FC<ListItemProps> = ({
subject: { subject, version, compatibilityLevel },
}) => {
return (
<tr>
<td>
<NavLink
exact
to={`schemas/${subject}/latest`}
activeClassName="is-active"
className="title is-6"
>
{subject}
</NavLink>
</td>
<td>{version}</td>
<td>
<span className="tag is-link">{compatibilityLevel}</span>
</td>
</tr>
);
};
export default ListItem;

View file

@ -0,0 +1,56 @@
import React from 'react';
import { Provider } from 'react-redux';
import { shallow } from 'enzyme';
import configureStore from 'redux/store/configureStore';
import ListContainer from '../ListContainer';
import List, { ListProps } from '../List';
import { schemas } from './fixtures';
describe('List', () => {
describe('Container', () => {
const store = configureStore();
it('renders view', () => {
const component = shallow(
<Provider store={store}>
<ListContainer />
</Provider>
);
expect(component.exists()).toBeTruthy();
});
});
describe('View', () => {
const setupWrapper = (props: Partial<ListProps> = {}) => (
<List schemas={[]} {...props} />
);
describe('without schemas', () => {
it('renders table heading without ListItem', () => {
const wrapper = shallow(setupWrapper());
expect(wrapper.exists('Breadcrumb')).toBeTruthy();
expect(wrapper.exists('thead')).toBeTruthy();
expect(wrapper.exists('ListItem')).toBeFalsy();
});
it('matches snapshot', () => {
expect(shallow(setupWrapper())).toMatchSnapshot();
});
});
describe('with schemas', () => {
const wrapper = shallow(setupWrapper({ schemas }));
it('renders table heading with ListItem', () => {
expect(wrapper.exists('Breadcrumb')).toBeTruthy();
expect(wrapper.exists('thead')).toBeTruthy();
expect(wrapper.find('ListItem').length).toEqual(3);
});
it('matches snapshot', () => {
expect(shallow(setupWrapper({ schemas }))).toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,26 @@
import React from 'react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import { schemas } from './fixtures';
import ListItem from '../ListItem';
describe('ListItem', () => {
const wrapper = mount(
<Router>
<table>
<tbody>
<ListItem subject={schemas[0]} />
</tbody>
</table>
</Router>
);
it('renders schemas', () => {
expect(wrapper.find('NavLink').length).toEqual(1);
expect(wrapper.find('td').length).toEqual(3);
});
it('matches snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,102 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`List View with schemas matches snapshot 1`] = `
<div
className="section"
>
<Breadcrumb>
Schema Registry
</Breadcrumb>
<div
className="box"
>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Schema Name
</th>
<th>
Version
</th>
<th>
Compatibility
</th>
</tr>
</thead>
<tbody>
<ListItem
key="1"
subject={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
/>
<ListItem
key="1"
subject={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test2",
"version": "1",
}
}
/>
<ListItem
key="1"
subject={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord3\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test3",
"version": "1",
}
}
/>
</tbody>
</table>
</div>
</div>
`;
exports[`List View without schemas matches snapshot 1`] = `
<div
className="section"
>
<Breadcrumb>
Schema Registry
</Breadcrumb>
<div
className="box"
>
<table
className="table is-striped is-fullwidth"
>
<thead>
<tr>
<th>
Schema Name
</th>
<th>
Version
</th>
<th>
Compatibility
</th>
</tr>
</thead>
<tbody />
</table>
</div>
</div>
`;

View file

@ -0,0 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ListItem matches snapshot 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<table>
<tbody>
<ListItem
subject={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
}
}
>
<tr>
<td>
<NavLink
activeClassName="is-active"
className="title is-6"
exact={true}
to="schemas/test/latest"
>
<Link
aria-current={null}
className="title is-6"
to={
Object {
"hash": "",
"pathname": "/schemas/test/latest",
"search": "",
"state": null,
}
}
>
<LinkAnchor
aria-current={null}
className="title is-6"
href="/schemas/test/latest"
navigate={[Function]}
>
<a
aria-current={null}
className="title is-6"
href="/schemas/test/latest"
onClick={[Function]}
>
test
</a>
</LinkAnchor>
</Link>
</NavLink>
</td>
<td>
1
</td>
<td>
<span
className="tag is-link"
>
BACKWARD
</span>
</td>
</tr>
</ListItem>
</tbody>
</table>
</Router>
</BrowserRouter>
`;

View file

@ -0,0 +1,28 @@
import { SchemaSubject } from 'generated-sources';
export const schemas: SchemaSubject[] = [
{
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test2',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test3',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord3","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
];

View file

@ -0,0 +1,43 @@
import React from 'react';
import { ClusterName } from 'redux/interfaces';
import { Switch, Route } from 'react-router-dom';
import PageLoader from 'components/common/PageLoader/PageLoader';
import ListContainer from './List/ListContainer';
import DetailsContainer from './Details/DetailsContainer';
export interface SchemasProps {
isFetched: boolean;
clusterName: ClusterName;
fetchSchemasByClusterName: (clusterName: ClusterName) => void;
}
const Schemas: React.FC<SchemasProps> = ({
isFetched,
fetchSchemasByClusterName,
clusterName,
}) => {
React.useEffect(() => {
fetchSchemasByClusterName(clusterName);
}, [fetchSchemasByClusterName, clusterName]);
if (isFetched) {
return (
<Switch>
<Route
exact
path="/ui/clusters/:clusterName/schemas"
component={ListContainer}
/>
<Route
exact
path="/ui/clusters/:clusterName/schemas/:subject/latest"
component={DetailsContainer}
/>
</Switch>
);
}
return <PageLoader />;
};
export default Schemas;

View file

@ -0,0 +1,32 @@
import { connect } from 'react-redux';
import { ClusterName, RootState } from 'redux/interfaces';
import { fetchSchemasByClusterName } from 'redux/actions';
import { getIsSchemaListFetched } from 'redux/reducers/schemas/selectors';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Schemas from './Schemas';
interface RouteProps {
clusterName: ClusterName;
}
type OwnProps = RouteComponentProps<RouteProps>;
const mapStateToProps = (
state: RootState,
{
match: {
params: { clusterName },
},
}: OwnProps
) => ({
isFetched: getIsSchemaListFetched(state),
clusterName,
});
const mapDispatchToProps = {
fetchSchemasByClusterName,
};
export default withRouter(
connect(mapStateToProps, mapDispatchToProps)(Schemas)
);

View file

@ -0,0 +1,86 @@
import React from 'react';
import { Provider } from 'react-redux';
import { shallow } from 'enzyme';
import configureStore from 'redux/store/configureStore';
import { StaticRouter } from 'react-router-dom';
import { match } from 'react-router';
import { ClusterName } from 'redux/interfaces';
import Schemas, { SchemasProps } from '../Schemas';
import SchemasContainer from '../SchemasContainer';
describe('Schemas', () => {
const pathname = `/ui/clusters/clusterName/schemas`;
describe('Container', () => {
const store = configureStore();
it('renders view', () => {
const component = shallow(
<Provider store={store}>
<StaticRouter location={{ pathname }} context={{}}>
<SchemasContainer />
</StaticRouter>
</Provider>
);
expect(component.exists()).toBeTruthy();
});
describe('View', () => {
const setupWrapper = (props: Partial<SchemasProps> = {}) => (
<Schemas
isFetched
clusterName="Test"
fetchSchemasByClusterName={jest.fn()}
{...props}
/>
);
describe('Initial state', () => {
let useEffect: jest.SpyInstance<
void,
[
effect: React.EffectCallback,
deps?: React.DependencyList | undefined
]
>;
let wrapper;
const mockedFn = jest.fn();
const mockedUseEffect = () => {
useEffect.mockImplementationOnce(mockedFn);
};
beforeEach(() => {
useEffect = jest.spyOn(React, 'useEffect');
mockedUseEffect();
wrapper = shallow(
setupWrapper({ fetchSchemasByClusterName: mockedFn })
);
});
it('should call fetchSchemasByClusterName every render', () => {
expect(mockedFn).toHaveBeenCalled();
});
it('matches snapshot', () => {
expect(
shallow(setupWrapper({ fetchSchemasByClusterName: mockedFn }))
).toMatchSnapshot();
});
});
describe('when page is loading', () => {
const wrapper = shallow(setupWrapper({ isFetched: false }));
it('renders PageLoader', () => {
expect(wrapper.exists('PageLoader')).toBeTruthy();
});
it('matches snapshot', () => {
expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot();
});
});
});
});
});

View file

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Schemas Container View Initial state matches snapshot 1`] = `
<Switch>
<Route
component={[Function]}
exact={true}
path="/ui/clusters/:clusterName/schemas"
/>
<Route
component={[Function]}
exact={true}
path="/ui/clusters/:clusterName/schemas/:subject/latest"
/>
</Switch>
`;
exports[`Schemas Container View when page is loading matches snapshot 1`] = `<PageLoader />`;

View file

@ -1,7 +1,7 @@
import React from 'react';
import { format } from 'date-fns';
import JSONTree from 'react-json-tree';
import { TopicMessage } from 'generated-sources';
import JSONViewer from 'components/common/JSONViewer/JSONViewer';
export interface MessageItemProp {
partition: TopicMessage['partition'];
@ -21,28 +21,7 @@ const MessageItem: React.FC<MessageItemProp> = ({
<td style={{ width: 150 }}>{offset}</td>
<td style={{ width: 100 }}>{partition}</td>
<td style={{ wordBreak: 'break-word' }}>
{content && (
<JSONTree
data={content}
hideRoot
invertTheme={false}
theme={{
tree: ({ style }) => ({
style: {
...style,
backgroundColor: undefined,
marginLeft: 0,
marginTop: 0,
},
}),
value: ({ style }) => ({
style: { ...style, marginLeft: 0 },
}),
base0D: '#3273dc',
base0B: '#363636',
}}
/>
)}
{content && <JSONViewer data={content as { [key: string]: string }} />}
</td>
</tr>
);

View file

@ -32,7 +32,7 @@ const MessagesTable: React.FC<MessagesTableProp> = ({ messages, onNext }) => {
partition={partition}
offset={offset}
timestamp={timestamp}
content={content as Record<string, unknown>}
content={content as { [key: string]: string }}
/>
)
)}

View file

@ -14,7 +14,7 @@ describe('MessageItem', () => {
expect(wrapper.find('tr').length).toEqual(1);
expect(wrapper.find('td').length).toEqual(4);
expect(wrapper.find('JSONTree').length).toEqual(1);
expect(wrapper.find('JSONViewer').length).toEqual(1);
});
it('matches snapshot', () => {
@ -28,7 +28,7 @@ describe('MessageItem', () => {
expect(wrapper.find('tr').length).toEqual(1);
expect(wrapper.find('td').length).toEqual(4);
expect(wrapper.find('JSONTree').length).toEqual(0);
expect(wrapper.find('JSONViewer').length).toEqual(0);
});
it('matches snapshot', () => {

View file

@ -36,35 +36,13 @@ exports[`MessageItem when content is defined matches snapshot 1`] = `
}
}
>
<JSONTree
collectionLimit={50}
<JSONViewer
data={
Object {
"foo": "bar",
"key": "val",
}
}
getItemString={[Function]}
hideRoot={true}
invertTheme={false}
isCustomNode={[Function]}
keyPath={
Array [
"root",
]
}
labelRenderer={[Function]}
postprocessValue={[Function]}
shouldExpandNode={[Function]}
theme={
Object {
"base0B": "#363636",
"base0D": "#3273dc",
"tree": [Function],
"value": [Function],
}
}
valueRenderer={[Function]}
/>
</td>
</tr>

View file

@ -0,0 +1,15 @@
import React from 'react';
import JSONTree from 'react-json-tree';
import theme from './themes/google';
interface JSONViewerProps {
data: {
[key: string]: string;
};
}
const JSONViewer: React.FC<JSONViewerProps> = ({ data }) => (
<JSONTree data={data} theme={theme} shouldExpandNode={() => true} hideRoot />
);
export default JSONViewer;

View file

@ -0,0 +1,20 @@
export default {
scheme: 'google',
author: 'seth wright (http://sethawright.com)',
base00: '#1d1f21',
base01: '#282a2e',
base02: '#373b41',
base03: '#969896',
base04: '#b4b7b4',
base05: '#c5c8c6',
base06: '#e0e0e0',
base07: '#ffffff',
base08: '#CC342B',
base09: '#F96A38',
base0A: '#FBA922',
base0B: '#198844',
base0C: '#3971ED',
base0D: '#3971ED',
base0E: '#A36AC7',
base0F: '#3971ED',
};

View file

@ -10,6 +10,8 @@ export const clusterTopicNewPath = (clusterName: ClusterName) =>
`${clusterPath(clusterName)}/topics/new`;
export const clusterConsumerGroupsPath = (clusterName: ClusterName) =>
`${clusterPath(clusterName)}/consumer-groups`;
export const clusterSchemasPath = (clusterName: ClusterName) =>
`${clusterPath(clusterName)}/schemas`;
export const clusterTopicPath = (
clusterName: ClusterName,

View file

@ -1,3 +1,7 @@
import {
clusterSchemasPayload,
schemaVersionsPayload,
} from 'redux/reducers/schemas/__test__/fixtures';
import * as actions from '../actions';
describe('Actions', () => {
@ -25,4 +29,50 @@ describe('Actions', () => {
});
});
});
describe('fetchSchemasByClusterNameAction', () => {
it('creates a REQUEST action', () => {
expect(actions.fetchSchemasByClusterNameAction.request()).toEqual({
type: 'GET_CLUSTER_SCHEMAS__REQUEST',
});
});
it('creates a SUCCESS action', () => {
expect(
actions.fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
).toEqual({
type: 'GET_CLUSTER_SCHEMAS__SUCCESS',
payload: clusterSchemasPayload,
});
});
it('creates a FAILURE action', () => {
expect(actions.fetchSchemasByClusterNameAction.failure()).toEqual({
type: 'GET_CLUSTER_SCHEMAS__FAILURE',
});
});
});
describe('fetchSchemaVersionsAction', () => {
it('creates a REQUEST action', () => {
expect(actions.fetchSchemaVersionsAction.request()).toEqual({
type: 'GET_SCHEMA_VERSIONS__REQUEST',
});
});
it('creates a SUCCESS action', () => {
expect(
actions.fetchSchemaVersionsAction.success(schemaVersionsPayload)
).toEqual({
type: 'GET_SCHEMA_VERSIONS__SUCCESS',
payload: schemaVersionsPayload,
});
});
it('creates a FAILURE action', () => {
expect(actions.fetchSchemaVersionsAction.failure()).toEqual({
type: 'GET_SCHEMA_VERSIONS__FAILURE',
});
});
});
});

View file

@ -8,6 +8,7 @@ import { Middleware } from 'redux';
import { RootState, Action } from 'redux/interfaces';
import * as actions from 'redux/actions/actions';
import * as thunks from 'redux/actions/thunks';
import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
import * as fixtures from './fixtures';
const middlewares: Array<Middleware> = [thunk];
@ -21,6 +22,7 @@ const mockStoreCreator: MockStoreCreator<
const store: MockStoreEnhanced<RootState, DispatchExts> = mockStoreCreator();
const clusterName = 'local';
const subject = 'test';
describe('Thunks', () => {
afterEach(() => {
@ -49,4 +51,58 @@ describe('Thunks', () => {
]);
});
});
describe('fetchSchemasByClusterName', () => {
it('creates GET_CLUSTER_SCHEMAS__SUCCESS when fetching cluster schemas', async () => {
fetchMock.getOnce(`/api/clusters/${clusterName}/schemas`, {
body: schemaFixtures.clusterSchemasPayload,
});
await store.dispatch(thunks.fetchSchemasByClusterName(clusterName));
expect(store.getActions()).toEqual([
actions.fetchSchemasByClusterNameAction.request(),
actions.fetchSchemasByClusterNameAction.success(
schemaFixtures.clusterSchemasPayload
),
]);
});
it('creates GET_CLUSTER_SCHEMAS__FAILURE when fetching cluster schemas', async () => {
fetchMock.getOnce(`/api/clusters/${clusterName}/schemas`, 404);
await store.dispatch(thunks.fetchSchemasByClusterName(clusterName));
expect(store.getActions()).toEqual([
actions.fetchSchemasByClusterNameAction.request(),
actions.fetchSchemasByClusterNameAction.failure(),
]);
});
});
describe('fetchSchemaVersions', () => {
it('creates GET_SCHEMA_VERSIONS__SUCCESS when fetching schema versions', async () => {
fetchMock.getOnce(
`/api/clusters/${clusterName}/schemas/${subject}/versions`,
{
body: schemaFixtures.schemaVersionsPayload,
}
);
await store.dispatch(thunks.fetchSchemaVersions(clusterName, subject));
expect(store.getActions()).toEqual([
actions.fetchSchemaVersionsAction.request(),
actions.fetchSchemaVersionsAction.success(
schemaFixtures.schemaVersionsPayload
),
]);
});
it('creates GET_SCHEMA_VERSIONS__FAILURE when fetching schema versions', async () => {
fetchMock.getOnce(
`/api/clusters/${clusterName}/schemas/${subject}/versions`,
404
);
await store.dispatch(thunks.fetchSchemaVersions(clusterName, subject));
expect(store.getActions()).toEqual([
actions.fetchSchemaVersionsAction.request(),
actions.fetchSchemaVersionsAction.failure(),
]);
});
});
});

View file

@ -13,6 +13,7 @@ import {
TopicMessage,
ConsumerGroup,
ConsumerGroupDetails,
SchemaSubject,
} from 'generated-sources';
export const fetchClusterStatsAction = createAsyncAction(
@ -96,3 +97,15 @@ export const fetchConsumerGroupDetailsAction = createAsyncAction(
{ consumerGroupID: ConsumerGroupID; details: ConsumerGroupDetails },
undefined
>();
export const fetchSchemasByClusterNameAction = createAsyncAction(
'GET_CLUSTER_SCHEMAS__REQUEST',
'GET_CLUSTER_SCHEMAS__SUCCESS',
'GET_CLUSTER_SCHEMAS__FAILURE'
)<undefined, SchemaSubject[], undefined>();
export const fetchSchemaVersionsAction = createAsyncAction(
'GET_SCHEMA_VERSIONS__REQUEST',
'GET_SCHEMA_VERSIONS__SUCCESS',
'GET_SCHEMA_VERSIONS__FAILURE'
)<undefined, SchemaSubject[], undefined>();

View file

@ -15,13 +15,14 @@ import {
TopicMessageQueryParams,
TopicFormFormattedParams,
TopicFormDataRaw,
SchemaName,
} from 'redux/interfaces';
import { BASE_PARAMS } from 'lib/constants';
import * as actions from './actions';
const apiClientConf = new Configuration(BASE_PARAMS);
const apiClient = new ApiClustersApi(apiClientConf);
export const apiClient = new ApiClustersApi(apiClientConf);
export const fetchClustersList = (): PromiseThunkResult => async (dispatch) => {
dispatch(actions.fetchClusterListAction.request());
@ -250,3 +251,32 @@ export const fetchConsumerGroupDetails = (
dispatch(actions.fetchConsumerGroupDetailsAction.failure());
}
};
export const fetchSchemasByClusterName = (
clusterName: ClusterName
): PromiseThunkResult<void> => async (dispatch) => {
dispatch(actions.fetchSchemasByClusterNameAction.request());
try {
const schemas = await apiClient.getSchemas({ clusterName });
dispatch(actions.fetchSchemasByClusterNameAction.success(schemas));
} catch (e) {
dispatch(actions.fetchSchemasByClusterNameAction.failure());
}
};
export const fetchSchemaVersions = (
clusterName: ClusterName,
subject: SchemaName
): PromiseThunkResult<void> => async (dispatch) => {
if (!subject) return;
dispatch(actions.fetchSchemaVersionsAction.request());
try {
const versions = await apiClient.getAllVersionsBySubject({
clusterName,
subject,
});
dispatch(actions.fetchSchemaVersionsAction.success(versions));
} catch (e) {
dispatch(actions.fetchSchemaVersionsAction.failure());
}
};

View file

@ -8,11 +8,13 @@ import { ClusterState } from './cluster';
import { BrokersState } from './broker';
import { LoaderState } from './loader';
import { ConsumerGroupsState } from './consumerGroup';
import { SchemasState } from './schema';
export * from './topic';
export * from './cluster';
export * from './broker';
export * from './consumerGroup';
export * from './schema';
export * from './loader';
export interface RootState {
@ -20,6 +22,7 @@ export interface RootState {
clusters: ClusterState;
brokers: BrokersState;
consumerGroups: ConsumerGroupsState;
schemas: SchemasState;
loader: LoaderState;
}

View file

@ -0,0 +1,9 @@
import { SchemaSubject } from 'generated-sources';
export type SchemaName = string;
export interface SchemasState {
byName: { [subject: string]: SchemaSubject };
allNames: SchemaName[];
currentSchemaVersions: SchemaSubject[];
}

View file

@ -4,6 +4,7 @@ import topics from './topics/reducer';
import clusters from './clusters/reducer';
import brokers from './brokers/reducer';
import consumerGroups from './consumerGroups/reducer';
import schemas from './schemas/reducer';
import loader from './loader/reducer';
export default combineReducers<RootState>({
@ -11,5 +12,6 @@ export default combineReducers<RootState>({
clusters,
brokers,
consumerGroups,
schemas,
loader,
});

View file

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Schemas reducer reacts on GET_CLUSTER_SCHEMAS__SUCCESS and returns payload 1`] = `
Object {
"allNames": Array [
"test2",
"test3",
"test",
],
"byName": Object {
"test": Object {
"compatibilityLevel": "BACKWARD",
"id": 2,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "2",
},
"test2": Object {
"compatibilityLevel": "BACKWARD",
"id": 4,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord4\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test2",
"version": "3",
},
"test3": Object {
"compatibilityLevel": "BACKWARD",
"id": 5,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test3",
"version": "1",
},
},
"currentSchemaVersions": Array [],
}
`;
exports[`Schemas reducer reacts on GET_SCHEMA_VERSIONS__SUCCESS and returns payload 1`] = `
Object {
"allNames": Array [],
"byName": Object {},
"currentSchemaVersions": Array [
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "1",
},
Object {
"compatibilityLevel": "BACKWARD",
"id": 2,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord2\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"subject": "test",
"version": "2",
},
],
}
`;

View file

@ -0,0 +1,54 @@
import { SchemasState } from 'redux/interfaces';
import { SchemaSubject } from 'generated-sources';
export const initialState: SchemasState = {
byName: {},
allNames: [],
currentSchemaVersions: [],
};
export const clusterSchemasPayload: SchemaSubject[] = [
{
subject: 'test2',
version: '3',
id: 4,
schema:
'{"type":"record","name":"MyRecord4","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test3',
version: '1',
id: 5,
schema:
'{"type":"record","name":"MyRecord","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test',
version: '2',
id: 2,
schema:
'{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
];
export const schemaVersionsPayload: SchemaSubject[] = [
{
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
{
subject: 'test',
version: '2',
id: 2,
schema:
'{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
},
];

View file

@ -0,0 +1,39 @@
import {
fetchSchemasByClusterNameAction,
fetchSchemaVersionsAction,
} from 'redux/actions';
import reducer from 'redux/reducers/schemas/reducer';
import {
clusterSchemasPayload,
initialState,
schemaVersionsPayload,
} from './fixtures';
describe('Schemas reducer', () => {
it('returns the initial state', () => {
expect(
reducer(undefined, fetchSchemasByClusterNameAction.request())
).toEqual(initialState);
expect(reducer(undefined, fetchSchemaVersionsAction.request())).toEqual(
initialState
);
});
it('reacts on GET_CLUSTER_SCHEMAS__SUCCESS and returns payload', () => {
expect(
reducer(
undefined,
fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
)
).toMatchSnapshot();
});
it('reacts on GET_SCHEMA_VERSIONS__SUCCESS and returns payload', () => {
expect(
reducer(
undefined,
fetchSchemaVersionsAction.success(schemaVersionsPayload)
)
).toMatchSnapshot();
});
});

View file

@ -0,0 +1,64 @@
import {
fetchSchemasByClusterNameAction,
fetchSchemaVersionsAction,
} from 'redux/actions';
import configureStore from 'redux/store/configureStore';
import * as selectors from '../selectors';
import { clusterSchemasPayload, schemaVersionsPayload } from './fixtures';
const store = configureStore();
describe('Schemas selectors', () => {
describe('Initial state', () => {
it('returns fetch status', () => {
expect(selectors.getIsSchemaListFetched(store.getState())).toBeFalsy();
expect(selectors.getIsSchemaVersionFetched(store.getState())).toBeFalsy();
});
it('returns schema list', () => {
expect(selectors.getSchemaList(store.getState())).toEqual([]);
});
it('returns undefined schema', () => {
expect(selectors.getSchema(store.getState(), ' ')).toBeUndefined();
});
it('returns sorted versions of schema', () => {
expect(selectors.getSortedSchemaVersions(store.getState())).toEqual([]);
});
});
describe('state', () => {
beforeAll(() => {
store.dispatch(
fetchSchemasByClusterNameAction.success(clusterSchemasPayload)
);
store.dispatch(fetchSchemaVersionsAction.success(schemaVersionsPayload));
});
it('returns fetch status', () => {
expect(selectors.getIsSchemaListFetched(store.getState())).toBeTruthy();
expect(
selectors.getIsSchemaVersionFetched(store.getState())
).toBeTruthy();
});
it('returns schema list', () => {
expect(selectors.getSchemaList(store.getState())).toEqual(
clusterSchemasPayload
);
});
it('returns schema', () => {
expect(selectors.getSchema(store.getState(), 'test2')).toEqual(
clusterSchemasPayload[0]
);
});
it('returns sorted versions of schema', () => {
expect(selectors.getSortedSchemaVersions(store.getState())).toEqual(
schemaVersionsPayload
);
});
});
});

View file

@ -0,0 +1,46 @@
import { SchemaSubject } from 'generated-sources';
import { Action, SchemasState } from 'redux/interfaces';
export const initialState: SchemasState = {
byName: {},
allNames: [],
currentSchemaVersions: [],
};
const updateSchemaList = (
state: SchemasState,
payload: SchemaSubject[]
): SchemasState => {
const initialMemo: SchemasState = {
...state,
allNames: [],
};
return payload.reduce((memo: SchemasState, schema) => {
if (!schema.subject) return memo;
return {
...memo,
byName: {
...memo.byName,
[schema.subject]: {
...memo.byName[schema.subject],
...schema,
},
},
allNames: [...memo.allNames, schema.subject],
};
}, initialMemo);
};
const reducer = (state = initialState, action: Action): SchemasState => {
switch (action.type) {
case 'GET_CLUSTER_SCHEMAS__SUCCESS':
return updateSchemaList(state, action.payload);
case 'GET_SCHEMA_VERSIONS__SUCCESS':
return { ...state, currentSchemaVersions: action.payload };
default:
return state;
}
};
export default reducer;

View file

@ -0,0 +1,48 @@
import { createSelector } from 'reselect';
import { RootState, SchemasState } from 'redux/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const schemasState = ({ schemas }: RootState): SchemasState => schemas;
const getAllNames = (state: RootState) => schemasState(state).allNames;
const getSchemaMap = (state: RootState) => schemasState(state).byName;
const getSchemaListFetchingStatus = createFetchingSelector(
'GET_CLUSTER_SCHEMAS'
);
const getSchemaVersionsFetchingStatus = createFetchingSelector(
'GET_SCHEMA_VERSIONS'
);
export const getIsSchemaListFetched = createSelector(
getSchemaListFetchingStatus,
(status) => status === 'fetched'
);
export const getIsSchemaVersionFetched = createSelector(
getSchemaVersionsFetchingStatus,
(status) => status === 'fetched'
);
export const getSchemaList = createSelector(
getIsSchemaListFetched,
getAllNames,
getSchemaMap,
(isFetched, allNames, byName) =>
isFetched ? allNames.map((subject) => byName[subject]) : []
);
const getSchemaName = (_: RootState, subject: string) => subject;
export const getSchema = createSelector(
getSchemaMap,
getSchemaName,
(schemas, subject) => schemas[subject]
);
export const getSortedSchemaVersions = createSelector(
schemasState,
({ currentSchemaVersions }) =>
currentSchemaVersions.sort((a, b) => a.id - b.id)
);