Browse Source

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
Alexander Krivonosov 4 years ago
parent
commit
caec4eb170
21 changed files with 1066 additions and 57 deletions
  1. 28 2
      kafka-ui-react-app/package-lock.json
  2. 2 0
      kafka-ui-react-app/package.json
  3. 17 13
      kafka-ui-react-app/src/components/Schemas/Details/Details.tsx
  4. 9 2
      kafka-ui-react-app/src/components/Schemas/Details/DetailsContainer.ts
  5. 21 5
      kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx
  6. 42 24
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/Details.spec.tsx.snap
  7. 172 0
      kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx
  8. 38 0
      kafka-ui-react-app/src/components/Schemas/Edit/EditContainer.ts
  9. 66 0
      kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx
  10. 311 0
      kafka-ui-react-app/src/components/Schemas/Edit/__tests__/__snapshots__/Edit.spec.tsx.snap
  11. 38 10
      kafka-ui-react-app/src/components/Schemas/New/New.tsx
  12. 41 0
      kafka-ui-react-app/src/components/Schemas/New/__test__/__snapshots__/New.spec.tsx.snap
  13. 6 0
      kafka-ui-react-app/src/components/Schemas/Schemas.tsx
  14. 56 0
      kafka-ui-react-app/src/components/common/JSONEditor/JSONEditor.tsx
  15. 10 0
      kafka-ui-react-app/src/components/common/JSONEditor/__tests__/JSONEditor.spec.tsx
  16. 43 0
      kafka-ui-react-app/src/components/common/JSONEditor/__tests__/__snapshots__/JSONEditor.spec.tsx.snap
  17. 4 0
      kafka-ui-react-app/src/lib/paths.ts
  18. 17 1
      kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts
  19. 79 0
      kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts
  20. 6 0
      kafka-ui-react-app/src/redux/actions/actions.ts
  21. 60 0
      kafka-ui-react-app/src/redux/actions/thunks/schemas.ts

+ 28 - 2
kafka-ui-react-app/package-lock.json

@@ -3248,6 +3248,11 @@
         "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": {
       "version": "7.4.1",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@@ -6375,6 +6380,11 @@
       "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
       "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": {
       "version": "26.6.2",
       "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz",
@@ -11979,11 +11989,15 @@
       "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
       "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": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
-      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
-      "dev": true
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
     },
     "lodash.isplainobject": {
       "version": "4.0.6",
@@ -15208,6 +15222,18 @@
         "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": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-2.0.0.tgz",

+ 2 - 0
kafka-ui-react-app/package.json

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

+ 17 - 13
kafka-ui-react-app/src/components/Schemas/Details/Details.tsx

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

+ 9 - 2
kafka-ui-react-app/src/components/Schemas/Details/DetailsContainer.ts

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

+ 21 - 5
kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx

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

+ 42 - 24
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/Details.spec.tsx.snap

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

+ 172 - 0
kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx

@@ -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;

+ 38 - 0
kafka-ui-react-app/src/components/Schemas/Edit/EditContainer.ts

@@ -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));

+ 66 - 0
kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx

@@ -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);
+    });
+  });
+});

+ 311 - 0
kafka-ui-react-app/src/components/Schemas/Edit/__tests__/__snapshots__/Edit.spec.tsx.snap

@@ -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>
+`;

+ 38 - 10
kafka-ui-react-app/src/components/Schemas/New/New.tsx

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

+ 41 - 0
kafka-ui-react-app/src/components/Schemas/New/__test__/__snapshots__/New.spec.tsx.snap

@@ -151,6 +151,47 @@ exports[`New View matches snapshot 1`] = `
                   />
                 </p>
               </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>
             <br />
             <div

+ 6 - 0
kafka-ui-react-app/src/components/Schemas/Schemas.tsx

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

+ 56 - 0
kafka-ui-react-app/src/components/common/JSONEditor/JSONEditor.tsx

@@ -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;

+ 10 - 0
kafka-ui-react-app/src/components/common/JSONEditor/__tests__/JSONEditor.spec.tsx

@@ -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();
+  });
+});

+ 43 - 0
kafka-ui-react-app/src/components/common/JSONEditor/__tests__/__snapshots__/JSONEditor.spec.tsx.snap

@@ -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}
+/>
+`;

+ 4 - 0
kafka-ui-react-app/src/lib/paths.ts

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

+ 17 - 1
kafka-ui-react-app/src/redux/actions/__test__/fixtures.ts

@@ -1,4 +1,10 @@
-import { ClusterStats, NewSchemaSubject, SchemaType } from 'generated-sources';
+import {
+  ClusterStats,
+  CompatibilityLevelCompatibilityEnum,
+  NewSchemaSubject,
+  SchemaSubject,
+  SchemaType,
+} from 'generated-sources';
 
 export const clusterStats: ClusterStats = {
   brokerCount: 1,
@@ -18,3 +24,13 @@ export const schemaPayload: NewSchemaSubject = {
     '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
   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,
+};

+ 79 - 0
kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts

@@ -2,6 +2,10 @@ import fetchMock from 'fetch-mock-jest';
 import * as actions from 'redux/actions/actions';
 import * as thunks from 'redux/actions/thunks';
 import * as schemaFixtures from 'redux/reducers/schemas/__test__/fixtures';
+import {
+  CompatibilityLevelCompatibilityEnum,
+  SchemaType,
+} from 'generated-sources';
 import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
 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', () => {
     it('fires DELETE_SCHEMA__SUCCESS on success', async () => {
       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(),
+      ]);
+    });
+  });
 });

+ 6 - 0
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -130,6 +130,12 @@ export const createSchemaAction = createAsyncAction(
   'POST_SCHEMA__FAILURE'
 )<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(
   'DELETE_SCHEMA__REQUEST',
   'DELETE_SCHEMA__SUCCESS',

+ 60 - 0
kafka-ui-react-app/src/redux/actions/thunks/schemas.ts

@@ -3,6 +3,8 @@ import {
   Configuration,
   NewSchemaSubject,
   SchemaSubject,
+  CompatibilityLevelCompatibilityEnum,
+  SchemaType,
 } from 'generated-sources';
 import {
   PromiseThunkResult,
@@ -13,6 +15,7 @@ import {
 import { BASE_PARAMS } from 'lib/constants';
 import * as actions from 'redux/actions';
 import { getResponse } from 'lib/errorHandling';
+import { isEqual } from 'lodash';
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 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 = (
   clusterName: ClusterName,
   subject: string