瀏覽代碼

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>
ValentinPrischepa 3 年之前
父節點
當前提交
540b8eb79b
共有 24 個文件被更改,包括 766 次插入1107 次删除
  1. 32 6
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/SchemasController.java
  2. 8 5
      kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/SchemaRegistryService.java
  3. 5 4
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/SchemaRegistryServiceTests.java
  4. 118 0
      kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/SchemaRegistryPaginationTest.java
  5. 26 3
      kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
  6. 21 9
      kafka-ui-react-app/src/components/Schemas/Details/Details.tsx
  7. 94 86
      kafka-ui-react-app/src/components/Schemas/Details/__test__/Details.spec.tsx
  8. 23 34
      kafka-ui-react-app/src/components/Schemas/Details/__test__/LatestVersionItem.spec.tsx
  9. 17 27
      kafka-ui-react-app/src/components/Schemas/Details/__test__/SchemaVersion.spec.tsx
  10. 0 368
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap
  11. 0 368
      kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap
  12. 10 31
      kafka-ui-react-app/src/components/Schemas/Details/__test__/fixtures.ts
  13. 18 4
      kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx
  14. 62 34
      kafka-ui-react-app/src/components/Schemas/Edit/__tests__/Edit.spec.tsx
  15. 8 1
      kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx
  16. 64 24
      kafka-ui-react-app/src/components/Schemas/List/List.tsx
  17. 79 22
      kafka-ui-react-app/src/components/Schemas/List/__test__/List.spec.tsx
  18. 15 30
      kafka-ui-react-app/src/components/Schemas/List/__test__/fixtures.ts
  19. 1 19
      kafka-ui-react-app/src/components/Schemas/Schemas.tsx
  20. 7 1
      kafka-ui-react-app/src/components/common/Pagination/Pagination.tsx
  21. 1 0
      kafka-ui-react-app/src/components/common/Search/Search.tsx
  22. 48 0
      kafka-ui-react-app/src/lib/hooks/useSearch.ts
  23. 37 15
      kafka-ui-react-app/src/redux/reducers/schemas/__test__/fixtures.ts
  24. 72 16
      kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts

+ 32 - 6
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<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

+ 8 - 5
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<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) {

+ 5 - 4
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<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();

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

+ 26 - 3
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:

+ 21 - 9
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 <PageLoader />;
   }
 

+ 94 - 86
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(
+    <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: {},
-        }
-      );
-      expect(screen.getByRole('progressbar')).toBeInTheDocument();
-      expect(screen.queryByText(subject)).not.toBeInTheDocument();
-      expect(screen.queryByText('Edit Schema')).not.toBeInTheDocument();
-      expect(screen.queryByText('Remove Schema')).not.toBeInTheDocument();
-    });
-  });
+  afterEach(() => fetchMock.reset());
 
-  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 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 component with shema info', () => {
-      expect(screen.getByText('Edit Schema')).toBeInTheDocument();
-    });
-
-    it('renders progressbar for versions block', () => {
+    it('renders pageloader', () => {
       expect(screen.getByRole('progressbar')).toBeInTheDocument();
-      expect(screen.queryByRole('table')).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 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());
+  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();
+        });
+      });
 
-      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
-      expect(screen.getByRole('table')).toBeInTheDocument();
+      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 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());
+    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();
+        });
+      });
 
-      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();
+      });
     });
   });
 });

+ 23 - 34
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(<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>
-    );
-
-    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();
-  });
+    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();
 
-  it('matches snapshot', () => {
-    expect(
-      shallow(
-        <ThemeProvider theme={theme}>
-          <LatestVersionItem schema={jsonSchema} />
-        </ThemeProvider>
-      )
-    ).toMatchSnapshot();
+    expect(screen.getByText('BACKWARD')).toBeInTheDocument();
+    expect(screen.getByTestId('json-viewer')).toBeInTheDocument();
   });
 });

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

+ 0 - 368
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/LatestVersionItem.spec.tsx.snap

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

+ 0 - 368
kafka-ui-react-app/src/components/Schemas/Details/__test__/__snapshots__/SchemaVersion.spec.tsx.snap

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

+ 10 - 31
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,
-  },
-];

+ 18 - 4
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 <PageLoader />;
+  }
   return (
     <FormProvider {...methods}>
       <PageHeading text="Edit schema" />

+ 62 - 34
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';
-
-const clusterName = 'local';
-const { subject } = schemaVersion;
-
-describe('Edit Component', () => {
-  describe('schema exists', () => {
-    beforeEach(() => {
-      render(
-        <Route path={clusterSchemaEditPath(':clusterName', ':subject')}>
-          <Edit />
-        </Route>,
-        {
-          pathname: clusterSchemaEditPath(clusterName, subject),
-          preloadedState: { schemas: schemasFulfilledState },
-        }
-      );
+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 = 'testClusterName';
+const schemasAPILatestUrl = `/api/clusters/${clusterName}/schemas/${schemaVersion.subject}/latest`;
+
+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
+        );
+
+        renderComponent();
+        await waitFor(() => {
+          expect(schemasAPILatestMock.called()).toBeTruthy();
+        });
+      });
 
-    it('renders component', () => {
-      expect(screen.queryByText('Edit schema')).not.toBeInTheDocument();
+      it('renders component with schema info', () => {
+        expect(screen.getByText('Submit')).toBeInTheDocument();
+        expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+      });
     });
   });
 });

+ 8 - 1
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<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));

+ 64 - 24
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 = () => {
           </>
         )}
       </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 />
+      )}
     </>
   );
 };

+ 79 - 22
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';
 
-const clusterName = 'testClusterName';
+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();
+      });
+    });
+
+    describe('responded with readonly cluster schemas', () => {
+      beforeEach(async () => {
+        const fetchSchemasMock = fetchMock.getOnce(
+          schemasAPIUrl,
+          schemasPayload
+        );
 
-      expect(screen.queryByText('Create Schema')).not.toBeInTheDocument();
+        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();
+      });
     });
   });
 });

+ 15 - 30
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: [],
+};

+ 1 - 19
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 <PageLoader />;
-  }
-
   return (
     <Switch>
       <BreadcrumbRoute

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

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

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

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

+ 48 - 0
kafka-ui-react-app/src/lib/hooks/useSearch.ts

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

+ 37 - 15
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,
+    },
   },
 };

+ 72 - 16
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<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'
 );