Explorar el Código

#867: add a view for comparing schema versions (#1044)

* #867: add a view for comparing schema versions

* fixing test for diff component

* adding color to lines which are different in versions

* adding function to determine schema type and display correct format when comparing versions

* removing unneccessary style

* changing fetch approach and fixing test issue

* fixinf schema versions comparision path change approach

* remove unnecessary code

* removing enzyme,removing direct use of Colors and adding dispatch to array of deps

* added requested changes

* makeing requested changes

Co-authored-by: NelyDavtyan <ndavtyan@provectus.com>
Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
Co-authored-by: NelyDavtyan <96067981+NelyDavtyan@users.noreply.github.com>
Si Tang hace 3 años
padre
commit
dd42dbe0cd

+ 16 - 2
kafka-ui-react-app/src/components/Schemas/Details/Details.tsx

@@ -1,6 +1,10 @@
 import React from 'react';
 import { useHistory, useParams } from 'react-router';
-import { clusterSchemasPath, clusterSchemaEditPath } from 'lib/paths';
+import {
+  clusterSchemasPath,
+  clusterSchemaSchemaDiffPath,
+  clusterSchemaEditPath,
+} from 'lib/paths';
 import ClusterContext from 'components/contexts/ClusterContext';
 import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
 import PageLoader from 'components/common/PageLoader/PageLoader';
