Schema registry views (#317)

* Add schema type select to the form

* Implement adding new version of a schema

* Fix some issues & Increase test coverage

* Add more tests

* Add compatibility level update

* Abstract updating schema into a separate thunk & test it

* Remove warnings and skipped test

* Update failed tests

* Update failing tests

* Update markup

* Make the JSONEditor a part of the form

* Fix linting problem

* Fix errors
This commit is contained in:
Alexander Krivonosov 2021-04-22 16:26:02 +03:00 committed by GitHub
parent 083e3f7de0
commit caec4eb170
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1066 additions and 57 deletions

View file

@ -3248,6 +3248,11 @@
"negotiator": "0.6.2" "negotiator": "0.6.2"
} }
}, },
"ace-builds": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.12.tgz",
"integrity": "sha512-G+chJctFPiiLGvs3+/Mly3apXTcfgE45dT5yp12BcWZ1kUs+gm0qd3/fv4gsz6fVag4mM0moHVpjHDIgph6Psg=="
},
"acorn": { "acorn": {
"version": "7.4.1", "version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@ -6375,6 +6380,11 @@
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true "dev": true
}, },
"diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="
},
"diff-sequences": { "diff-sequences": {
"version": "26.6.2", "version": "26.6.2",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz",
@ -11979,11 +11989,15 @@
"integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
"dev": true "dev": true
}, },
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
},
"lodash.isequal": { "lodash.isequal": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
"dev": true
}, },
"lodash.isplainobject": { "lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
@ -15208,6 +15222,18 @@
"object-assign": "^4.1.1" "object-assign": "^4.1.1"
} }
}, },
"react-ace": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-9.3.0.tgz",
"integrity": "sha512-RWPDwVobLvyD0wDoHHQqEnn9pNQBhMnmo6LmRACkaXxAg3UQZpse6x9JFLC5EXyWby+P3uolIlQPct4NFEBPNg==",
"requires": {
"ace-builds": "^1.4.6",
"diff-match-patch": "^1.0.4",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"prop-types": "^15.7.2"
}
},
"react-app-polyfill": { "react-app-polyfill": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-2.0.0.tgz", "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-2.0.0.tgz",

View file

@ -5,6 +5,7 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3", "@fortawesome/fontawesome-free": "^5.15.3",
"@hookform/error-message": "0.0.5", "@hookform/error-message": "0.0.5",
"ace-builds": "^1.4.12",
"@rooks/use-outside-click-ref": "^4.10.1", "@rooks/use-outside-click-ref": "^4.10.1",
"bulma": "^0.9.2", "bulma": "^0.9.2",
"bulma-switch": "^2.0.0", "bulma-switch": "^2.0.0",
@ -16,6 +17,7 @@
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"pretty-ms": "^7.0.1", "pretty-ms": "^7.0.1",
"react": "^17.0.1", "react": "^17.0.1",
"react-ace": "^9.3.0",
"react-datepicker": "^3.7.0", "react-datepicker": "^3.7.0",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-hook-form": "^6.15.5", "react-hook-form": "^6.15.5",

View file

@ -2,9 +2,10 @@ import React from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { SchemaSubject } from 'generated-sources'; import { SchemaSubject } from 'generated-sources';
import { ClusterName, SchemaName } from 'redux/interfaces'; import { ClusterName, SchemaName } from 'redux/interfaces';
import { clusterSchemasPath } from 'lib/paths'; import { clusterSchemasPath, clusterSchemaSchemaEditPath } from 'lib/paths';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import ClusterContext from 'components/contexts/ClusterContext'; import ClusterContext from 'components/contexts/ClusterContext';
import { Link } from 'react-router-dom';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal'; import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
@ -16,11 +17,13 @@ export interface DetailsProps {
schema: SchemaSubject; schema: SchemaSubject;
clusterName: ClusterName; clusterName: ClusterName;
versions: SchemaSubject[]; versions: SchemaSubject[];
isFetched: boolean; areVersionsFetched: boolean;
areSchemasFetched: boolean;
fetchSchemaVersions: ( fetchSchemaVersions: (
clusterName: ClusterName, clusterName: ClusterName,
schemaName: SchemaName schemaName: SchemaName
) => void; ) => void;
fetchSchemasByClusterName: (clusterName: ClusterName) => void;
deleteSchema: (clusterName: ClusterName, subject: string) => Promise<void>; deleteSchema: (clusterName: ClusterName, subject: string) => Promise<void>;
} }
@ -29,9 +32,11 @@ const Details: React.FC<DetailsProps> = ({
schema, schema,
clusterName, clusterName,
fetchSchemaVersions, fetchSchemaVersions,
fetchSchemasByClusterName,
deleteSchema, deleteSchema,
versions, versions,
isFetched, areVersionsFetched,
areSchemasFetched,
}) => { }) => {
const { isReadOnly } = React.useContext(ClusterContext); const { isReadOnly } = React.useContext(ClusterContext);
const [ const [
@ -40,8 +45,9 @@ const Details: React.FC<DetailsProps> = ({
] = React.useState(false); ] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
fetchSchemasByClusterName(clusterName);
fetchSchemaVersions(clusterName, subject); fetchSchemaVersions(clusterName, subject);
}, [fetchSchemaVersions, clusterName]); }, [fetchSchemaVersions, fetchSchemasByClusterName, clusterName]);
const history = useHistory(); const history = useHistory();
const onDelete = React.useCallback(() => { const onDelete = React.useCallback(() => {
@ -63,7 +69,7 @@ const Details: React.FC<DetailsProps> = ({
{subject} {subject}
</Breadcrumb> </Breadcrumb>
</div> </div>
{isFetched ? ( {areVersionsFetched && areSchemasFetched ? (
<> <>
<div className="box"> <div className="box">
<div className="level"> <div className="level">
@ -78,19 +84,17 @@ const Details: React.FC<DetailsProps> = ({
</div> </div>
</div> </div>
{!isReadOnly && ( {!isReadOnly && (
<div className="level-right"> <div className="level-right buttons">
<button <Link
className="button is-warning is-small level-item" className="button is-warning"
type="button" type="button"
title="in development" to={clusterSchemaSchemaEditPath(clusterName, subject)}
disabled
> >
Update Schema Update Schema
</button> </Link>
<button <button
className="button is-danger is-small level-item" className="button is-danger"
type="button" type="button"
title="in development"
onClick={() => setDeleteSchemaConfirmationVisible(true)} onClick={() => setDeleteSchemaConfirmationVisible(true)}
> >
Remove Remove

View file

@ -5,8 +5,13 @@ import {
getIsSchemaVersionFetched, getIsSchemaVersionFetched,
getSchema, getSchema,
getSortedSchemaVersions, getSortedSchemaVersions,
getIsSchemaListFetched,
} from 'redux/reducers/schemas/selectors'; } from 'redux/reducers/schemas/selectors';
import { fetchSchemaVersions, deleteSchema } from 'redux/actions'; import {
fetchSchemaVersions,
deleteSchema,
fetchSchemasByClusterName,
} from 'redux/actions';
import Details from './Details'; import Details from './Details';
@ -28,12 +33,14 @@ const mapStateToProps = (
subject, subject,
schema: getSchema(state, subject), schema: getSchema(state, subject),
versions: getSortedSchemaVersions(state), versions: getSortedSchemaVersions(state),
isFetched: getIsSchemaVersionFetched(state), areVersionsFetched: getIsSchemaVersionFetched(state),
areSchemasFetched: getIsSchemaListFetched(state),
clusterName, clusterName,
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
fetchSchemaVersions, fetchSchemaVersions,
fetchSchemasByClusterName,
deleteSchema, deleteSchema,
}; };

View file

@ -42,7 +42,9 @@ describe('Details', () => {
clusterName={clusterName} clusterName={clusterName}
fetchSchemaVersions={fetchSchemaVersionsMock} fetchSchemaVersions={fetchSchemaVersionsMock}
deleteSchema={jest.fn()} deleteSchema={jest.fn()}
isFetched fetchSchemasByClusterName={jest.fn()}
areSchemasFetched
areVersionsFetched
versions={[]} versions={[]}
{...props} {...props}
/> />
@ -71,14 +73,16 @@ describe('Details', () => {
}); });
describe('when page with schema versions is loading', () => { describe('when page with schema versions is loading', () => {
const wrapper = shallow(setupWrapper({ isFetched: false })); const wrapper = shallow(setupWrapper({ areVersionsFetched: false }));
it('renders PageLoader', () => { it('renders PageLoader', () => {
expect(wrapper.exists('PageLoader')).toBeTruthy(); expect(wrapper.exists('PageLoader')).toBeTruthy();
}); });
it('matches snapshot', () => { it('matches snapshot', () => {
expect(shallow(setupWrapper({ isFetched: false }))).toMatchSnapshot(); expect(
shallow(setupWrapper({ areVersionsFetched: false }))
).toMatchSnapshot();
}); });
}); });
@ -129,7 +133,7 @@ describe('Details', () => {
it('calls deleteSchema after confirmation', () => { it('calls deleteSchema after confirmation', () => {
expect(confirmationModal.prop('isOpen')).toBeFalsy(); expect(confirmationModal.prop('isOpen')).toBeFalsy();
wrapper.find('button').at(1).simulate('click'); wrapper.find('button').simulate('click');
expect(findConfirmationModal().prop('isOpen')).toBeTruthy(); expect(findConfirmationModal().prop('isOpen')).toBeTruthy();
// @ts-expect-error lack of typing of enzyme#invoke // @ts-expect-error lack of typing of enzyme#invoke
confirmationModal.invoke('onConfirm')(); confirmationModal.invoke('onConfirm')();
@ -138,7 +142,7 @@ describe('Details', () => {
it('calls deleteSchema after confirmation', () => { it('calls deleteSchema after confirmation', () => {
expect(confirmationModal.prop('isOpen')).toBeFalsy(); expect(confirmationModal.prop('isOpen')).toBeFalsy();
wrapper.find('button').at(1).simulate('click'); wrapper.find('button').simulate('click');
expect(findConfirmationModal().prop('isOpen')).toBeTruthy(); expect(findConfirmationModal().prop('isOpen')).toBeTruthy();
// @ts-expect-error lack of typing of enzyme#invoke // @ts-expect-error lack of typing of enzyme#invoke
wrapper.find('mock-ConfirmationModal').invoke('onCancel')(); wrapper.find('mock-ConfirmationModal').invoke('onCancel')();
@ -167,5 +171,17 @@ describe('Details', () => {
}); });
}); });
}); });
describe('when page with schemas are loading', () => {
const wrapper = shallow(setupWrapper({ areSchemasFetched: false }));
it('renders PageLoader', () => {
expect(wrapper.exists('PageLoader')).toBeTruthy();
});
it('matches snapshot', () => {
expect(wrapper).toMatchSnapshot();
});
});
}); });
}); });

View file

@ -49,20 +49,18 @@ exports[`Details View Initial state matches snapshot 1`] = `
</div> </div>
</div> </div>
<div <div
className="level-right" className="level-right buttons"
> >
<button <Link
className="button is-warning is-small level-item" className="button is-warning"
disabled={true} to="/ui/clusters/testCluster/schemas/test/edit"
title="in development"
type="button" type="button"
> >
Update Schema Update Schema
</button> </Link>
<button <button
className="button is-danger is-small level-item" className="button is-danger"
onClick={[Function]} onClick={[Function]}
title="in development"
type="button" type="button"
> >
Remove Remove
@ -195,20 +193,18 @@ exports[`Details View when page with schema versions loaded when schema has vers
</div> </div>
</div> </div>
<div <div
className="level-right" className="level-right buttons"
> >
<button <Link
className="button is-warning is-small level-item" className="button is-warning"
disabled={true} to="/ui/clusters/testCluster/schemas/test/edit"
title="in development"
type="button" type="button"
> >
Update Schema Update Schema
</button> </Link>
<button <button
className="button is-danger is-small level-item" className="button is-danger"
onClick={[Function]} onClick={[Function]}
title="in development"
type="button" type="button"
> >
Remove Remove
@ -344,20 +340,18 @@ exports[`Details View when page with schema versions loaded when versions are em
</div> </div>
</div> </div>
<div <div
className="level-right" className="level-right buttons"
> >
<button <Link
className="button is-warning is-small level-item" className="button is-warning"
disabled={true} to="/ui/clusters/testCluster/schemas/test/edit"
title="in development"
type="button" type="button"
> >
Update Schema Update Schema
</button> </Link>
<button <button
className="button is-danger is-small level-item" className="button is-danger"
onClick={[Function]} onClick={[Function]}
title="in development"
type="button" type="button"
> >
Remove Remove
@ -416,3 +410,27 @@ exports[`Details View when page with schema versions loaded when versions are em
</div> </div>
</div> </div>
`; `;
exports[`Details View when page with schemas are loading matches snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/testCluster/schemas",
"label": "Schema Registry",
},
]
}
>
test
</Breadcrumb>
</div>
<PageLoader />
</div>
`;

View file

@ -0,0 +1,172 @@
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import {
CompatibilityLevelCompatibilityEnum,
SchemaSubject,
SchemaType,
} from 'generated-sources';
import { clusterSchemaPath, clusterSchemasPath } from 'lib/paths';
import React from 'react';
import { ClusterName, NewSchemaSubjectRaw, SchemaName } from 'redux/interfaces';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { useHistory } from 'react-router';
import JSONEditor from 'components/common/JSONEditor/JSONEditor';
import { useForm } from 'react-hook-form';
export interface EditProps {
subject: SchemaName;
schema: SchemaSubject;
clusterName: ClusterName;
schemasAreFetched: boolean;
fetchSchemasByClusterName: (clusterName: ClusterName) => void;
updateSchema: (
latestSchema: SchemaSubject,
newSchema: string,
newSchemaType: SchemaType,
newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
clusterName: string,
subject: string
) => Promise<void>;
}
const Edit = ({
subject,
schema,
clusterName,
schemasAreFetched,
fetchSchemasByClusterName,
updateSchema,
}: EditProps) => {
React.useEffect(() => {
if (!schemasAreFetched) fetchSchemasByClusterName(clusterName);
}, [clusterName, fetchSchemasByClusterName]);
const {
register,
handleSubmit,
formState: { isSubmitting },
control,
} = useForm<NewSchemaSubjectRaw>({ mode: 'onChange' });
const getFormattedSchema = React.useCallback(
() => JSON.stringify(JSON.parse(schema.schema), null, '\t'),
[schema]
);
const history = useHistory();
const onSubmit = React.useCallback(
async ({
schemaType,
compatibilityLevel,
newSchema,
}: {
schemaType: SchemaType;
compatibilityLevel: CompatibilityLevelCompatibilityEnum;
newSchema: string;
}) => {
await updateSchema(
schema,
newSchema,
schemaType,
compatibilityLevel,
clusterName,
subject
);
history.push(clusterSchemaPath(clusterName, subject));
},
[schema, register, control, clusterName, subject, updateSchema, history]
);
return (
<div className="section">
<div className="level">
<div className="level-item level-left">
<Breadcrumb
links={[
{
href: clusterSchemasPath(clusterName),
label: 'Schema Registry',
},
{
href: clusterSchemaPath(clusterName, subject),
label: subject,
},
]}
>
Edit
</Breadcrumb>
</div>
</div>
{schemasAreFetched && !isSubmitting ? (
<div className="box">
<form
onSubmit={handleSubmit(onSubmit)}
className="mt-3 is-flex-direction-column"
>
<div className="mb-4">
<h5 className="title is-5 mb-2">Schema Type</h5>
<div className="select">
<select
name="schemaType"
ref={register({
required: 'Schema Type is required.',
})}
defaultValue={schema.schemaType}
>
{Object.keys(SchemaType).map((type: string) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<h5 className="title is-5 mb-2">Compatibility Level</h5>
<div className="select">
<select
name="compatibilityLevel"
ref={register()}
defaultValue={schema.compatibilityLevel}
>
{Object.keys(CompatibilityLevelCompatibilityEnum).map(
(level: string) => (
<option key={level} value={level}>
{level}
</option>
)
)}
</select>
</div>
</div>
<div className="columns">
<div className="column is-one-half">
<h4 className="title is-5 mb-2">Latest Schema</h4>
<JSONEditor
readonly
value={getFormattedSchema()}
name="latestSchema"
/>
</div>
<div className="column is-one-half">
<h4 className="title is-5 mb-2">New Schema</h4>
<JSONEditor
control={control}
value={getFormattedSchema()}
name="newSchema"
/>
</div>
</div>
<button type="submit" className="button is-primary">
Submit
</button>
</form>
</div>
) : (
<PageLoader />
)}
</div>
);
};
export default Edit;

View file

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

View file

@ -0,0 +1,66 @@
import { mount, shallow } from 'enzyme';
import { SchemaType } from 'generated-sources';
import React from 'react';
import { StaticRouter } from 'react-router-dom';
import Edit, { EditProps } from 'components/Schemas/Edit/Edit';
describe('Edit Component', () => {
const mockSchema = {
subject: 'Subject',
version: '1',
id: 1,
schema: '{"schema": "schema"}',
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.AVRO,
};
const setupWrapper = (props: Partial<EditProps> = {}) => (
<Edit
subject="Subject"
clusterName="ClusterName"
schemasAreFetched
fetchSchemasByClusterName={jest.fn()}
updateSchema={jest.fn()}
schema={mockSchema}
{...props}
/>
);
describe('when schemas are not fetched', () => {
const component = shallow(setupWrapper({ schemasAreFetched: false }));
it('matches the snapshot', () => {
expect(component).toMatchSnapshot();
});
it('shows loader', () => {
expect(component.find('PageLoader').exists()).toBeTruthy();
});
it('fetches them', () => {
const mockFetch = jest.fn();
mount(
<StaticRouter>
{setupWrapper({
schemasAreFetched: false,
fetchSchemasByClusterName: mockFetch,
})}
</StaticRouter>
);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe('when schemas are fetched', () => {
const component = shallow(setupWrapper());
it('matches the snapshot', () => {
expect(component).toMatchSnapshot();
});
it('shows editor', () => {
expect(component.find('JSONEditor').length).toEqual(2);
expect(component.find('button').exists()).toBeTruthy();
});
it('does not fetch them', () => {
const mockFetch = jest.fn();
shallow(setupWrapper());
expect(mockFetch).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -0,0 +1,311 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Edit Component when schemas are fetched matches the snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<div
className="level-item level-left"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/ClusterName/schemas",
"label": "Schema Registry",
},
Object {
"href": "/ui/clusters/ClusterName/schemas/Subject/latest",
"label": "Subject",
},
]
}
>
Edit
</Breadcrumb>
</div>
</div>
<div
className="box"
>
<form
className="mt-3 is-flex-direction-column"
onSubmit={[Function]}
>
<div
className="mb-4"
>
<h5
className="title is-5 mb-2"
>
Schema Type
</h5>
<div
className="select"
>
<select
defaultValue="AVRO"
name="schemaType"
>
<option
key="AVRO"
value="AVRO"
>
AVRO
</option>
<option
key="JSON"
value="JSON"
>
JSON
</option>
<option
key="PROTOBUF"
value="PROTOBUF"
>
PROTOBUF
</option>
</select>
</div>
</div>
<div
className="mb-4"
>
<h5
className="title is-5 mb-2"
>
Compatibility Level
</h5>
<div
className="select"
>
<select
defaultValue="BACKWARD"
name="compatibilityLevel"
>
<option
key="BACKWARD"
value="BACKWARD"
>
BACKWARD
</option>
<option
key="BACKWARD_TRANSITIVE"
value="BACKWARD_TRANSITIVE"
>
BACKWARD_TRANSITIVE
</option>
<option
key="FORWARD"
value="FORWARD"
>
FORWARD
</option>
<option
key="FORWARD_TRANSITIVE"
value="FORWARD_TRANSITIVE"
>
FORWARD_TRANSITIVE
</option>
<option
key="FULL"
value="FULL"
>
FULL
</option>
<option
key="FULL_TRANSITIVE"
value="FULL_TRANSITIVE"
>
FULL_TRANSITIVE
</option>
<option
key="NONE"
value="NONE"
>
NONE
</option>
</select>
</div>
</div>
<div
className="columns"
>
<div
className="column is-one-half"
>
<h4
className="title is-5 mb-2"
>
Latest Schema
</h4>
<JSONEditor
name="latestSchema"
readonly={true}
value="{
\\"schema\\": \\"schema\\"
}"
/>
</div>
<div
className="column is-one-half"
>
<h4
className="title is-5 mb-2"
>
New Schema
</h4>
<JSONEditor
control={
Object {
"defaultValuesRef": Object {
"current": Object {},
},
"fieldArrayDefaultValuesRef": Object {
"current": Object {},
},
"fieldArrayNamesRef": Object {
"current": Set {},
},
"fieldArrayValuesRef": Object {
"current": Object {},
},
"fieldsRef": Object {
"current": Object {},
},
"fieldsWithValidationRef": Object {
"current": Object {},
},
"formState": Object {
"dirtyFields": Object {},
"errors": Object {},
"isDirty": false,
"isSubmitSuccessful": false,
"isSubmitted": false,
"isSubmitting": false,
"isValid": true,
"isValidating": false,
"submitCount": 0,
"touched": Object {},
},
"formStateRef": Object {
"current": Object {
"dirtyFields": Object {},
"errors": Object {},
"isDirty": false,
"isSubmitSuccessful": false,
"isSubmitted": false,
"isSubmitting": false,
"isValid": true,
"isValidating": false,
"submitCount": 0,
"touched": Object {},
},
},
"getValues": [Function],
"isFormDirty": [Function],
"mode": Object {
"isOnAll": false,
"isOnBlur": false,
"isOnChange": true,
"isOnSubmit": false,
"isOnTouch": false,
},
"reValidateMode": Object {
"isReValidateOnBlur": false,
"isReValidateOnChange": true,
},
"readFormStateRef": Object {
"current": Object {
"constructor": true,
"dirtyFields": true,
"errors": true,
"isDirty": true,
"isSubmitSuccessful": true,
"isSubmitted": true,
"isSubmitting": true,
"isValid": true,
"isValidating": true,
"submitCount": true,
"touched": true,
},
},
"register": [Function],
"removeFieldEventListener": [Function],
"resetFieldArrayFunctionRef": Object {
"current": Object {},
},
"setValue": [Function],
"shallowFieldsStateRef": Object {
"current": Object {},
},
"shouldUnregister": true,
"trigger": [Function],
"unregister": [Function],
"updateFormState": [Function],
"updateWatchedValue": [Function],
"useWatchFieldsRef": Object {
"current": Object {},
},
"useWatchRenderFunctionsRef": Object {
"current": Object {},
},
"validFieldsRef": Object {
"current": Object {},
},
"validateResolver": undefined,
"watchInternal": [Function],
}
}
name="newSchema"
value="{
\\"schema\\": \\"schema\\"
}"
/>
</div>
</div>
<button
className="button is-primary"
type="submit"
>
Submit
</button>
</form>
</div>
</div>
`;
exports[`Edit Component when schemas are not fetched matches the snapshot 1`] = `
<div
className="section"
>
<div
className="level"
>
<div
className="level-item level-left"
>
<Breadcrumb
links={
Array [
Object {
"href": "/ui/clusters/ClusterName/schemas",
"label": "Schema Registry",
},
Object {
"href": "/ui/clusters/ClusterName/schemas/Subject/latest",
"label": "Subject",
},
]
}
>
Edit
</Breadcrumb>
</div>
</div>
<PageLoader />
</div>
`;

View file

@ -3,10 +3,10 @@ import { ClusterName, NewSchemaSubjectRaw } from 'redux/interfaces';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message'; import { ErrorMessage } from '@hookform/error-message';
import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb'; import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { clusterSchemasPath } from 'lib/paths'; import { clusterSchemaPath, clusterSchemasPath } from 'lib/paths';
import { NewSchemaSubject, SchemaType } from 'generated-sources'; import { NewSchemaSubject, SchemaType } from 'generated-sources';
import { SCHEMA_NAME_VALIDATION_PATTERN } from 'lib/constants'; import { SCHEMA_NAME_VALIDATION_PATTERN } from 'lib/constants';
import { useParams } from 'react-router'; import { useHistory, useParams } from 'react-router';
export interface NewProps { export interface NewProps {
createSchema: ( createSchema: (
@ -17,6 +17,7 @@ export interface NewProps {
const New: React.FC<NewProps> = ({ createSchema }) => { const New: React.FC<NewProps> = ({ createSchema }) => {
const { clusterName } = useParams<{ clusterName: string }>(); const { clusterName } = useParams<{ clusterName: string }>();
const history = useHistory();
const { const {
register, register,
errors, errors,
@ -25,12 +26,17 @@ const New: React.FC<NewProps> = ({ createSchema }) => {
} = useForm<NewSchemaSubjectRaw>(); } = useForm<NewSchemaSubjectRaw>();
const onSubmit = React.useCallback( const onSubmit = React.useCallback(
async ({ subject, schema }: NewSchemaSubjectRaw) => { async ({ subject, schema, schemaType }: NewSchemaSubjectRaw) => {
await createSchema(clusterName, { try {
subject, await createSchema(clusterName, {
schema, subject,
schemaType: SchemaType.AVRO, schema,
}); schemaType,
});
history.push(clusterSchemaPath(clusterName, subject));
} catch (e) {
// Show Error
}
}, },
[clusterName] [clusterName]
); );
@ -62,7 +68,7 @@ const New: React.FC<NewProps> = ({ createSchema }) => {
className="input" className="input"
placeholder="Schema Name" placeholder="Schema Name"
ref={register({ ref={register({
required: 'Topic Name is required.', required: 'Schema Name is required.',
pattern: { pattern: {
value: SCHEMA_NAME_VALIDATION_PATTERN, value: SCHEMA_NAME_VALIDATION_PATTERN,
message: 'Only alphanumeric, _, -, and . allowed', message: 'Only alphanumeric, _, -, and . allowed',
@ -83,7 +89,9 @@ const New: React.FC<NewProps> = ({ createSchema }) => {
<div className="control"> <div className="control">
<textarea <textarea
className="textarea" className="textarea"
ref={register} ref={register({
required: 'Schema is required.',
})}
name="schema" name="schema"
disabled={isSubmitting} disabled={isSubmitting}
/> />
@ -92,6 +100,26 @@ const New: React.FC<NewProps> = ({ createSchema }) => {
<ErrorMessage errors={errors} name="schema" /> <ErrorMessage errors={errors} name="schema" />
</p> </p>
</div> </div>
<div className="field">
<label className="label">Schema Type *</label>
<div className="control select">
<select
ref={register({
required: 'Schema Type is required.',
})}
name="schemaType"
disabled={isSubmitting}
>
<option value={SchemaType.AVRO}>AVRO</option>
<option value={SchemaType.JSON}>JSON</option>
<option value={SchemaType.PROTOBUF}>PROTOBUF</option>
</select>
</div>
<p className="help is-danger">
<ErrorMessage errors={errors} name="schemaType" />
</p>
</div>
</div> </div>
<br /> <br />
<div className="field"> <div className="field">

View file

@ -151,6 +151,47 @@ exports[`New View matches snapshot 1`] = `
/> />
</p> </p>
</div> </div>
<div
className="field"
>
<label
className="label"
>
Schema Type *
</label>
<div
className="control select"
>
<select
disabled={false}
name="schemaType"
>
<option
value="AVRO"
>
AVRO
</option>
<option
value="JSON"
>
JSON
</option>
<option
value="PROTOBUF"
>
PROTOBUF
</option>
</select>
</div>
<p
className="help is-danger"
>
<Component
errors={Object {}}
name="schemaType"
/>
</p>
</div>
</div> </div>
<br /> <br />
<div <div

View file

@ -9,6 +9,7 @@ import {
import ListContainer from './List/ListContainer'; import ListContainer from './List/ListContainer';
import DetailsContainer from './Details/DetailsContainer'; import DetailsContainer from './Details/DetailsContainer';
import NewContainer from './New/NewContainer'; import NewContainer from './New/NewContainer';
import EditContainer from './Edit/EditContainer';
const Schemas: React.FC = () => ( const Schemas: React.FC = () => (
<Switch> <Switch>
@ -27,6 +28,11 @@ const Schemas: React.FC = () => (
path={clusterSchemaPath(':clusterName', ':subject')} path={clusterSchemaPath(':clusterName', ':subject')}
component={DetailsContainer} component={DetailsContainer}
/> />
<Route
exact
path="/ui/clusters/:clusterName/schemas/:subject/edit"
component={EditContainer}
/>
</Switch> </Switch>
); );

View file

@ -0,0 +1,56 @@
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-json5';
import 'ace-builds/src-noconflict/theme-dawn';
import React from 'react';
import { Control, Controller } from 'react-hook-form';
interface JSONEditorProps {
readonly?: boolean;
onChange?: (e: string) => void;
value: string;
name: string;
control?: Control;
}
const JSONEditor: React.FC<JSONEditorProps> = ({
readonly,
onChange,
value,
name,
control,
}) => {
if (control) {
return (
<Controller
control={control}
name={name}
as={
<AceEditor
defaultValue={value}
mode="json5"
theme="dawn"
name={name}
tabSize={2}
width="100%"
wrapEnabled
/>
}
/>
);
}
return (
<AceEditor
mode="json5"
theme="dawn"
name={name}
value={value}
tabSize={2}
width="100%"
readOnly={readonly}
onChange={onChange}
wrapEnabled
/>
);
};
export default JSONEditor;

View file

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

View file

@ -0,0 +1,43 @@
// 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="json5"
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="dawn"
value="{}"
width="100%"
wrapEnabled={true}
/>
`;

View file

@ -24,6 +24,10 @@ export const clusterSchemaPath = (
clusterName: ClusterName, clusterName: ClusterName,
subject: SchemaName subject: SchemaName
) => `${clusterSchemasPath(clusterName)}/${subject}/latest`; ) => `${clusterSchemasPath(clusterName)}/${subject}/latest`;
export const clusterSchemaSchemaEditPath = (
clusterName: ClusterName,
subject: SchemaName
) => `${clusterSchemasPath(clusterName)}/${subject}/edit`;
// Topics // Topics
export const clusterTopicsPath = (clusterName: ClusterName) => export const clusterTopicsPath = (clusterName: ClusterName) =>

View file

@ -1,4 +1,10 @@
import { ClusterStats, NewSchemaSubject, SchemaType } from 'generated-sources'; import {
ClusterStats,
CompatibilityLevelCompatibilityEnum,
NewSchemaSubject,
SchemaSubject,
SchemaType,
} from 'generated-sources';
export const clusterStats: ClusterStats = { export const clusterStats: ClusterStats = {
brokerCount: 1, brokerCount: 1,
@ -18,3 +24,13 @@ export const schemaPayload: NewSchemaSubject = {
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}', '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
schemaType: SchemaType.JSON, schemaType: SchemaType.JSON,
}; };
export const schema: SchemaSubject = {
subject: 'NewSchema',
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
schemaType: SchemaType.JSON,
version: '1',
id: 1,
compatibilityLevel: CompatibilityLevelCompatibilityEnum.BACKWARD,
};

View file

@ -2,6 +2,10 @@ import fetchMock from 'fetch-mock-jest';
import * as actions from 'redux/actions/actions'; import * as actions from 'redux/actions/actions';
import * as thunks from 'redux/actions/thunks'; import * as thunks from 'redux/actions/thunks';
import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures'; import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
import {
CompatibilityLevelCompatibilityEnum,
SchemaType,
} from 'generated-sources';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator'; import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import * as fixtures from 'redux/actions/__test__/fixtures'; import * as fixtures from 'redux/actions/__test__/fixtures';
@ -124,6 +128,47 @@ describe('Thunks', () => {
}); });
}); });
describe('updateSchemaCompatibilityLevel', () => {
it('creates UPDATE_SCHEMA__SUCCESS when patching a schema', async () => {
fetchMock.putOnce(
`/api/clusters/${clusterName}/schemas/${subject}/compatibility`,
200
);
await store.dispatch(
thunks.updateSchemaCompatibilityLevel(
clusterName,
subject,
CompatibilityLevelCompatibilityEnum.BACKWARD
)
);
expect(store.getActions()).toEqual([
actions.updateSchemaCompatibilityLevelAction.request(),
actions.updateSchemaCompatibilityLevelAction.success(),
]);
});
it('creates UPDATE_SCHEMA__SUCCESS when failing to patch a schema', async () => {
fetchMock.putOnce(
`/api/clusters/${clusterName}/schemas/${subject}/compatibility`,
404
);
try {
await store.dispatch(
thunks.updateSchemaCompatibilityLevel(
clusterName,
subject,
CompatibilityLevelCompatibilityEnum.BACKWARD
)
);
} catch (error) {
expect(error.status).toEqual(404);
expect(store.getActions()).toEqual([
actions.updateSchemaCompatibilityLevelAction.request(),
actions.updateSchemaCompatibilityLevelAction.failure({}),
]);
}
});
});
describe('deleteSchema', () => { describe('deleteSchema', () => {
it('fires DELETE_SCHEMA__SUCCESS on success', async () => { it('fires DELETE_SCHEMA__SUCCESS on success', async () => {
fetchMock.deleteOnce( fetchMock.deleteOnce(
@ -156,4 +201,38 @@ describe('Thunks', () => {
} }
}); });
}); });
describe('updateSchema', () => {
it('calls createSchema', () => {
store.dispatch(
thunks.updateSchema(
fixtures.schema,
fixtures.schemaPayload.schema,
SchemaType.AVRO,
CompatibilityLevelCompatibilityEnum.BACKWARD,
clusterName,
subject
)
);
expect(store.getActions()).toEqual([
actions.createSchemaAction.request(),
]);
});
it('calls updateSchema and does not call createSchema when schema does not change', () => {
store.dispatch(
thunks.updateSchema(
fixtures.schema,
fixtures.schema.schema,
SchemaType.JSON,
CompatibilityLevelCompatibilityEnum.FORWARD,
clusterName,
subject
)
);
expect(store.getActions()).toEqual([
actions.updateSchemaCompatibilityLevelAction.request(),
]);
});
});
}); });

View file

@ -130,6 +130,12 @@ export const createSchemaAction = createAsyncAction(
'POST_SCHEMA__FAILURE' 'POST_SCHEMA__FAILURE'
)<undefined, SchemaSubject, { alert?: FailurePayload }>(); )<undefined, SchemaSubject, { alert?: FailurePayload }>();
export const updateSchemaCompatibilityLevelAction = createAsyncAction(
'PATCH_SCHEMA_COMPATIBILITY__REQUEST',
'PATCH_SCHEMA_COMPATIBILITY__SUCCESS',
'PATCH_SCHEMA_COMPATIBILITY__FAILURE'
)<undefined, undefined, { alert?: FailurePayload }>();
export const deleteSchemaAction = createAsyncAction( export const deleteSchemaAction = createAsyncAction(
'DELETE_SCHEMA__REQUEST', 'DELETE_SCHEMA__REQUEST',
'DELETE_SCHEMA__SUCCESS', 'DELETE_SCHEMA__SUCCESS',

View file

@ -3,6 +3,8 @@ import {
Configuration, Configuration,
NewSchemaSubject, NewSchemaSubject,
SchemaSubject, SchemaSubject,
CompatibilityLevelCompatibilityEnum,
SchemaType,
} from 'generated-sources'; } from 'generated-sources';
import { import {
PromiseThunkResult, PromiseThunkResult,
@ -13,6 +15,7 @@ import {
import { BASE_PARAMS } from 'lib/constants'; import { BASE_PARAMS } from 'lib/constants';
import * as actions from 'redux/actions'; import * as actions from 'redux/actions';
import { getResponse } from 'lib/errorHandling'; import { getResponse } from 'lib/errorHandling';
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);
@ -68,6 +71,63 @@ export const createSchema = (
} }
}; };
export const updateSchemaCompatibilityLevel = (
clusterName: ClusterName,
subject: string,
compatibilityLevel: CompatibilityLevelCompatibilityEnum
): PromiseThunkResult => async (dispatch) => {
dispatch(actions.updateSchemaCompatibilityLevelAction.request());
try {
await schemasApiClient.updateSchemaCompatibilityLevel({
clusterName,
subject,
compatibilityLevel: {
compatibility: compatibilityLevel,
},
});
dispatch(actions.updateSchemaCompatibilityLevelAction.success());
} catch (error) {
const response = await getResponse(error);
const alert: FailurePayload = {
subject: 'compatibilityLevel',
title: `Compatibility level ${subject}`,
response,
};
dispatch(actions.updateSchemaCompatibilityLevelAction.failure({ alert }));
}
};
export const updateSchema = (
latestSchema: SchemaSubject,
newSchema: string,
newSchemaType: SchemaType,
newCompatibilityLevel: CompatibilityLevelCompatibilityEnum,
clusterName: string,
subject: string
): PromiseThunkResult => async (dispatch) => {
if (
(newSchema &&
!isEqual(JSON.parse(latestSchema.schema), JSON.parse(newSchema))) ||
newSchemaType !== latestSchema.schemaType
) {
await dispatch(
createSchema(clusterName, {
...latestSchema,
schema: newSchema || latestSchema.schema,
schemaType: newSchemaType || latestSchema.schemaType,
})
);
}
if (newCompatibilityLevel !== latestSchema.compatibilityLevel) {
await dispatch(
updateSchemaCompatibilityLevel(
clusterName,
subject,
newCompatibilityLevel
)
);
}
};
export const deleteSchema = ( export const deleteSchema = (
clusterName: ClusterName, clusterName: ClusterName,
subject: string subject: string