From 540b8eb79b2d38d2b5dab8e6d04cf1ff16847e46 Mon Sep 17 00:00:00 2001 From: ValentinPrischepa Date: Mon, 31 Jan 2022 02:24:39 -0800 Subject: [PATCH] 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 Co-authored-by: Damir Abdulganiev Co-authored-by: Damir Abdulganiev --- .../ui/controller/SchemasController.java | 38 +- .../ui/service/SchemaRegistryService.java | 13 +- .../kafka/ui/SchemaRegistryServiceTests.java | 9 +- .../service/SchemaRegistryPaginationTest.java | 118 ++++++ .../main/resources/swagger/kafka-ui-api.yaml | 29 +- .../components/Schemas/Details/Details.tsx | 30 +- .../Schemas/Details/__test__/Details.spec.tsx | 184 ++++----- .../__test__/LatestVersionItem.spec.tsx | 57 ++- .../Details/__test__/SchemaVersion.spec.tsx | 44 +-- .../LatestVersionItem.spec.tsx.snap | 368 ------------------ .../__snapshots__/SchemaVersion.spec.tsx.snap | 368 ------------------ .../Schemas/Details/__test__/fixtures.ts | 41 +- .../src/components/Schemas/Edit/Edit.tsx | 22 +- .../Schemas/Edit/__tests__/Edit.spec.tsx | 92 +++-- .../GlobalSchemaSelector.tsx | 9 +- .../src/components/Schemas/List/List.tsx | 88 +++-- .../Schemas/List/__test__/List.spec.tsx | 101 +++-- .../Schemas/List/__test__/fixtures.ts | 45 +-- .../src/components/Schemas/Schemas.tsx | 20 +- .../common/Pagination/Pagination.tsx | 8 +- .../src/components/common/Search/Search.tsx | 1 + kafka-ui-react-app/src/lib/hooks/useSearch.ts | 48 +++ .../reducers/schemas/__test__/fixtures.ts | 52 ++- .../redux/reducers/schemas/schemasSlice.ts | 88 ++++- 24 files changed, 766 insertions(+), 1107 deletions(-) create mode 100644 kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java delete mode 100644 kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap delete mode 100644 kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap create mode 100644 kafka-ui-react-app/src/lib/hooks/useSearch.ts diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java index 10b5208b64..c9ba613a3d 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java @@ -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>> getSchemas(String clusterName, - ServerWebExchange exchange) { - Flux subjects = schemaRegistryService.getAllLatestVersionSchemas( - getCluster(clusterName) - ); - return Mono.just(ResponseEntity.ok(subjects)); + public Mono> 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 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 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 diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java index ccf21cc125..84aba4b81b 100644 --- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java +++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java @@ -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 getAllLatestVersionSchemas(KafkaCluster cluster) { - var allSubjectNames = getAllSubjectNames(cluster); - return allSubjectNames - .flatMapMany(Flux::fromArray) - .flatMap(subject -> getLatestSchemaVersionBySubject(cluster, subject)); + public Mono> getAllLatestVersionSchemas(KafkaCluster cluster, + List subjects) { + return Flux.fromIterable(subjects) + .concatMap(subject -> getLatestSchemaVersionBySubject(cluster, subject)) + .collect(Collectors.toList()); } public Mono getAllSubjectNames(KafkaCluster cluster) { diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/SchemaRegistryServiceTests.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/SchemaRegistryServiceTests.java index 01c5eb123a..1712c000c7 100644 --- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/SchemaRegistryServiceTests.java +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/SchemaRegistryServiceTests.java @@ -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 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(); diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java new file mode 100644 index 0000000000..5b4cee3a1f --- /dev/null +++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java @@ -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(); + } +} diff --git a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml index b0633f9b0b..0f024a3c50 100644 --- a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml +++ b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml @@ -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: diff --git a/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx b/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx index 2813add2e2..0ee5916126 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx @@ -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 ; } diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx index 746b633a7b..daa00885ab 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx @@ -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( + + +
+ + , + { + pathname: clusterSchemaPath(clusterName, schemaVersion.subject), + preloadedState: { + schemas: initialState, + }, + } + ); +}; describe('Details', () => { - describe('for an initial state', () => { - it('renders pageloader', () => { - render( - -
- , - { - 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( - -
- , - { - 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( - -
- , - { - 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( - -
- , - { - 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(); + }); }); }); }); diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx index 5872d070de..76331664ee 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx @@ -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(); +}; + describe('LatestVersionItem', () => { it('renders latest version of json schema', () => { - const wrapper = mount( - - - - ); - - 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( - - - - ); + 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( - - - - ) - ).toMatchSnapshot(); + expect(screen.getByText('BACKWARD')).toBeInTheDocument(); + expect(screen.getByTestId('json-viewer')).toBeInTheDocument(); }); }); diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx index 42fca8cdee..ab54753392 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx @@ -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( + + + + +
+ ); +}; describe('SchemaVersion', () => { it('renders versions', () => { - const wrapper = mount( - - - - - -
-
- ); - - 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( - - - - ) - ).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(); }); }); diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap b/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap deleted file mode 100644 index 079d89ae84..0000000000 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap +++ /dev/null @@ -1,368 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LatestVersionItem matches snapshot 1`] = ` - - - -`; diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap b/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap deleted file mode 100644 index 5fb054e15a..0000000000 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap +++ /dev/null @@ -1,368 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SchemaVersion matches snapshot 1`] = ` - - - -`; diff --git a/kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts b/kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts index bc0b1f2bd2..6443dfd541 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts +++ b/kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts @@ -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, - }, -]; diff --git a/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx b/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx index 0507652be4..11842fc770 100644 --- a/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx @@ -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 ; + } return ( diff --git a/kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx b/kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx index 6513a4fba5..7070f9a6ff 100644 --- a/kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx @@ -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( - - - , - { - pathname: clusterSchemaEditPath(clusterName, subject), - preloadedState: { schemas: schemasFulfilledState }, - } - ); +const renderComponent = ( + initialState: RootState['schemas'] = schemasInitialState, + context: ContextProps = contextInitialValue +) => { + return render( + + + + + , + { + 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( - - - , - { - 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(); + }); }); }); }); diff --git a/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx b/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx index 9c5a7ab4e3..be7faf3a88 100644 --- a/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx +++ b/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx @@ -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(); 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)); diff --git a/kafka-ui-react-app/src/components/Schemas/List/List.tsx b/kafka-ui-react-app/src/components/Schemas/List/List.tsx index 4cc4583358..066d209bc3 100644 --- a/kafka-ui-react-app/src/components/Schemas/List/List.tsx +++ b/kafka-ui-react-app/src/components/Schemas/List/List.tsx @@ -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 = () => { )} - - - - - - - - - - {schemas.length === 0 && ( - - No schemas found - - )} - {schemas.map((subject) => ( - - ))} - - + + + + {isFetched ? ( + <> + + + + + + + + + + {schemas.length === 0 && ( + + No schemas found + + )} + {schemas.map((subject) => ( + + ))} + + + + + ) : ( + + )} ); }; diff --git a/kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx b/kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx index 5969fa0c3d..00583bee0f 100644 --- a/kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx +++ b/kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx @@ -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( - - + + - - , + + , { 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(); + }); }); }); }); diff --git a/kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts b/kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts index 46a4d5a105..61cb7383e8 100644 --- a/kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts +++ b/kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts @@ -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: [], +}; diff --git a/kafka-ui-react-app/src/components/Schemas/Schemas.tsx b/kafka-ui-react-app/src/components/Schemas/Schemas.tsx index 8832653f0c..659e1e3170 100644 --- a/kafka-ui-react-app/src/components/Schemas/Schemas.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Schemas.tsx @@ -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 ; - } - return ( = ({ 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 diff --git a/kafka-ui-react-app/src/components/common/Search/Search.tsx b/kafka-ui-react-app/src/components/common/Search/Search.tsx index 05c919c474..1369ea9f41 100644 --- a/kafka-ui-react-app/src/components/common/Search/Search.tsx +++ b/kafka-ui-react-app/src/components/common/Search/Search.tsx @@ -17,6 +17,7 @@ const Search: React.FC = ({ (e) => handleSearch(e.target.value), 300 ); + return ( 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; diff --git a/kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts b/kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts index de3bd92ccd..02c8d2278c 100644 --- a/kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts +++ b/kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts @@ -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, + }, }, }; diff --git a/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts b/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts index cfc358815f..eef2a7b3f6 100644 --- a/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts +++ b/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts @@ -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( - '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({ selectId: ({ subject }) => subject, }); +const SCHEMAS_PAGE_COUNT = 1; + +const initialState = { + totalPages: SCHEMAS_PAGE_COUNT, + ...schemasAdapter.getInitialState(), + versions: { + ...schemaVersionsAdapter.getInitialState(), + latest: 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((state) => state.schemas); -export const { selectAll: selectAllSchemaVersions } = - schemaVersionsAdapter.getSelectors( - (state) => state.schemas.versions - ); +export const { + selectAll: selectAllSchemaVersions, + selectById: selectVersionSchemaByID, +} = schemaVersionsAdapter.getSelectors( + (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' );