#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>
This commit is contained in:
Si Tang 2022-02-21 22:12:27 +09:00 committed by GitHub
parent 4cc4175ef2
commit dd42dbe0cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 562 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
},
];

View file

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

View file

@ -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 }) => {

View file

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

View file

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

View file

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