Schema registry pagination and search

* [ISSUE-1191, ISSUE-1208] Implemented search and pagination for schema registry overview page.

* [ISSUE-1191, ISSUE-1208] Implemented search and pagination for schema registry overview page.

* [ISSUE-1191, ISSUE-1208] Implemented search and pagination for schema registry overview page.

* [ISSUE-1191, ISSUE-1208] fixed Checkstyle violation issue.

* WIP: Fixes some frontend issues just to build frontend

* WIP: Fixes fronted just to build it

* WIP: Fixes frontend to build it

* WIP: Schemas tests are failing

* WIP: List tests work

* WIP: Details test work

* WIP: Updates tests

* WIP: Fixes lint errors and comments

* WIP: Changes usePagination, some tests have warns

* WIP: Refreshes with query string works correctly

* cleanup

* WIP: cleanup

* WIP: cleanup

* WIP: Removes ThemeProvider from test as render function uses ThemeProvider

* WIP: Pagination + Search works correcly

* WIP: Cleanup

* WIP: Cleanup

* WIP: Cleanup

Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
Co-authored-by: Damir Abdulganiev <dabdulganiev@provectus.com>
Co-authored-by: Damir Abdulganiev <damupka@gmail.com>
This commit is contained in:
ValentinPrischepa 2022-01-31 02:24:39 -08:00 committed by GitHub
parent a24696cc30
commit 540b8eb79b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 766 additions and 1107 deletions

View file