@@ -77,12 +81,22 @@ const Details: React.FC = () => {
   if (!isFetched || !schema) {
     return <PageLoader />;
   }
-
   return (
     <>
       <PageHeading text={schema.subject}>
         {!isReadOnly && (
           <>
+            <Button
+              isLink
+              buttonSize="M"
+              buttonType="primary"
+              to={{
+                pathname: clusterSchemaSchemaDiffPath(clusterName, subject),
+                search: `leftVersion=${versions[0]?.version}&rightVersion=${versions[0]?.version}`,
+              }}
+            >
+              Compare Versions
+            </Button>
             <Button
               isLink
               buttonSize="M"

+ 58 - 0
kafka-ui-react-app/src/components/Schemas/Diff/Diff.styled.ts

@@ -0,0 +1,58 @@
+import styled from 'styled-components';
+
+export const DiffWrapper = styled.div`
+  align-items: stretch;
+  display: block;
+  flex-basis: 0;
+  flex-grow: 1;
+  flex-shrink: 1;
+  min-height: min-content;
+  padding-top: 1.5rem !important;
+  &
+    .ace_editor
+    > .ace_scroller
+    > .ace_content
+    > .ace_marker-layer
+    > .codeMarker {
+    background: ${({ theme }) => theme.icons.warningIcon};
+    position: absolute;
+    z-index: 20;
+  }
+`;
+
+export const Section = styled.div`
+  animation: fadein 0.5s;
+`;
+
+export const DiffBox = styled.div`
+  flex-direction: column;
+  margin-left: -0.75rem;
+  margin-right: -0.75rem;
+  margin-top: -0.75rem;
+  box-shadow: none;
+  padding: 1.25rem;
+  &:last-child {
+    margin-bottom: -0.75rem;
+  }
+`;
+
+export const DiffTilesWrapper = styled.div`
+  align-items: stretch;
+  display: block;
+  flex-basis: 0;
+  flex-grow: 1;
+  flex-shrink: 1;
+  min-height: min-content;
+  &:not(.is-child) {
+    display: flex;
+  }
+`;
+
+export const DiffTile = styled.div`
+  flex: none;
+  width: 50%;
+`;
+
+export const DiffVersionsSelect = styled.div`
+  width: 0.625em;
+`;

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

@@ -0,0 +1,179 @@
+import React from 'react';
+import { SchemaSubject } from 'generated-sources';
+import { clusterSchemaSchemaDiffPath } from 'lib/paths';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import DiffViewer from 'components/common/DiffViewer/DiffViewer';
+import { useHistory, useParams, useLocation } from 'react-router';
+import {
+  fetchSchemaVersions,
+  SCHEMAS_VERSIONS_FETCH_ACTION,
+} from 'redux/reducers/schemas/schemasSlice';
+import { useForm, Controller } from 'react-hook-form';
+import Select from 'components/common/Select/Select';
+import { useAppDispatch } from 'lib/hooks/redux';
+import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
+
+import * as S from './Diff.styled';
+
+export interface DiffProps {
+  leftVersionInPath?: string;
+  rightVersionInPath?: string;
+  versions: SchemaSubject[];
+  areVersionsFetched: boolean;
+}
+
+const Diff: React.FC<DiffProps> = ({
+  leftVersionInPath,
+  rightVersionInPath,
+  versions,
+  areVersionsFetched,
+}) => {
+  const [leftVersion, setLeftVersion] = React.useState(leftVersionInPath || '');
+  const [rightVersion, setRightVersion] = React.useState(
+    rightVersionInPath || ''
+  );
+  const history = useHistory();
+  const location = useLocation();
+
+  const { clusterName, subject } =
+    useParams<{ clusterName: string; subject: string }>();
+  const dispatch = useAppDispatch();
+
+  React.useEffect(() => {
+    dispatch(fetchSchemaVersions({ clusterName, subject }));
+    return () => {
+      dispatch(resetLoaderById(SCHEMAS_VERSIONS_FETCH_ACTION));
+    };
+  }, [clusterName, subject, dispatch]);
+
+  const getSchemaContent = (allVersions: SchemaSubject[], version: string) => {
+    const selectedSchema =
+      allVersions.find((s) => s.version === version)?.schema ||
+      (allVersions.length ? allVersions[0].schema : '');
+    return selectedSchema.trim().startsWith('{')
+      ? JSON.stringify(JSON.parse(selectedSchema), null, '\t')
+      : selectedSchema;
+  };
+  const getSchemaType = (allVersions: SchemaSubject[]) => {
+    return allVersions[0].schemaType;
+  };
+
+  const methods = useForm({ mode: 'onChange' });
+  const {
+    formState: { isSubmitting },
+    control,
+  } = methods;
+
+  const searchParams = React.useMemo(
+    () => new URLSearchParams(location.search),
+    [location]
+  );
+
+  return (
+    <S.Section>
+      {areVersionsFetched ? (
+        <S.DiffBox>
+          <S.DiffTilesWrapper>
+            <S.DiffTile>
+              <S.DiffVersionsSelect>
+                <Controller
+                  defaultValue={leftVersion}
+                  control={control}
+                  rules={{ required: true }}
+                  name="schemaType"
+                  render={({ field: { name } }) => (
+                    <Select
+                      id="left-select"
+                      name={name}
+                      value={
+                        leftVersion === '' ? versions[0].version : leftVersion
+                      }
+                      onChange={(event) => {
+                        history.push(
+                          clusterSchemaSchemaDiffPath(clusterName, subject)
+                        );
+                        searchParams.set('leftVersion', event.toString());
+                        searchParams.set(
+                          'rightVersion',
+                          rightVersion === ''
+                            ? versions[0].version
+                            : rightVersion
+                        );
+                        history.push({
+                          search: `?${searchParams.toString()}`,
+                        });
+                        setLeftVersion(event.toString());
+                      }}
+                      minWidth="100%"
+                      disabled={isSubmitting}
+                      options={versions.map((type) => ({
+                        value: type.version,
+                        label: `Version ${type.version}`,
+                      }))}
+                    />
+                  )}
+                />
+              </S.DiffVersionsSelect>
+            </S.DiffTile>
+            <S.DiffTile>
+              <S.DiffVersionsSelect>
+                <Controller
+                  defaultValue={rightVersion}
+                  control={control}
+                  rules={{ required: true }}
+                  name="schemaType"
+                  render={({ field: { name } }) => (
+                    <Select
+                      id="right-select"
+                      name={name}
+                      value={
+                        rightVersion === '' ? versions[0].version : rightVersion
+                      }
+                      onChange={(event) => {
+                        history.push(
+                          clusterSchemaSchemaDiffPath(clusterName, subject)
+                        );
+                        searchParams.set(
+                          'leftVersion',
+                          leftVersion === '' ? versions[0].version : leftVersion
+                        );
+                        searchParams.set('rightVersion', event.toString());
+                        history.push({
+                          search: `?${searchParams.toString()}`,
+                        });
+                        setRightVersion(event.toString());
+                      }}
+                      minWidth="100%"
+                      disabled={isSubmitting}
+                      options={versions.map((type) => ({
+                        value: type.version,
+                        label: `Version ${type.version}`,
+                      }))}
+                    />
+                  )}
+                />
+              </S.DiffVersionsSelect>
+            </S.DiffTile>
+          </S.DiffTilesWrapper>
+          <S.DiffWrapper>
+            <DiffViewer
+              value={[
+                getSchemaContent(versions, leftVersion),
+                getSchemaContent(versions, rightVersion),
+              ]}
+              setOptions={{
+                autoScrollEditorIntoView: true,
+              }}
+              isFixedHeight={false}
+              schemaType={getSchemaType(versions)}
+            />
+          </S.DiffWrapper>
+        </S.DiffBox>
+      ) : (
+        <PageLoader />
+      )}
+    </S.Section>
+  );
+};
+
+export default Diff;

+ 32 - 0
kafka-ui-react-app/src/components/Schemas/Diff/DiffContainer.ts

@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { ClusterName, RootState } from 'redux/interfaces';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import {
+  getAreSchemaVersionsFulfilled,
+  selectAllSchemaVersions,
+} from 'redux/reducers/schemas/schemasSlice';
+
+import Diff from './Diff';
+
+interface RouteProps {
+  leftVersion?: string;
+  rightVersion?: string;
+}
+
+type OwnProps = RouteComponentProps<RouteProps>;
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { leftVersion, rightVersion },
+    },
+  }: OwnProps
+) => ({
+  versions: selectAllSchemaVersions(state),
+  areVersionsFetched: getAreSchemaVersionsFulfilled(state),
+  leftVersionInPath: leftVersion,
+  rightVersionInPath: rightVersion,
+});
+
+export default withRouter(connect(mapStateToProps)(Diff));

+ 127 - 0
kafka-ui-react-app/src/components/Schemas/Diff/__test__/Diff.spec.tsx

@@ -0,0 +1,127 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import configureStore from 'redux-mock-store';
+import { StaticRouter } from 'react-router';
+import Diff, { DiffProps } from 'components/Schemas/Diff/Diff';
+import { render } from 'lib/testHelpers';
+import { screen } from '@testing-library/react';
+import thunk from 'redux-thunk';
+
+import { versions } from './fixtures';
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+describe('Diff', () => {
+  const initialState: Partial<DiffProps> = {};
+  const store = mockStore(initialState);
+
+  const setupComponent = (props: DiffProps) =>
+    render(
+      <Provider store={store}>
+        <StaticRouter>
+          <Diff
+            versions={props.versions}
+            leftVersionInPath={props.leftVersionInPath}
+            rightVersionInPath={props.rightVersionInPath}
+            areVersionsFetched={props.areVersionsFetched}
+          />
+        </StaticRouter>
+      </Provider>
+    );
+  describe('Container', () => {
+    it('renders view', () => {
+      setupComponent({
+        areVersionsFetched: true,
+        versions,
+      });
+    });
+  });
+
+  describe('View', () => {
+    setupComponent({
+      areVersionsFetched: true,
+      versions,
+    });
+  });
+  describe('when page with schema versions is loading', () => {
+    beforeAll(() => {
+      setupComponent({
+        areVersionsFetched: false,
+        versions: [],
+      });
+    });
+    it('renders PageLoader', () => {
+      expect(screen.getByRole('progressbar')).toBeInTheDocument();
+    });
+  });
+
+  describe('when schema versions are loaded and no specified versions in path', () => {
+    beforeEach(() => {
+      setupComponent({
+        areVersionsFetched: true,
+        versions,
+      });
+    });
+
+    it('renders all options', () => {
+      const selectedOption = screen.getAllByRole('option');
+      expect(selectedOption.length).toEqual(2);
+    });
+    it('renders left select with empty value', () => {
+      const select = screen.getAllByRole('listbox')[0];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent(versions[0].version);
+    });
+
+    it('renders right select with empty value', () => {
+      const select = screen.getAllByRole('listbox')[1];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent(versions[0].version);
+    });
+  });
+  describe('when schema versions are loaded and two versions in path', () => {
+    beforeEach(() => {
+      setupComponent({
+        areVersionsFetched: true,
+        versions,
+        leftVersionInPath: '1',
+        rightVersionInPath: '2',
+      });
+    });
+
+    it('renders left select with version 1', () => {
+      const select = screen.getAllByRole('listbox')[0];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent('1');
+    });
+
+    it('renders right select with version 2', () => {
+      const select = screen.getAllByRole('listbox')[1];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent('2');
+    });
+  });
+
+  describe('when schema versions are loaded and only one versions in path', () => {
+    beforeEach(() => {
+      setupComponent({
+        areVersionsFetched: true,
+        versions,
+        leftVersionInPath: '1',
+      });
+    });
+
+    it('renders left select with version 1', () => {
+      const select = screen.getAllByRole('listbox')[0];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent('1');
+    });
+
+    it('renders right select with empty value', () => {
+      const select = screen.getAllByRole('listbox')[1];
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveTextContent(versions[0].version);
+    });
+  });
+});

+ 31 - 0
kafka-ui-react-app/src/components/Schemas/Diff/__test__/fixtures.ts

@@ -0,0 +1,31 @@
+import { SchemaSubject, SchemaType } from 'generated-sources';
+
+export const versions: SchemaSubject[] = [
+  {
+    subject: 'test',
+    version: '3',
+    id: 3,
+    schema:
+      'syntax = "proto3";\npackage com.indeed;\n\nmessage MyRecord {\n  int32 id = 1;\n  string name = 2;\n}\n',
+    compatibilityLevel: 'BACKWARD',
+    schemaType: SchemaType.PROTOBUF,
+  },
+  {
+    subject: 'test',
+    version: '2',
+    id: 2,
+    schema:
+      '{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+    schemaType: SchemaType.JSON,
+  },
+  {
+    subject: 'test',
+    version: '1',
+    id: 1,
+    schema:
+      '{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
+    compatibilityLevel: 'BACKWARD',
+    schemaType: SchemaType.JSON,
+  },
+];

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

@@ -5,11 +5,13 @@ import {
   clusterSchemaPath,
   clusterSchemaEditPath,
   clusterSchemasPath,
+  clusterSchemaSchemaDiffPath,
 } from 'lib/paths';
 import List from 'components/Schemas/List/List';
 import Details from 'components/Schemas/Details/Details';
 import New from 'components/Schemas/New/New';
 import Edit from 'components/Schemas/Edit/Edit';
+import DiffContainer from 'components/Schemas/Diff/DiffContainer';
 import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
 
 const Schemas: React.FC = () => {
@@ -35,6 +37,11 @@ const Schemas: React.FC = () => {
         path={clusterSchemaEditPath(':clusterName', ':subject')}
         component={Edit}
       />
+      <BreadcrumbRoute
+        exact
+        path={clusterSchemaSchemaDiffPath(':clusterName', ':subject')}
+        component={DiffContainer}
+      />
     </Switch>
   );
 };

+ 1 - 1
kafka-ui-react-app/src/components/common/Button/Button.tsx

@@ -8,7 +8,7 @@ interface Props
   extends React.ButtonHTMLAttributes<HTMLButtonElement>,
     ButtonProps {
   isLink?: boolean;
-  to?: string;
+  to?: string | object;
 }
 
 export const Button: React.FC<Props> = ({ isLink, to, ...props }) => {

+ 51 - 0
kafka-ui-react-app/src/components/common/DiffViewer/DiffViewer.tsx

@@ -0,0 +1,51 @@
+import { diff as DiffEditor } from 'react-ace';
+import 'ace-builds/src-noconflict/mode-json5';
+import 'ace-builds/src-noconflict/mode-protobuf';
+import 'ace-builds/src-noconflict/theme-textmate';
+import React from 'react';
+import { IDiffEditorProps } from 'react-ace/lib/diff';
+import { SchemaType } from 'generated-sources';
+
+interface DiffViewerProps extends IDiffEditorProps {
+  isFixedHeight?: boolean;
+  schemaType: string;
+}
+
+const DiffViewer = React.forwardRef<DiffEditor | null, DiffViewerProps>(
+  (props, ref) => {
+    const { isFixedHeight, schemaType, ...rest } = props;
+    const autoHeight =
+      !isFixedHeight && props.value && props.value.length === 2
+        ? Math.max(
+            props.value[0].split(/\r\n|\r|\n/).length + 1,
+            props.value[1].split(/\r\n|\r|\n/).length + 1
+          ) * 16
+        : 500;
+    return (
+      <div data-testid="diffviewer">
+        <DiffEditor
+          name="diff-editor"
+          ref={ref}
+          mode={
+            schemaType === SchemaType.JSON || schemaType === SchemaType.AVRO
+              ? 'json5'
+              : 'protobuf'
+          }
+          theme="textmate"
+          tabSize={2}
+          width="100%"
+          height={`${autoHeight}px`}
+          showPrintMargin={false}
+          maxLines={Infinity}
+          readOnly
+          wrapEnabled
+          {...rest}
+        />
+      </div>
+    );
+  }
+);
+
+DiffViewer.displayName = 'DiffViewer';
+
+export default DiffViewer;

+ 56 - 0
kafka-ui-react-app/src/components/common/DiffViewer/__tests__/DiffViewer.spec.tsx

@@ -0,0 +1,56 @@
+import React from 'react';
+import { render } from 'lib/testHelpers';
+import DiffViewer from 'components/common/DiffViewer/DiffViewer';
+import { screen } from '@testing-library/react';
+
+describe('Editor component', () => {
+  const left = '{\n}';
+  const right = '{\ntest: true\n}';
+  const renderComponent = (props: {
+    leftVersion?: string;
+    rightVersion?: string;
+    isFixedHeight?: boolean;
+  }) => {
+    render(
+      <DiffViewer
+        value={[props.leftVersion ?? '', props.rightVersion ?? '']}
+        name="name"
+        schemaType="JSON"
+        isFixedHeight={props.isFixedHeight}
+      />
+    );
+  };
+
+  it('renders', () => {
+    renderComponent({ leftVersion: left, rightVersion: right });
+    expect(screen.getByTestId('diffviewer')).toBeInTheDocument();
+  });
+
+  it('renders with fixed height', () => {
+    renderComponent({
+      leftVersion: left,
+      rightVersion: right,
+      isFixedHeight: true,
+    });
+    const wrapper = screen.getByTestId('diffviewer');
+    expect(wrapper.firstChild).toHaveStyle('height: 500px');
+  });
+
+  it('renders with fixed height with no value', () => {
+    renderComponent({ isFixedHeight: true });
+    const wrapper = screen.getByTestId('diffviewer');
+    expect(wrapper.firstChild).toHaveStyle('height: 500px');
+  });
+
+  it('renders without fixed height with no value', () => {
+    renderComponent({});
+    const wrapper = screen.getByTestId('diffviewer');
+    expect(wrapper.firstChild).toHaveStyle('height: 32px');
+  });
+
+  it('renders without fixed height with one value', () => {
+    renderComponent({ leftVersion: left });
+    const wrapper = screen.getByTestId('diffviewer');
+    expect(wrapper.firstChild).toHaveStyle('height: 48px');
+  });
+});

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

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