@ -7,10 +7,17 @@ import com.provectus.kafka.ui.model.CompatibilityLevelDTO;
import com.provectus.kafka.ui.model.KafkaCluster;
import com.provectus.kafka.ui.model.NewSchemaSubjectDTO;
import com.provectus.kafka.ui.model.SchemaSubjectDTO;
import com.provectus.kafka.ui.model.SchemaSubjectsResponseDTO;
import com.provectus.kafka.ui.service.SchemaRegistryService;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
@ -22,6 +29,8 @@ import reactor.core.publisher.Mono;
@Slf4j
public class SchemasController extends AbstractController implements SchemasApi {
private static final Integer DEFAULT_PAGE_SIZE = 25;
private final SchemaRegistryService schemaRegistryService;
@Override
@ -102,12 +111,29 @@ public class SchemasController extends AbstractController implements SchemasApi
}
@Override
public Mono<ResponseEntity<Flux<SchemaSubjectDTO>>> getSchemas(String clusterName,
ServerWebExchange exchange) {
Flux<SchemaSubjectDTO> subjects = schemaRegistryService.getAllLatestVersionSchemas(
getCluster(clusterName)
);
return Mono.just(ResponseEntity.ok(subjects));
public Mono<ResponseEntity<SchemaSubjectsResponseDTO>> getSchemas(String clusterName,
@Valid Integer pageNum,
@Valid Integer perPage,
@Valid String search,
ServerWebExchange serverWebExchange) {
return schemaRegistryService
.getAllSubjectNames(getCluster(clusterName))
.flatMap(subjects -> {
int pageSize = perPage != null && perPage > 0 ? perPage : DEFAULT_PAGE_SIZE;
int subjectToSkip = ((pageNum != null && pageNum > 0 ? pageNum : 1) - 1) * pageSize;
List<String> filteredSubjects = Arrays.stream(subjects)
.filter(subj -> search == null || StringUtils.containsIgnoreCase(subj, search))
.sorted()
.collect(Collectors.toList());
var totalPages = (filteredSubjects.size() / pageSize)
+ (filteredSubjects.size() % pageSize == 0 ? 0 : 1);
List<String> subjectsToRender = filteredSubjects.stream()
.skip(subjectToSkip)
.limit(pageSize)
.collect(Collectors.toList());
return schemaRegistryService.getAllLatestVersionSchemas(getCluster(clusterName), subjectsToRender)
.map(a -> new SchemaSubjectsResponseDTO().pageCount(totalPages).schemas(a));
}).map(ResponseEntity::ok);
}
@Override

View file

@ -22,9 +22,11 @@ import com.provectus.kafka.ui.model.schemaregistry.InternalCompatibilityLevel;
import com.provectus.kafka.ui.model.schemaregistry.InternalNewSchema;
import com.provectus.kafka.ui.model.schemaregistry.SubjectIdResponse;
import java.util.Formatter;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
@ -43,6 +45,7 @@ import reactor.core.publisher.Mono;
@Slf4j
@RequiredArgsConstructor
public class SchemaRegistryService {
public static final String NO_SUCH_SCHEMA_VERSION = "No such schema %s with version %s";
public static final String NO_SUCH_SCHEMA = "No such schema %s";
@ -57,11 +60,11 @@ public class SchemaRegistryService {
private final ClusterMapper mapper;
private final WebClient webClient;
public Flux<SchemaSubjectDTO> getAllLatestVersionSchemas(KafkaCluster cluster) {
var allSubjectNames = getAllSubjectNames(cluster);
return allSubjectNames
.flatMapMany(Flux::fromArray)
.flatMap(subject -> getLatestSchemaVersionBySubject(cluster, subject));
public Mono<List<SchemaSubjectDTO>> getAllLatestVersionSchemas(KafkaCluster cluster,
List<String> subjects) {
return Flux.fromIterable(subjects)
.concatMap(subject -> getLatestSchemaVersionBySubject(cluster, subject))
.collect(Collectors.toList());
}
public Mono<String[]> getAllSubjectNames(KafkaCluster cluster) {

View file

@ -3,6 +3,7 @@ package com.provectus.kafka.ui;
import com.provectus.kafka.ui.model.CompatibilityLevelDTO;
import com.provectus.kafka.ui.model.NewSchemaSubjectDTO;
import com.provectus.kafka.ui.model.SchemaSubjectDTO;
import com.provectus.kafka.ui.model.SchemaSubjectsResponseDTO;
import com.provectus.kafka.ui.model.SchemaTypeDTO;
import java.util.List;
import java.util.UUID;
@ -145,14 +146,14 @@ class SchemaRegistryServiceTests extends AbstractBaseTest {
.uri("/api/clusters/{clusterName}/schemas", LOCAL)
.exchange()
.expectStatus().isOk()
.expectBodyList(SchemaSubjectDTO.class)
.expectBody(SchemaSubjectsResponseDTO.class)
.consumeWith(result -> {
List<SchemaSubjectDTO> responseBody = result.getResponseBody();
SchemaSubjectsResponseDTO responseBody = result.getResponseBody();
log.info("Response of test schemas: {}", responseBody);
Assertions.assertNotNull(responseBody);
Assertions.assertFalse(responseBody.isEmpty());
Assertions.assertFalse(responseBody.getSchemas().isEmpty());
SchemaSubjectDTO actualSchemaSubject = responseBody.stream()
SchemaSubjectDTO actualSchemaSubject = responseBody.getSchemas().stream()
.filter(schemaSubject -> subject.equals(schemaSubject.getSubject()))
.findFirst()
.orElseThrow();

View file

@ -0,0 +1,118 @@
package com.provectus.kafka.ui.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.provectus.kafka.ui.controller.SchemasController;
import com.provectus.kafka.ui.model.InternalSchemaRegistry;
import com.provectus.kafka.ui.model.KafkaCluster;
import com.provectus.kafka.ui.model.SchemaSubjectDTO;
import java.util.Comparator;
import java.util.Optional;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
public class SchemaRegistryPaginationTest {
private static final String LOCAL_KAFKA_CLUSTER_NAME = "local";
private final SchemaRegistryService schemaRegistryService = mock(SchemaRegistryService.class);
private final ClustersStorage clustersStorage = mock(ClustersStorage.class);
private SchemasController controller;
private void init(String[] subjects) {
when(schemaRegistryService.getAllSubjectNames(isA(KafkaCluster.class)))
.thenReturn(Mono.just(subjects));
when(schemaRegistryService
.getAllLatestVersionSchemas(isA(KafkaCluster.class), anyList())).thenCallRealMethod();
when(clustersStorage.getClusterByName(isA(String.class)))
.thenReturn(Optional.of(buildKafkaCluster(LOCAL_KAFKA_CLUSTER_NAME)));
when(schemaRegistryService.getLatestSchemaVersionBySubject(isA(KafkaCluster.class), isA(String.class)))
.thenAnswer(a -> Mono.just(new SchemaSubjectDTO().subject(a.getArgument(1))));
this.controller = new SchemasController(schemaRegistryService);
this.controller.setClustersStorage(clustersStorage);
}
@Test
void shouldListFirst25andThen10Schemas() {
init(
IntStream.rangeClosed(1, 100)
.boxed()
.map(num -> "subject" + num)
.toArray(String[]::new)
);
var schemasFirst25 = controller.getSchemas(LOCAL_KAFKA_CLUSTER_NAME,
null, null, null, null).block();
assertThat(schemasFirst25.getBody().getPageCount()).isEqualTo(4);
assertThat(schemasFirst25.getBody().getSchemas()).hasSize(25);
assertThat(schemasFirst25.getBody().getSchemas())
.isSortedAccordingTo(Comparator.comparing(SchemaSubjectDTO::getSubject));
var schemasFirst10 = controller.getSchemas(LOCAL_KAFKA_CLUSTER_NAME,
null, 10, null, null).block();
assertThat(schemasFirst10.getBody().getPageCount()).isEqualTo(10);
assertThat(schemasFirst10.getBody().getSchemas()).hasSize(10);
assertThat(schemasFirst10.getBody().getSchemas())
.isSortedAccordingTo(Comparator.comparing(SchemaSubjectDTO::getSubject));
}
@Test
void shouldListSchemasContaining_1() {
init(
IntStream.rangeClosed(1, 100)
.boxed()
.map(num -> "subject" + num)
.toArray(String[]::new)
);
var schemasSearch7 = controller.getSchemas(LOCAL_KAFKA_CLUSTER_NAME,
null, null, "1", null).block();
assertThat(schemasSearch7.getBody().getPageCount()).isEqualTo(1);
assertThat(schemasSearch7.getBody().getSchemas()).hasSize(20);
}
@Test
void shouldCorrectlyHandleNonPositivePageNumberAndPageSize() {
init(
IntStream.rangeClosed(1, 100)
.boxed()
.map(num -> "subject" + num)
.toArray(String[]::new)
);
var schemas = controller.getSchemas(LOCAL_KAFKA_CLUSTER_NAME,
0, -1, null, null).block();;
assertThat(schemas.getBody().getPageCount()).isEqualTo(4);
assertThat(schemas.getBody().getSchemas()).hasSize(25);
assertThat(schemas.getBody().getSchemas()).isSortedAccordingTo(Comparator.comparing(SchemaSubjectDTO::getSubject));
}
@Test
void shouldCalculateCorrectPageCountForNonDivisiblePageSize() {
init(
IntStream.rangeClosed(1, 100)
.boxed()
.map(num -> "subject" + num)
.toArray(String[]::new)
);
var schemas = controller.getSchemas(LOCAL_KAFKA_CLUSTER_NAME,
4, 33, null, null).block();
assertThat(schemas.getBody().getPageCount()).isEqualTo(4);
assertThat(schemas.getBody().getSchemas()).hasSize(1);
assertThat(schemas.getBody().getSchemas().get(0).getSubject()).isEqualTo("subject99");
}
private KafkaCluster buildKafkaCluster(String clusterName) {
return KafkaCluster.builder()
.name(clusterName)
.schemaRegistry(InternalSchemaRegistry.builder().build())
.build();
}
}

View file

@ -790,15 +790,28 @@ paths:
required: true
schema:
type: string
- name: page
in: query
required: false
schema:
type: integer
- name: perPage
in: query
required: false
schema:
type: integer
- name: search
in: query
required: false
schema:
type: string
responses:
200:
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SchemaSubject'
$ref: '#/components/schemas/SchemaSubjectsResponse'
/api/clusters/{clusterName}/schemas/{subject}:
delete:
@ -2248,6 +2261,16 @@ components:
required:
- isCompatible
SchemaSubjectsResponse:
type: object
properties:
pageCount:
type: integer
schemas:
type: array
items:
$ref: '#/components/schemas/SchemaSubject'
Connect:
type: object
properties:

View file

@ -13,15 +13,19 @@ import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchLatestSchema,
fetchSchemaVersions,
getAreSchemasFulfilled,
getAreSchemaLatestFulfilled,
getAreSchemaVersionsFulfilled,
schemasApiClient,
SCHEMAS_VERSIONS_FETCH_ACTION,
SCHEMA_LATEST_FETCH_ACTION,
selectAllSchemaVersions,
selectSchemaById,
getSchemaLatest,
} from 'redux/reducers/schemas/schemasSlice';
import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice';
import { getResponse } from 'lib/errorHandling';
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
import LatestVersionItem from './LatestVersion/LatestVersionItem';
import SchemaVersion from './SchemaVersion/SchemaVersion';
@ -39,15 +43,23 @@ const Details: React.FC = () => {
] = React.useState(false);
React.useEffect(() => {
dispatch(fetchSchemaVersions({ clusterName, subject }));
dispatch(fetchLatestSchema({ clusterName, subject }));
return () => {
dispatch(resetLoaderById(SCHEMA_LATEST_FETCH_ACTION));
};
}, []);
const areSchemasFetched = useAppSelector(getAreSchemasFulfilled);
React.useEffect(() => {
dispatch(fetchSchemaVersions({ clusterName, subject }));
return () => {
dispatch(resetLoaderById(SCHEMAS_VERSIONS_FETCH_ACTION));
};
}, [clusterName, subject]);
const versions = useAppSelector((state) => selectAllSchemaVersions(state));
const schema = useAppSelector(getSchemaLatest);
const isFetched = useAppSelector(getAreSchemaLatestFulfilled);
const areVersionsFetched = useAppSelector(getAreSchemaVersionsFulfilled);
const schema = useAppSelector((state) => selectSchemaById(state, subject));
const versions = useAppSelector((state) =>
selectAllSchemaVersions(state).filter((v) => v.subject === subject)
);
const onDelete = React.useCallback(async () => {
try {
@ -62,7 +74,7 @@ const Details: React.FC = () => {
}
}, [clusterName, subject]);
if (!areSchemasFetched || !schema) {
if (!isFetched || !schema) {
return <PageLoader />;
}

View file

@ -5,111 +5,119 @@ import { Route } from 'react-router';
import { clusterSchemaPath } from 'lib/paths';
import { screen, waitFor } from '@testing-library/dom';
import {
schemasFulfilledState,
schemasInitialState,
schemaVersion,
} from 'redux/reducers/schemas/__test__/fixtures';
import fetchMock from 'fetch-mock';
import ClusterContext, {
ContextProps,
initialValue as contextInitialValue,
} from 'components/contexts/ClusterContext';
import { RootState } from 'redux/interfaces';
import { versionPayload, versionEmptyPayload } from './fixtures';
const clusterName = 'testClusterName';
const subject = 'schema7_1';
const schemasAPILatestUrl = `/api/clusters/${clusterName}/schemas/${schemaVersion.subject}/latest`;
const schemasAPIVersionsUrl = `/api/clusters/${clusterName}/schemas/${schemaVersion.subject}/versions`;
const renderComponent = (
initialState: RootState['schemas'] = schemasInitialState,
context: ContextProps = contextInitialValue
) => {
return render(
<Route path={clusterSchemaPath(':clusterName', ':subject')}>
<ClusterContext.Provider value={context}>
<Details />
</ClusterContext.Provider>
</Route>,
{
pathname: clusterSchemaPath(clusterName, schemaVersion.subject),
preloadedState: {
schemas: initialState,
},
}
);
};
describe('Details', () => {
describe('for an initial state', () => {
it('renders pageloader', () => {
render(
<Route path={clusterSchemaPath(':clusterName', ':subject')}>
<Details />
</Route>,
{
pathname: clusterSchemaPath(clusterName, subject),
preloadedState: {},
}
afterEach(() => fetchMock.reset());
describe('fetch failed', () => {
beforeEach(async () => {
const schemasAPILatestMock = fetchMock.getOnce(schemasAPILatestUrl, 404);
const schemasAPIVersionsMock = fetchMock.getOnce(
schemasAPIVersionsUrl,
404
);
renderComponent();
await waitFor(() => {
expect(schemasAPILatestMock.called()).toBeTruthy();
});
await waitFor(() => {
expect(schemasAPIVersionsMock.called()).toBeTruthy();
});
});
it('renders pageloader', () => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByText(subject)).not.toBeInTheDocument();
expect(screen.queryByText(schemaVersion.subject)).not.toBeInTheDocument();
expect(screen.queryByText('Edit Schema')).not.toBeInTheDocument();
expect(screen.queryByText('Remove Schema')).not.toBeInTheDocument();
});
});
describe('for a loaded scheme', () => {
beforeEach(() => {
render(
<Route path={clusterSchemaPath(':clusterName', ':subject')}>
<Details />
</Route>,
{
pathname: clusterSchemaPath(clusterName, subject),
preloadedState: {
loader: {
'schemas/fetch': 'fulfilled',
},
schemas: schemasFulfilledState,
},
}
);
describe('fetch success', () => {
describe('has schema versions', () => {
beforeEach(async () => {
const schemasAPILatestMock = fetchMock.getOnce(
schemasAPILatestUrl,
schemaVersion
);
const schemasAPIVersionsMock = fetchMock.getOnce(
schemasAPIVersionsUrl,
versionPayload
);
renderComponent();
await waitFor(() => {
expect(schemasAPILatestMock.called()).toBeTruthy();
});
await waitFor(() => {
expect(schemasAPIVersionsMock.called()).toBeTruthy();
});
});
it('renders component with schema info', () => {
expect(screen.getByText('Edit Schema')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
it('renders component with shema info', () => {
expect(screen.getByText('Edit Schema')).toBeInTheDocument();
});
describe('empty schema versions', () => {
beforeEach(async () => {
const schemasAPILatestMock = fetchMock.getOnce(
schemasAPILatestUrl,
schemaVersion
);
const schemasAPIVersionsMock = fetchMock.getOnce(
schemasAPIVersionsUrl,
versionEmptyPayload
);
renderComponent();
await waitFor(() => {
expect(schemasAPILatestMock.called()).toBeTruthy();
});
await waitFor(() => {
expect(schemasAPIVersionsMock.called()).toBeTruthy();
});
});
it('renders progressbar for versions block', () => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});
describe('for a loaded scheme and versions', () => {
afterEach(() => fetchMock.restore());
it('renders versions table', async () => {
const mock = fetchMock.getOnce(
`/api/clusters/${clusterName}/schemas/${subject}/versions`,
[schemaVersion]
);
render(
<Route path={clusterSchemaPath(':clusterName', ':subject')}>
<Details />
</Route>,
{
pathname: clusterSchemaPath(clusterName, subject),
preloadedState: {
loader: {
'schemas/fetch': 'fulfilled',
},
schemas: schemasFulfilledState,
},
}
);
await waitFor(() => expect(mock.called()).toBeTruthy());
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders versions table with 0 items', async () => {
const mock = fetchMock.getOnce(
`/api/clusters/${clusterName}/schemas/${subject}/versions`,
[]
);
render(
<Route path={clusterSchemaPath(':clusterName', ':subject')}>
<Details />
</Route>,
{
pathname: clusterSchemaPath(clusterName, subject),
preloadedState: {
loader: {
'schemas/fetch': 'fulfilled',
},
schemas: schemasFulfilledState,
},
}
);
await waitFor(() => expect(mock.called()).toBeTruthy());
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No active Schema')).toBeInTheDocument();
// seems like incorrect behaviour
it('renders versions table with 0 items', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('No active Schema')).toBeInTheDocument();
});
});
});
});

View file

@ -1,47 +1,36 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import LatestVersionItem from 'components/Schemas/Details/LatestVersion/LatestVersionItem';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
import { SchemaSubject } from 'generated-sources';
import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
import { jsonSchema, protoSchema } from './fixtures';
const renderComponent = (schema: SchemaSubject) => {
render(<LatestVersionItem schema={schema} />);
};
describe('LatestVersionItem', () => {
it('renders latest version of json schema', () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
<LatestVersionItem schema={jsonSchema} />
</ThemeProvider>
);
expect(wrapper.find('div[data-testid="meta-data"]').length).toEqual(1);
expect(
wrapper.find('div[data-testid="meta-data"] > div:first-child > p').text()
).toEqual('1');
expect(wrapper.exists('EditorViewer')).toBeTruthy();
renderComponent(jsonSchema);
expect(screen.getByText('Relevant version')).toBeInTheDocument();
expect(screen.getByText('Latest version')).toBeInTheDocument();
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Subject')).toBeInTheDocument();
expect(screen.getByText('Compatibility')).toBeInTheDocument();
expect(screen.getByText('15')).toBeInTheDocument();
expect(screen.getByTestId('json-viewer')).toBeInTheDocument();
});
it('renders latest version of compatibility', () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
<LatestVersionItem schema={protoSchema} />
</ThemeProvider>
);
renderComponent(protoSchema);
expect(screen.getByText('Relevant version')).toBeInTheDocument();
expect(screen.getByText('Latest version')).toBeInTheDocument();
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Subject')).toBeInTheDocument();
expect(screen.getByText('Compatibility')).toBeInTheDocument();
expect(wrapper.find('div[data-testid="meta-data"]').length).toEqual(1);
expect(
wrapper.find('div[data-testid="meta-data"] > div:last-child > p').text()
).toEqual('BACKWARD');
expect(wrapper.exists('EditorViewer')).toBeTruthy();
});
it('matches snapshot', () => {
expect(
shallow(
<ThemeProvider theme={theme}>
<LatestVersionItem schema={jsonSchema} />
</ThemeProvider>
)
).toMatchSnapshot();
expect(screen.getByText('BACKWARD')).toBeInTheDocument();
expect(screen.getByTestId('json-viewer')).toBeInTheDocument();
});
});

View file

@ -1,36 +1,26 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import SchemaVersion from 'components/Schemas/Details/SchemaVersion/SchemaVersion';
import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme';
import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { versions } from './fixtures';
const renderComponent = () => {
render(
<table>
<tbody>
<SchemaVersion version={versions[0]} />
</tbody>
</table>
);
};
describe('SchemaVersion', () => {
it('renders versions', () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
<table>
<tbody>
<SchemaVersion version={versions[0]} />
</tbody>
</table>
</ThemeProvider>
);
expect(wrapper.find('td').length).toEqual(3);
expect(wrapper.exists('Editor')).toBeFalsy();
wrapper.find('span').simulate('click');
expect(wrapper.exists('Editor')).toBeTruthy();
});
it('matches snapshot', () => {
expect(
shallow(
<ThemeProvider theme={theme}>
<SchemaVersion version={versions[0]} />
</ThemeProvider>
)
).toMatchSnapshot();
renderComponent();
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.queryByTestId('json-viewer')).not.toBeInTheDocument();
userEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('json-viewer')).toBeInTheDocument();
});
});

View file

@ -1,368 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LatestVersionItem matches snapshot 1`] = `
<ContextProvider
value={
Object {
"alert": Object {
"color": Object {
"error": "#FAD1D1",
"info": "#E3E6E8",
"success": "#D6F5E0",
"warning": "#FFEECC",
},
"shadow": "rgba(0, 0, 0, 0.1)",
},
"breadcrumb": "#ABB5BA",
"button": Object {
"border": Object {
"active": "#171A1C",
"hover": "#454F54",
"normal": "#73848C",
},
"fontSize": Object {
"L": "16px",
"M": "14px",
"S": "14px",
},
"height": Object {
"L": "40px",
"M": "32px",
"S": "24px",
},
"primary": Object {
"backgroundColor": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
"color": "#FFFFFF",
"invertedColors": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
},
"secondary": Object {
"backgroundColor": Object {
"active": "#D5DADD",
"hover": "#E3E6E8",
"normal": "#F1F2F3",
},
"color": "#171A1C",
"invertedColors": Object {
"active": "#171A1C",
"hover": "#454F54",
"normal": "#73848C",
},
},
},
"circularAlert": Object {
"color": Object {
"error": "#E51A1A",
"info": "#E3E6E8",
"success": "#5CD685",
"warning": "#FFEECC",
},
},
"configList": Object {
"color": "#ABB5BA",
},
"connectEditWarning": "#FFEECC",
"consumerTopicContent": Object {
"backgroundColor": "#F1F2F3",
},
"dangerZone": Object {
"borderColor": "#E3E6E8",
"color": "#E51A1A",
},
"dropdown": Object {
"color": "#E51A1A",
},
"heading": Object {
"h1": Object {
"color": "#171A1C",
},
"h3": Object {
"color": "#73848C",
"fontSize": "14px",
},
},
"icons": Object {
"closeIcon": "#ABB5BA",
"liveIcon": Object {
"circleBig": "#FAD1D1",
"circleSmall": "#E51A1A",
},
"messageToggleIconClosed": "#ABB5BA",
"messageToggleIconOpened": "#171A1C",
"verticalElipsisIcon": "#73848C",
},
"input": Object {
"backgroundColor": Object {
"readOnly": "#F1F2F3",
},
"borderColor": Object {
"disabled": "#E3E6E8",
"focus": "#454F54",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"disabled": "#ABB5BA",
"placeholder": Object {
"normal": "#ABB5BA",
"readOnly": "#ABB5BA",
},
"readOnly": "#171A1C",
},
"error": "#E51A1A",
"icon": Object {
"color": "#454F54",
},
"label": Object {
"color": "#454F54",
},
},
"layout": Object {
"minWidth": "1200px",
"navBarHeight": "3.25rem",
"navBarWidth": "201px",
"overlay": Object {
"backgroundColor": "#73848C",
},
"stuffBorderColor": "#E3E6E8",
"stuffColor": "#F1F2F3",
},
"menu": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"chevronIconColor": "#73848C",
"color": Object {
"active": "#171A1C",
"hover": "#73848C",
"normal": "#73848C",
},
"statusIconColor": Object {
"offline": "#E51A1A",
"online": "#5CD685",
},
},
"metrics": Object {
"backgroundColor": "#F1F2F3",
"filters": Object {
"color": Object {
"icon": "#171A1C",
"normal": "#73848C",
},
},
"indicator": Object {
"backgroundColor": "#FFFFFF",
"lightTextColor": "#ABB5BA",
"titleColor": "#73848C",
"warningTextColor": "#E51A1A",
},
},
"modal": Object {
"backgroundColor": "#FFFFFF",
"border": Object {
"bottom": "#F1F2F3",
"top": "#F1F2F3",
},
"overlay": "rgba(10, 10, 10, 0.1)",
"shadow": "rgba(0, 0, 0, 0.1)",
},
"pageLoader": Object {
"borderBottomColor": "#FFFFFF",
"borderColor": "#4F4FFF",
},
"pagination": Object {
"backgroundColor": "#FFFFFF",
"borderColor": Object {
"active": "#454F54",
"disabled": "#C7CED1",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#C7CED1",
"hover": "#171A1C",
"normal": "#171A1C",
},
"currentPage": "#E3E6E8",
},
"panelColor": "#FFFFFF",
"primaryTab": Object {
"borderColor": Object {
"active": "#4F4FFF",
"hover": "transparent",
"nav": "#E3E6E8",
"normal": "transparent",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"schema": Object {
"backgroundColor": Object {
"div": "#FFFFFF",
"tr": "#F1F2F3",
},
},
"scrollbar": Object {
"thumbColor": Object {
"active": "#73848C",
"normal": "#FFFFFF",
},
"trackColor": Object {
"active": "#F1F2F3",
"normal": "#FFFFFF",
},
},
"secondaryTab": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"select": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#E3E6E8",
"normal": "#FFFFFF",
},
"borderColor": Object {
"active": "#454F54",
"disabled": "#E3E6E8",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#ABB5BA",
"hover": "#171A1C",
"normal": "#171A1C",
},
},
"switch": Object {
"checked": "#29A352",
"circle": "#FFFFFF",
"unchecked": "#ABB5BA",
},
"table": Object {
"link": Object {
"color": Object {
"normal": "#171A1C",
},
},
"td": Object {
"color": Object {
"normal": "#171A1C",
},
},
"th": Object {
"backgroundColor": Object {
"normal": "#FFFFFF",
},
"color": Object {
"active": "#4F4FFF",
"hover": "#4F4FFF",
"normal": "#73848C",
},
"previewColor": Object {
"normal": "#4F4FFF",
},
},
"tr": Object {
"backgroundColor": Object {
"hover": "#F1F2F3",
},
},
},
"tag": Object {
"backgroundColor": Object {
"blue": "#e3f2fd",
"gray": "#F1F2F3",
"green": "#D6F5E0",
"red": "#FAD1D1",
"white": "#E3E6E8",
"yellow": "#FFEECC",
},
"color": "#171A1C",
},
"textArea": Object {
"backgroundColor": Object {
"readOnly": "#F1F2F3",
},
"borderColor": Object {
"disabled": "#E3E6E8",
"focus": "#454F54",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"disabled": "#ABB5BA",
"placeholder": Object {
"focus": Object {
"normal": "transparent",
"readOnly": "#ABB5BA",
},
"normal": "#ABB5BA",
},
"readOnly": "#171A1C",
},
},
"topicFormLabel": Object {
"color": "#73848C",
},
"topicMetaData": Object {
"backgroundColor": "#F1F2F3",
"color": Object {
"label": "#73848C",
"meta": "#ABB5BA",
"value": "#2F3639",
},
},
"topicsList": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
},
"color": Object {
"active": "#171A1C",
"hover": "#73848C",
"normal": "#171A1C",
},
},
"viewer": Object {
"wrapper": "#f9fafa",
},
}
}
>
<LatestVersionItem
schema={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"schemaType": "JSON",
"subject": "test",
"version": "1",
}
}
/>
</ContextProvider>
`;

View file

@ -1,368 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SchemaVersion matches snapshot 1`] = `
<ContextProvider
value={
Object {
"alert": Object {
"color": Object {
"error": "#FAD1D1",
"info": "#E3E6E8",
"success": "#D6F5E0",
"warning": "#FFEECC",
},
"shadow": "rgba(0, 0, 0, 0.1)",
},
"breadcrumb": "#ABB5BA",
"button": Object {
"border": Object {
"active": "#171A1C",
"hover": "#454F54",
"normal": "#73848C",
},
"fontSize": Object {
"L": "16px",
"M": "14px",
"S": "14px",
},
"height": Object {
"L": "40px",
"M": "32px",
"S": "24px",
},
"primary": Object {
"backgroundColor": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
"color": "#FFFFFF",
"invertedColors": Object {
"active": "#1414B8",
"hover": "#1717CF",
"normal": "#4F4FFF",
},
},
"secondary": Object {
"backgroundColor": Object {
"active": "#D5DADD",
"hover": "#E3E6E8",
"normal": "#F1F2F3",
},
"color": "#171A1C",
"invertedColors": Object {
"active": "#171A1C",
"hover": "#454F54",
"normal": "#73848C",
},
},
},
"circularAlert": Object {
"color": Object {
"error": "#E51A1A",
"info": "#E3E6E8",
"success": "#5CD685",
"warning": "#FFEECC",
},
},
"configList": Object {
"color": "#ABB5BA",
},
"connectEditWarning": "#FFEECC",
"consumerTopicContent": Object {
"backgroundColor": "#F1F2F3",
},
"dangerZone": Object {
"borderColor": "#E3E6E8",
"color": "#E51A1A",
},
"dropdown": Object {
"color": "#E51A1A",
},
"heading": Object {
"h1": Object {
"color": "#171A1C",
},
"h3": Object {
"color": "#73848C",
"fontSize": "14px",
},
},
"icons": Object {
"closeIcon": "#ABB5BA",
"liveIcon": Object {
"circleBig": "#FAD1D1",
"circleSmall": "#E51A1A",
},
"messageToggleIconClosed": "#ABB5BA",
"messageToggleIconOpened": "#171A1C",
"verticalElipsisIcon": "#73848C",
},
"input": Object {
"backgroundColor": Object {
"readOnly": "#F1F2F3",
},
"borderColor": Object {
"disabled": "#E3E6E8",
"focus": "#454F54",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"disabled": "#ABB5BA",
"placeholder": Object {
"normal": "#ABB5BA",
"readOnly": "#ABB5BA",
},
"readOnly": "#171A1C",
},
"error": "#E51A1A",
"icon": Object {
"color": "#454F54",
},
"label": Object {
"color": "#454F54",
},
},
"layout": Object {
"minWidth": "1200px",
"navBarHeight": "3.25rem",
"navBarWidth": "201px",
"overlay": Object {
"backgroundColor": "#73848C",
},
"stuffBorderColor": "#E3E6E8",
"stuffColor": "#F1F2F3",
},
"menu": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"chevronIconColor": "#73848C",
"color": Object {
"active": "#171A1C",
"hover": "#73848C",
"normal": "#73848C",
},
"statusIconColor": Object {
"offline": "#E51A1A",
"online": "#5CD685",
},
},
"metrics": Object {
"backgroundColor": "#F1F2F3",
"filters": Object {
"color": Object {
"icon": "#171A1C",
"normal": "#73848C",
},
},
"indicator": Object {
"backgroundColor": "#FFFFFF",
"lightTextColor": "#ABB5BA",
"titleColor": "#73848C",
"warningTextColor": "#E51A1A",
},
},
"modal": Object {
"backgroundColor": "#FFFFFF",
"border": Object {
"bottom": "#F1F2F3",
"top": "#F1F2F3",
},
"overlay": "rgba(10, 10, 10, 0.1)",
"shadow": "rgba(0, 0, 0, 0.1)",
},
"pageLoader": Object {
"borderBottomColor": "#FFFFFF",
"borderColor": "#4F4FFF",
},
"pagination": Object {
"backgroundColor": "#FFFFFF",
"borderColor": Object {
"active": "#454F54",
"disabled": "#C7CED1",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#C7CED1",
"hover": "#171A1C",
"normal": "#171A1C",
},
"currentPage": "#E3E6E8",
},
"panelColor": "#FFFFFF",
"primaryTab": Object {
"borderColor": Object {
"active": "#4F4FFF",
"hover": "transparent",
"nav": "#E3E6E8",
"normal": "transparent",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"schema": Object {
"backgroundColor": Object {
"div": "#FFFFFF",
"tr": "#F1F2F3",
},
},
"scrollbar": Object {
"thumbColor": Object {
"active": "#73848C",
"normal": "#FFFFFF",
},
"trackColor": Object {
"active": "#F1F2F3",
"normal": "#FFFFFF",
},
},
"secondaryTab": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
"normal": "#FFFFFF",
},
"color": Object {
"active": "#171A1C",
"hover": "#171A1C",
"normal": "#73848C",
},
},
"select": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#E3E6E8",
"normal": "#FFFFFF",
},
"borderColor": Object {
"active": "#454F54",
"disabled": "#E3E6E8",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"active": "#171A1C",
"disabled": "#ABB5BA",
"hover": "#171A1C",
"normal": "#171A1C",
},
},
"switch": Object {
"checked": "#29A352",
"circle": "#FFFFFF",
"unchecked": "#ABB5BA",
},
"table": Object {
"link": Object {
"color": Object {
"normal": "#171A1C",
},
},
"td": Object {
"color": Object {
"normal": "#171A1C",
},
},
"th": Object {
"backgroundColor": Object {
"normal": "#FFFFFF",
},
"color": Object {
"active": "#4F4FFF",
"hover": "#4F4FFF",
"normal": "#73848C",
},
"previewColor": Object {
"normal": "#4F4FFF",
},
},
"tr": Object {
"backgroundColor": Object {
"hover": "#F1F2F3",
},
},
},
"tag": Object {
"backgroundColor": Object {
"blue": "#e3f2fd",
"gray": "#F1F2F3",
"green": "#D6F5E0",
"red": "#FAD1D1",
"white": "#E3E6E8",
"yellow": "#FFEECC",
},
"color": "#171A1C",
},
"textArea": Object {
"backgroundColor": Object {
"readOnly": "#F1F2F3",
},
"borderColor": Object {
"disabled": "#E3E6E8",
"focus": "#454F54",
"hover": "#73848C",
"normal": "#ABB5BA",
},
"color": Object {
"disabled": "#ABB5BA",
"placeholder": Object {
"focus": Object {
"normal": "transparent",
"readOnly": "#ABB5BA",
},
"normal": "#ABB5BA",
},
"readOnly": "#171A1C",
},
},
"topicFormLabel": Object {
"color": "#73848C",
},
"topicMetaData": Object {
"backgroundColor": "#F1F2F3",
"color": Object {
"label": "#73848C",
"meta": "#ABB5BA",
"value": "#2F3639",
},
},
"topicsList": Object {
"backgroundColor": Object {
"active": "#E3E6E8",
"hover": "#F1F2F3",
},
"color": Object {
"active": "#171A1C",
"hover": "#73848C",
"normal": "#171A1C",
},
},
"viewer": Object {
"wrapper": "#f9fafa",
},
}
}
>
<SchemaVersion
version={
Object {
"compatibilityLevel": "BACKWARD",
"id": 1,
"schema": "{\\"type\\":\\"record\\",\\"name\\":\\"MyRecord1\\",\\"namespace\\":\\"com.mycompany\\",\\"fields\\":[{\\"name\\":\\"id\\",\\"type\\":\\"long\\"}]}",
"schemaType": "JSON",
"subject": "test",
"version": "1",
}
}
/>
</ContextProvider>
`;

View file

@ -1,8 +1,17 @@
import { SchemaSubject, SchemaType } from 'generated-sources';
import {
schemaVersion1,
schemaVersion2,
} from 'redux/reducers/schemas/__test__/fixtures';
export const versionPayload = [schemaVersion1, schemaVersion2];
export const versionEmptyPayload = [];
export const versions = [schemaVersion1, schemaVersion2];
export const jsonSchema: SchemaSubject = {
subject: 'test',
version: '1',
version: '15',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
@ -19,33 +28,3 @@ export const protoSchema: SchemaSubject = {
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.PROTOBUF,
};
export const versions: SchemaSubject[] = [
{
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.JSON,
},
{
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: '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,
},
];

View file

@ -16,11 +16,16 @@ import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
schemaAdded,
schemasApiClient,
fetchLatestSchema,
getSchemaLatest,
SCHEMA_LATEST_FETCH_ACTION,
getAreSchemaLatestFulfilled,
schemaUpdated,
selectSchemaById,
} from 'redux/reducers/schemas/schemasSlice';
import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice';
import { getResponse } from 'lib/errorHandling';
import PageLoader from 'components/common/PageLoader/PageLoader';
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
import * as S from './Edit.styled';
@ -37,7 +42,15 @@ const Edit: React.FC = () => {
handleSubmit,
} = methods;
const schema = useAppSelector((state) => selectSchemaById(state, subject));
React.useEffect(() => {
dispatch(fetchLatestSchema({ clusterName, subject }));
return () => {
dispatch(resetLoaderById(SCHEMA_LATEST_FETCH_ACTION));
};
}, [clusterName, subject]);
const schema = useAppSelector((state) => getSchemaLatest(state));
const isFetched = useAppSelector(getAreSchemaLatestFulfilled);
const formatedSchema = React.useMemo(() => {
return schema?.schemaType === SchemaType.PROTOBUF
@ -84,8 +97,9 @@ const Edit: React.FC = () => {
}
}, []);
if (!schema) return null;
if (!isFetched || !schema) {
return <PageLoader />;
}
return (
<FormProvider {...methods}>
<PageHeading text="Edit schema" />

View file

@ -3,49 +3,77 @@ import Edit from 'components/Schemas/Edit/Edit';
import { render } from 'lib/testHelpers';
import { clusterSchemaEditPath } from 'lib/paths';
import {
schemasFulfilledState,
schemasInitialState,
schemaVersion,
} from 'redux/reducers/schemas/__test__/fixtures';
import { Route } from 'react-router';
import { screen } from '@testing-library/dom';
import { screen, waitFor } from '@testing-library/dom';
import ClusterContext, {
ContextProps,
initialValue as contextInitialValue,
} from 'components/contexts/ClusterContext';
import { RootState } from 'redux/interfaces';
import fetchMock from 'fetch-mock';
const clusterName = 'local';
const { subject } = schemaVersion;
const clusterName = 'testClusterName';
const schemasAPILatestUrl = `/api/clusters/${clusterName}/schemas/${schemaVersion.subject}/latest`;
describe('Edit Component', () => {
describe('schema exists', () => {
beforeEach(() => {
render(
<Route path={clusterSchemaEditPath(':clusterName', ':subject')}>
<Edit />
</Route>,
{
pathname: clusterSchemaEditPath(clusterName, subject),
preloadedState: { schemas: schemasFulfilledState },
}
);
const renderComponent = (
initialState: RootState['schemas'] = schemasInitialState,
context: ContextProps = contextInitialValue
) => {
return render(
<Route path={clusterSchemaEditPath(':clusterName', ':subject')}>
<ClusterContext.Provider value={context}>
<Edit />
</ClusterContext.Provider>
</Route>,
{
pathname: clusterSchemaEditPath(clusterName, schemaVersion.subject),
preloadedState: {
schemas: initialState,
},
}
);
};
describe('Edit', () => {
afterEach(() => fetchMock.reset());
describe('fetch failed', () => {
beforeEach(async () => {
const schemasAPILatestMock = fetchMock.getOnce(schemasAPILatestUrl, 404);
renderComponent();
await waitFor(() => {
expect(schemasAPILatestMock.called()).toBeTruthy();
});
});
it('renders component', () => {
expect(screen.getByText('Edit schema')).toBeInTheDocument();
it('renders pageloader', () => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByText(schemaVersion.subject)).not.toBeInTheDocument();
expect(screen.queryByText('Submit')).not.toBeInTheDocument();
});
});
describe('schema does not exist', () => {
beforeEach(() => {
render(
<Route path={clusterSchemaEditPath(':clusterName', ':subject')}>
<Edit />
</Route>,
{
pathname: clusterSchemaEditPath(clusterName, 'fake'),
preloadedState: { schemas: schemasFulfilledState },
}
);
});
describe('fetch success', () => {
describe('has schema versions', () => {
beforeEach(async () => {
const schemasAPILatestMock = fetchMock.getOnce(
schemasAPILatestUrl,
schemaVersion
);
it('renders component', () => {
expect(screen.queryByText('Edit schema')).not.toBeInTheDocument();
renderComponent();
await waitFor(() => {
expect(schemasAPILatestMock.called()).toBeTruthy();
});
});
it('renders component with schema info', () => {
expect(screen.getByText('Submit')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
});
});

View file

@ -3,6 +3,8 @@ import Select from 'components/common/Select/Select';
import { CompatibilityLevelCompatibilityEnum } from 'generated-sources';
import { getResponse } from 'lib/errorHandling';
import { useAppDispatch } from 'lib/hooks/redux';
import usePagination from 'lib/hooks/usePagination';
import useSearch from 'lib/hooks/useSearch';
import React from 'react';
import { useParams } from 'react-router-dom';
import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice';
@ -16,6 +18,9 @@ import * as S from './GlobalSchemaSelector.styled';
const GlobalSchemaSelector: React.FC = () => {
const { clusterName } = useParams<{ clusterName: string }>();
const dispatch = useAppDispatch();
const [searchText] = useSearch();
const { page, perPage } = usePagination();
const [currentCompatibilityLevel, setCurrentCompatibilityLevel] =
React.useState<CompatibilityLevelCompatibilityEnum | undefined>();
const [nextCompatibilityLevel, setNextCompatibilityLevel] = React.useState<
@ -61,7 +66,9 @@ const GlobalSchemaSelector: React.FC = () => {
setCurrentCompatibilityLevel(nextCompatibilityLevel);
setNextCompatibilityLevel(undefined);
setIsConfirmationVisible(false);
dispatch(fetchSchemas(clusterName));
dispatch(
fetchSchemas({ clusterName, page, perPage, search: searchText })
);
} catch (e) {
const err = await getResponse(e as Response);
dispatch(serverErrorAlertAdded(err));

View file

@ -6,16 +6,42 @@ import * as C from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { Button } from 'components/common/Button/Button';
import PageHeading from 'components/common/PageHeading/PageHeading';
import { useAppSelector } from 'lib/hooks/redux';
import { selectAllSchemas } from 'redux/reducers/schemas/schemasSlice';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
selectAllSchemas,
fetchSchemas,
getAreSchemasFulfilled,
SCHEMAS_FETCH_ACTION,
} from 'redux/reducers/schemas/schemasSlice';
import usePagination from 'lib/hooks/usePagination';
import PageLoader from 'components/common/PageLoader/PageLoader';
import Pagination from 'components/common/Pagination/Pagination';
import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
import Search from 'components/common/Search/Search';
import useSearch from 'lib/hooks/useSearch';
import ListItem from './ListItem';
import GlobalSchemaSelector from './GlobalSchemaSelector/GlobalSchemaSelector';
const List: React.FC = () => {
const dispatch = useAppDispatch();
const { isReadOnly } = React.useContext(ClusterContext);
const { clusterName } = useParams<{ clusterName: string }>();
const schemas = useAppSelector(selectAllSchemas);
const isFetched = useAppSelector(getAreSchemasFulfilled);
const totalPages = useAppSelector((state) => state.schemas.totalPages);
const [searchText, handleSearchText] = useSearch();
const { page, perPage } = usePagination();
React.useEffect(() => {
dispatch(fetchSchemas({ clusterName, page, perPage, search: searchText }));
return () => {
dispatch(resetLoaderById(SCHEMAS_FETCH_ACTION));
};
}, [clusterName, page, perPage, searchText]);
return (
<>
@ -34,28 +60,42 @@ const List: React.FC = () => {
</>
)}
</PageHeading>
<C.Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Schema Name" />
<TableHeaderCell title="Version" />
<TableHeaderCell title="Compatibility" />
</tr>
</thead>
<tbody>
{schemas.length === 0 && (
<tr>
<td colSpan={10}>No schemas found</td>
</tr>
)}
{schemas.map((subject) => (
<ListItem
key={[subject.id, subject.subject].join('-')}
subject={subject}
/>
))}
</tbody>
</C.Table>
<ControlPanelWrapper hasInput>
<Search
placeholder="Search by Schema Name"
value={searchText}
handleSearch={handleSearchText}
/>
</ControlPanelWrapper>
{isFetched ? (
<>
<C.Table isFullwidth>
<thead>
<tr>
<TableHeaderCell title="Schema Name" />
<TableHeaderCell title="Version" />
<TableHeaderCell title="Compatibility" />
</tr>
</thead>
<tbody>
{schemas.length === 0 && (
<tr>
<td colSpan={10}>No schemas found</td>
</tr>
)}
{schemas.map((subject) => (
<ListItem
key={[subject.id, subject.subject].join('-')}
subject={subject}
/>
))}
</tbody>
</C.Table>
<Pagination totalPages={totalPages} />
</>
) : (
<PageLoader />
)}
</>
);
};

View file

@ -3,60 +3,117 @@ import List from 'components/Schemas/List/List';
import { render } from 'lib/testHelpers';
import { Route } from 'react-router';
import { clusterSchemasPath } from 'lib/paths';
import { screen } from '@testing-library/dom';
import { screen, waitFor } from '@testing-library/dom';
import {
schemasFulfilledState,
schemasInitialState,
schemaVersion1,
schemaVersion2,
} from 'redux/reducers/schemas/__test__/fixtures';
import ClusterContext, {
ContextProps,
initialValue as contextInitialValue,
} from 'components/contexts/ClusterContext';
import { RootState } from 'redux/interfaces';
import fetchMock from 'fetch-mock';
import { schemasPayload, schemasEmptyPayload } from './fixtures';
const clusterName = 'testClusterName';
const schemasAPIUrl = `/api/clusters/${clusterName}/schemas`;
const schemasAPICompabilityUrl = `${schemasAPIUrl}/compatibility`;
const renderComponent = (
initialState: RootState['schemas'] = schemasInitialState,
context: ContextProps = contextInitialValue
) =>
render(
<ClusterContext.Provider value={context}>
<Route path={clusterSchemasPath(':clusterName')}>
<Route path={clusterSchemasPath(':clusterName')}>
<ClusterContext.Provider value={context}>
<List />
</Route>
</ClusterContext.Provider>,
</ClusterContext.Provider>
</Route>,
{
pathname: clusterSchemasPath(clusterName),
preloadedState: {
loader: {
'schemas/fetch': 'fulfilled',
},
schemas: initialState,
},
}
);
describe('List', () => {
it('renders list', () => {
renderComponent(schemasFulfilledState);
expect(screen.getByText('MySchemaSubject')).toBeInTheDocument();
expect(screen.getByText('schema7_1')).toBeInTheDocument();
afterEach(() => {
fetchMock.reset();
});
it('renders empty table', () => {
renderComponent();
expect(screen.getByText('No schemas found')).toBeInTheDocument();
describe('fetch error', () => {
it('shows progressbar', async () => {
const fetchSchemasMock = fetchMock.getOnce(schemasAPIUrl, 404);
const fetchCompabilityMock = fetchMock.getOnce(
schemasAPICompabilityUrl,
404
);
renderComponent();
await waitFor(() => expect(fetchSchemasMock.called()).toBeTruthy());
await waitFor(() => expect(fetchCompabilityMock.called()).toBeTruthy());
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});
describe('with readonly cluster', () => {
it('does not render Create Schema button', () => {
renderComponent(schemasFulfilledState, {
...contextInitialValue,
isReadOnly: true,
describe('fetch success', () => {
describe('responded without schemas', () => {
beforeEach(async () => {
const fetchSchemasMock = fetchMock.getOnce(
schemasAPIUrl,
schemasEmptyPayload
);
const fetchCompabilityMock = fetchMock.getOnce(
schemasAPICompabilityUrl,
200
);
renderComponent();
await waitFor(() => expect(fetchSchemasMock.called()).toBeTruthy());
await waitFor(() => expect(fetchCompabilityMock.called()).toBeTruthy());
});
it('renders empty table', () => {
expect(screen.getByText('No schemas found')).toBeInTheDocument();
});
});
describe('responded with schemas', () => {
beforeEach(async () => {
const fetchSchemasMock = fetchMock.getOnce(
schemasAPIUrl,
schemasPayload
);
const fetchCompabilityMock = fetchMock.getOnce(
schemasAPICompabilityUrl,
200
);
renderComponent(schemasFulfilledState);
await waitFor(() => expect(fetchSchemasMock.called()).toBeTruthy());
await waitFor(() => expect(fetchCompabilityMock.called()).toBeTruthy());
});
it('renders list', () => {
expect(screen.getByText(schemaVersion1.subject)).toBeInTheDocument();
expect(screen.getByText(schemaVersion2.subject)).toBeInTheDocument();
});
});
expect(screen.queryByText('Create Schema')).not.toBeInTheDocument();
describe('responded with readonly cluster schemas', () => {
beforeEach(async () => {
const fetchSchemasMock = fetchMock.getOnce(
schemasAPIUrl,
schemasPayload
);
renderComponent(schemasFulfilledState, {
...contextInitialValue,
isReadOnly: true,
});
await waitFor(() => expect(fetchSchemasMock.called()).toBeTruthy());
});
it('does not render Create Schema button', () => {
expect(screen.queryByText('Create Schema')).not.toBeInTheDocument();
});
});
});
});

View file

@ -1,31 +1,16 @@
import { SchemaSubject, SchemaType } from 'generated-sources';
import {
schemaVersion1,
schemaVersion2,
} from 'redux/reducers/schemas/__test__/fixtures';
export const schemas: SchemaSubject[] = [
{
subject: 'test',
version: '1',
id: 1,
schema:
'{"type":"record","name":"MyRecord1","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.JSON,
},
{
subject: 'test2',
version: '1',
id: 2,
schema:
'{"type":"record","name":"MyRecord2","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.JSON,
},
{
subject: 'test3',
version: '1',
id: 12,
schema:
'{"type":"record","name":"MyRecord3","namespace":"com.mycompany","fields":[{"name":"id","type":"long"}]}',
compatibilityLevel: 'BACKWARD',
schemaType: SchemaType.JSON,
},
];
export const schemas = [schemaVersion1, schemaVersion2];
export const schemasPayload = {
pageCount: 1,
schemas,
};
export const schemasEmptyPayload = {
pageCount: 1,
schemas: [],
};

View file

@ -1,17 +1,11 @@
import React from 'react';
import { Switch, useParams } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import {
clusterSchemaNewPath,
clusterSchemaPath,
clusterSchemaEditPath,
clusterSchemasPath,
} from 'lib/paths';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchSchemas,
getAreSchemasFulfilled,
} from 'redux/reducers/schemas/schemasSlice';
import PageLoader from 'components/common/PageLoader/PageLoader';
import List from 'components/Schemas/List/List';
import Details from 'components/Schemas/Details/Details';
import New from 'components/Schemas/New/New';
@ -19,18 +13,6 @@ import Edit from 'components/Schemas/Edit/Edit';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
const Schemas: React.FC = () => {
const dispatch = useAppDispatch();
const { clusterName } = useParams<{ clusterName: string }>();
const isFetched = useAppSelector(getAreSchemasFulfilled);
React.useEffect(() => {
dispatch(fetchSchemas(clusterName));
}, []);
if (!isFetched) {
return <PageLoader />;
}
return (
<Switch>
<BreadcrumbRoute

View file

@ -3,6 +3,7 @@ import usePagination from 'lib/hooks/usePagination';
import { range } from 'lodash';
import React from 'react';
import PageControl from 'components/common/Pagination/PageControl';
import useSearch from 'lib/hooks/useSearch';
import * as S from './Pagination.styled';
@ -14,12 +15,17 @@ const NEIGHBOURS = 2;
const Pagination: React.FC<PaginationProps> = ({ totalPages }) => {
const { page, perPage, pathname } = usePagination();
const [searchText] = useSearch();
const currentPage = page || 1;
const currentPerPage = perPage || PER_PAGE;
const searchParam = searchText ? `&q=${searchText}` : '';
const getPath = (newPage: number) =>
`${pathname}?page=${Math.max(newPage, 1)}&perPage=${currentPerPage}`;
`${pathname}?page=${Math.max(
newPage,
1
)}&perPage=${currentPerPage}${searchParam}`;
const pages = React.useMemo(() => {
// Total visible numbers: neighbours, current, first & last

View file

@ -17,6 +17,7 @@ const Search: React.FC<SearchProps> = ({
(e) => handleSearch(e.target.value),
300
);
return (
<Input
type="text"

View file

@ -0,0 +1,48 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router';
const SEARCH_QUERY_ARG = 'q';
// meant for use with <Search> component
// returns value of Q search param (?q='something') and callback to change it
const useSearch = (initValue = ''): [string, (value: string) => void] => {
const history = useHistory();
const { search, pathname } = useLocation();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
const q = useMemo(
() => queryParams.get(SEARCH_QUERY_ARG)?.trim(),
[queryParams]
);
const page = useMemo(() => queryParams.get('page')?.trim(), [queryParams]);
// set intial value
useEffect(() => {
if (initValue.trim() !== '' && !q) {
queryParams.set(SEARCH_QUERY_ARG, initValue.trim());
history.push({ pathname, search: queryParams.toString() });
}
}, []);
const handleChange = useCallback(
(value: string) => {
const trimmedValue = value.trim();
if (trimmedValue !== q) {
if (trimmedValue) {
queryParams.set(SEARCH_QUERY_ARG, trimmedValue);
} else {
queryParams.delete(SEARCH_QUERY_ARG);
}
// If we were on page 3 we can't determine if new search results have 3 pages - so we always reset page
if (page) {
queryParams.delete('page');
}
history.replace({ pathname, search: queryParams.toString() });
}
},
[history, pathname, queryParams, q]
);
return [q || initValue.trim() || '', handleChange];
};
export default useSearch;

View file

@ -1,6 +1,18 @@
import { SchemaType } from 'generated-sources';
import { SchemaType, SchemaSubject } from 'generated-sources';
import { RootState } from 'redux/interfaces';
export const schemaVersion = {
export const schemasInitialState: RootState['schemas'] = {
totalPages: 0,
ids: [],
entities: {},
versions: {
latest: null,
ids: [],
entities: {},
},
};
export const schemaVersion1: SchemaSubject = {
subject: 'schema7_1',
version: '1',
id: 2,
@ -9,31 +21,41 @@ export const schemaVersion = {
compatibilityLevel: 'FULL',
schemaType: SchemaType.JSON,
};
export const schemaVersion2: SchemaSubject = {
subject: 'MySchemaSubject',
version: '2',
id: 28,
schema: '12',
compatibilityLevel: 'FORWARD_TRANSITIVE',
schemaType: SchemaType.JSON,
};
export { schemaVersion1 as schemaVersion };
export const schemasFulfilledState = {
ids: ['MySchemaSubject', 'schema7_1'],
totalPages: 1,
ids: [schemaVersion2.subject, schemaVersion1.subject],
entities: {
MySchemaSubject: {
subject: 'MySchemaSubject',
version: '1',
id: 28,
schema: '12',
compatibilityLevel: 'FORWARD_TRANSITIVE',
schemaType: SchemaType.JSON,
},
schema7_1: schemaVersion,
[schemaVersion2.subject]: schemaVersion2,
[schemaVersion1.subject]: schemaVersion1,
},
versions: {
latest: null,
ids: [],
entities: {},
},
};
export const schemasInitialState = {
export const versionFulfilledState = {
totalPages: 1,
ids: [],
entities: {},
versions: {
ids: [],
entities: {},
latest: schemaVersion2,
ids: [schemaVersion1.id, schemaVersion2.id],
entities: {
[schemaVersion2.id]: schemaVersion2,
[schemaVersion1.id]: schemaVersion1,
},
},
};

View file

@ -4,7 +4,14 @@ import {
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { Configuration, SchemasApi, SchemaSubject } from 'generated-sources';
import {
Configuration,
SchemasApi,
SchemaSubject,
SchemaSubjectsResponse,
GetSchemasRequest,
GetLatestSchemaRequest,
} from 'generated-sources';
import { BASE_PARAMS } from 'lib/constants';
import { getResponse } from 'lib/errorHandling';
import { ClusterName, RootState } from 'redux/interfaces';
@ -13,21 +20,44 @@ import { createFetchingSelector } from 'redux/reducers/loader/selectors';
const apiClientConf = new Configuration(BASE_PARAMS);
export const schemasApiClient = new SchemasApi(apiClientConf);
export const fetchSchemas = createAsyncThunk<SchemaSubject[], ClusterName>(
'schemas/fetch',
async (clusterName: ClusterName, { rejectWithValue }) => {
export const SCHEMA_LATEST_FETCH_ACTION = 'schemas/latest/fetch';
export const fetchLatestSchema = createAsyncThunk<
SchemaSubject,
GetLatestSchemaRequest
>(SCHEMA_LATEST_FETCH_ACTION, async (schemaParams, { rejectWithValue }) => {
try {
return await schemasApiClient.getLatestSchema(schemaParams);
} catch (error) {
return rejectWithValue(await getResponse(error as Response));
}
});
export const SCHEMAS_FETCH_ACTION = 'schemas/fetch';
export const fetchSchemas = createAsyncThunk<
SchemaSubjectsResponse,
GetSchemasRequest
>(
SCHEMAS_FETCH_ACTION,
async ({ clusterName, page, perPage, search }, { rejectWithValue }) => {
try {
return await schemasApiClient.getSchemas({ clusterName });
return await schemasApiClient.getSchemas({
clusterName,
page,
perPage,
search: search || undefined,
});
} catch (error) {
return rejectWithValue(await getResponse(error as Response));
}
}
);
export const SCHEMAS_VERSIONS_FETCH_ACTION = 'schemas/versions/fetch';
export const fetchSchemaVersions = createAsyncThunk<
SchemaSubject[],
{ clusterName: ClusterName; subject: SchemaSubject['subject'] }
>(
'schemas/versions/fetch',
SCHEMAS_VERSIONS_FETCH_ACTION,
async ({ clusterName, subject }, { rejectWithValue }) => {
try {
return await schemasApiClient.getAllVersionsBySubject({
@ -48,18 +78,31 @@ const schemasAdapter = createEntityAdapter<SchemaSubject>({
selectId: ({ subject }) => subject,
});
const SCHEMAS_PAGE_COUNT = 1;
const initialState = {
totalPages: SCHEMAS_PAGE_COUNT,
...schemasAdapter.getInitialState(),
versions: {
...schemaVersionsAdapter.getInitialState(),
latest: <SchemaSubject | null>null,
},
};
const schemasSlice = createSlice({
name: 'schemas',
initialState: schemasAdapter.getInitialState({
versions: schemaVersionsAdapter.getInitialState(),
}),
initialState,
reducers: {
schemaAdded: schemasAdapter.addOne,
schemaUpdated: schemasAdapter.upsertOne,
},
extraReducers: (builder) => {
builder.addCase(fetchSchemas.fulfilled, (state, { payload }) => {
schemasAdapter.setAll(state, payload);
state.totalPages = payload.pageCount || SCHEMAS_PAGE_COUNT;
schemasAdapter.setAll(state, payload.schemas || []);
});
builder.addCase(fetchLatestSchema.fulfilled, (state, { payload }) => {
state.versions.latest = payload;
});
builder.addCase(fetchSchemaVersions.fulfilled, (state, { payload }) => {
schemaVersionsAdapter.setAll(state.versions, payload);
@ -70,19 +113,32 @@ const schemasSlice = createSlice({
export const { selectAll: selectAllSchemas, selectById: selectSchemaById } =
schemasAdapter.getSelectors<RootState>((state) => state.schemas);
export const { selectAll: selectAllSchemaVersions } =
schemaVersionsAdapter.getSelectors<RootState>(
(state) => state.schemas.versions
);
export const {
selectAll: selectAllSchemaVersions,
selectById: selectVersionSchemaByID,
} = schemaVersionsAdapter.getSelectors<RootState>(
(state) => state.schemas.versions
);
const getSchemaVersions = (state: RootState) => state.schemas.versions;
export const getSchemaLatest = createSelector(
getSchemaVersions,
(state) => state.latest
);
export const { schemaAdded, schemaUpdated } = schemasSlice.actions;
export const getAreSchemasFulfilled = createSelector(
createFetchingSelector('schemas/fetch'),
createFetchingSelector(SCHEMAS_FETCH_ACTION),
(status) => status === 'fulfilled'
);
export const getAreSchemaLatestFulfilled = createSelector(
createFetchingSelector(SCHEMA_LATEST_FETCH_ACTION),
(status) => status === 'fulfilled'
);
export const getAreSchemaVersionsFulfilled = createSelector(
createFetchingSelector('schemas/versions/fetch'),
createFetchingSelector(SCHEMAS_VERSIONS_FETCH_ACTION),
(status) => status === 'fulfilled'
);