Forráskód Böngészése

FE: KSQL execution is unstable (#1576)

* sse init

* sse refactoring

* checkstyle fix

* checkstyle fix

* refactor

* refactor

* api spec changes

* ReactiveAdminClient toMono fix

* moved to ksql/v2 request

* Updates Result renderer and Query SSE bugs

* Result renderer works

* Corretcly renders table and errors

* Changes the way getFormattedError formats message for error alert

* Adds common comp Header, Adds new component TableTitle, Changes PageHeading, LatestVersionItem and TableTitle to use Header comp, Adds error handling for KSQL SSE

* Changes functions to useCallbacks

* WIP: Fixes linter issue

* fixes

* WIP: Disables controls on request

* fixes

* WIP: Disabled editors look disabled, Updates snapshot

* WIP: Removes codesmell

* fixes

* fixes

* WIP: Adds eslint rules for react hooks, Fixes bug in ksqlDb query

* WIP: Fixes error with early return

* WIP: run to test if it builds

* WIP: Fixes error formating

* WIP: Fixes error message

* WIP: better error

* WIP: Fixes validation issues and now we can submit form with CMD + ENTER

* WIP: Initial tests

* WIP: More test for query

* WIP: rewrite to make things simpler

* WIP: Tests done

* WIP: More tests

* small improvement

* Test if sonar works

* Adds cases for TableRenderer

* Fixes test fro table renderer

* Changes sonar properties

* Adds cases for QueryForm.spec.tsx

* Adds cases to QueryForm.spec.tsx

* Updates Query.spec.tsx

* Adds small eventsource mock

* Adds test to QueryForm.spec.tsx

* Updates Query.spec.tsx

* Better error with empty response

Co-authored-by: Ekaterina Petrova <epetrova@provectus.com>
Co-authored-by: iliax <ikuramshin@provectus.com>
Co-authored-by: Ilya Kuramshin <ilia-2k@rambler.ru>
Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
Co-authored-by: Mgrdich <mgotm13@gmail.com>
Co-authored-by: Mgrdich <46796009+Mgrdich@users.noreply.github.com>
Damir Abdulganiev 3 éve
szülő
commit
50889e6ac3
33 módosított fájl, 1277 hozzáadás és 541 törlés
  1. 4 3
      kafka-ui-react-app/.eslintrc.json
  2. 1 1
      kafka-ui-react-app/sonar-project.properties
  3. 17 13
      kafka-ui-react-app/src/components/Connect/Details/__tests__/__snapshots__/Details.spec.tsx.snap
  4. 1 1
      kafka-ui-react-app/src/components/KsqlDb/List/List.tsx
  5. 4 21
      kafka-ui-react-app/src/components/KsqlDb/Query/Query.styled.ts
  6. 197 98
      kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx
  7. 57 0
      kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/QueryForm.styled.ts
  8. 170 0
      kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx
  9. 332 0
      kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/__test__/QueryForm.spec.tsx
  10. 0 7
      kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.styled.tsx
  11. 0 67
      kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.tsx
  12. 172 63
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/Query.spec.tsx
  13. 0 14
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/ResultRenderer.spec.tsx
  14. 0 7
      kafka-ui-react-app/src/components/KsqlDb/Query/__test__/__snapshots__/ResultRenderer.spec.tsx.snap
  15. 15 0
      kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/TableRenderer.styled.tsx
  16. 87 0
      kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/TableRenderer.tsx
  17. 71 0
      kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/__test__/TableRenderer.spec.tsx
  18. 2 2
      kafka-ui-react-app/src/components/Schemas/Details/Details.tsx
  19. 7 9
      kafka-ui-react-app/src/components/Schemas/Details/LatestVersion/LatestVersionItem.styled.tsx
  20. 9 11
      kafka-ui-react-app/src/components/Schemas/Details/LatestVersion/LatestVersionItem.tsx
  21. 1 8
      kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion/SchemaVersion.styled.ts
  22. 3 3
      kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion/SchemaVersion.tsx
  23. 2 4
      kafka-ui-react-app/src/components/common/ConfirmationModal/ConfirmationModal.tsx
  24. 14 20
      kafka-ui-react-app/src/components/common/PageHeading/PageHeading.tsx
  25. 2 0
      kafka-ui-react-app/src/components/common/SQLEditor/SQLEditor.tsx
  26. 20 0
      kafka-ui-react-app/src/components/common/heading/Heading.styled.tsx
  27. 7 0
      kafka-ui-react-app/src/components/common/table/TableTitle/TableTitle.styled.tsx
  28. 2 2
      kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts
  29. 0 96
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/__snapshots__/reducer.spec.ts.snap
  30. 19 20
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/fixtures.ts
  31. 0 65
      kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/reducer.spec.ts
  32. 6 5
      kafka-ui-react-app/src/redux/reducers/ksqlDb/ksqlDbSlice.ts
  33. 55 1
      kafka-ui-react-app/src/theme/theme.ts

+ 4 - 3
kafka-ui-react-app/.eslintrc.json

@@ -23,7 +23,7 @@
   "plugins": [
     "@typescript-eslint",
     "prettier",
-    "eslint-plugin-react-hooks"
+    "react-hooks"
   ],
   "extends": [
     "airbnb",
@@ -34,8 +34,6 @@
     "prettier"
   ],
   "rules": {
-    "react-hooks/rules-of-hooks": "error",
-    "react-hooks/exhaustive-deps": "warn",
     "react/no-unused-prop-types": "off",
     "react/require-default-props": "off",
     "prettier/prettier": "warn",
@@ -43,6 +41,9 @@
     "jsx-a11y/label-has-associated-control": "off",
     "import/prefer-default-export": "off",
     "@typescript-eslint/no-explicit-any": "error",
+    "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
+    // breaks builds as we still have those warns
+    "react-hooks/exhaustive-deps": "off", // Checks effect dependencies
     "import/no-extraneous-dependencies": [
       "error",
       {

+ 1 - 1
kafka-ui-react-app/sonar-project.properties

@@ -2,7 +2,7 @@ sonar.projectKey=com.provectus:kafka-ui_frontend
 sonar.organization=provectus
 
 sonar.sources=.
-sonar.exclusions=**/__test?__/**,src/setupWorker.ts,src/setupTests.ts,src/setupProxy.js,**/fixtures.ts,src/lib/testHelpers.tsx
+sonar.exclusions=**/__tests__/**,**/__test__/**,src/setupWorker.ts,src/setupTests.ts,src/setupProxy.js,**/fixtures.ts,src/lib/testHelpers.tsx
 
 sonar.typescript.lcov.reportPaths=./coverage/lcov.info
 sonar.testExecutionReportPaths=./test-report.xml

+ 17 - 13
kafka-ui-react-app/src/components/Connect/Details/__tests__/__snapshots__/Details.spec.tsx.snap

@@ -1,7 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Details view matches snapshot 1`] = `
-.c1 {
+.c2 {
   display: -webkit-box;
   display: -webkit-flex;
   display: -ms-flexbox;
@@ -9,7 +9,7 @@ exports[`Details view matches snapshot 1`] = `
   border-bottom: 1px #E3E6E8 solid;
 }
 
-.c1 a {
+.c2 a {
   height: 40px;
   width: 96px;
   display: -webkit-box;
@@ -30,16 +30,25 @@ exports[`Details view matches snapshot 1`] = `
   border-bottom: 1px transparent solid;
 }
 
-.c1 a.is-active {
+.c2 a.is-active {
   border-bottom: 1px #4C4CFF solid;
   color: #171A1C;
 }
 
-.c1 a:hover:not(.is-active) {
+.c2 a:hover:not(.is-active) {
   border-bottom: 1px transparent solid;
   color: #171A1C;
 }
 
+.c1 {
+  font-family: Inter,sans-serif;
+  font-style: normal;
+  font-weight: 500;
+  color: #000;
+  font-size: 24px;
+  line-height: 32px;
+}
+
 .c0 {
   height: 56px;
   display: -webkit-box;
@@ -57,13 +66,6 @@ exports[`Details view matches snapshot 1`] = `
   padding: 0px 16px;
 }
 
-.c0 h1 {
-  font-size: 24px;
-  font-weight: 500;
-  line-height: 32px;
-  color: #171A1C;
-}
-
 .c0 > div {
   display: -webkit-box;
   display: -webkit-flex;
@@ -76,7 +78,9 @@ exports[`Details view matches snapshot 1`] = `
   <div
     className="c0"
   >
-    <h1>
+    <h1
+      className="c1"
+    >
       my-connector
     </h1>
     <div>
@@ -84,7 +88,7 @@ exports[`Details view matches snapshot 1`] = `
     </div>
   </div>
   <nav
-    className="c1"
+    className="c2"
     role="navigation"
   >
     <a

+ 1 - 1
kafka-ui-react-app/src/components/KsqlDb/List/List.tsx

@@ -67,7 +67,7 @@ const List: FC = () => {
           <Table isFullwidth>
             <thead>
               <tr>
-                <th> </th>
+                <TableHeaderCell title={' '} key="empty cell" />
                 {headers.map(({ Header, accessor }) => (
                   <TableHeaderCell title={Header} key={accessor} />
                 ))}

+ 4 - 21
kafka-ui-react-app/src/components/KsqlDb/Query/Query.styled.ts

@@ -1,26 +1,9 @@
+import PageLoader from 'components/common/PageLoader/PageLoader';
 import styled from 'styled-components';
 
-export const QueryWrapper = styled.div`
-  padding: 16px;
-`;
-
-export const KSQLInputsWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  gap: 24px;
-
-  padding-bottom: 16px;
+export const ContinuousLoader = styled(PageLoader)`
   & > div {
-    flex-grow: 1;
+    transform: scale(0.5);
+    padding-top: 0;
   }
 `;
-
-export const KSQLInputHeader = styled.div`
-  display: flex;
-  justify-content: space-between;
-`;
-
-export const KSQLButtons = styled.div`
-  display: flex;
-  gap: 16px;
-`;

+ 197 - 98
kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx

@@ -1,40 +1,77 @@
-import React, { useCallback, useEffect, FC } from 'react';
-import { yupResolver } from '@hookform/resolvers/yup';
-import Editor from 'components/common/Editor/Editor';
-import SQLEditor from 'components/common/SQLEditor/SQLEditor';
-import yup from 'lib/yupExtended';
-import { useForm, Controller } from 'react-hook-form';
+import React, { useCallback, useEffect, FC, useState } from 'react';
 import { useParams } from 'react-router';
+import TableRenderer from 'components/KsqlDb/Query/renderer/TableRenderer/TableRenderer';
 import {
   executeKsql,
   resetExecutionResult,
 } from 'redux/reducers/ksqlDb/ksqlDbSlice';
-import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
 import { useDispatch, useSelector } from 'react-redux';
 import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors';
-import { Button } from 'components/common/Button/Button';
+import { BASE_PARAMS } from 'lib/constants';
+import { KsqlResponse, KsqlTableResponse } from 'generated-sources';
+import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
+import { now } from 'lodash';
 
-import {
-  KSQLButtons,
-  KSQLInputHeader,
-  KSQLInputsWrapper,
-  QueryWrapper,
-} from './Query.styled';
-
-type FormValues = {
-  ksql: string;
-  streamsProperties: string;
-};
+import type { FormValues } from './QueryForm/QueryForm';
+import * as S from './Query.styled';
+import QueryForm from './QueryForm/QueryForm';
+
+const AUTO_DISMISS_TIME = 8_000;
+
+export const getFormattedErrorFromTableData = (
+  responseValues: KsqlTableResponse['values']
+): { title: string; message: string } => {
+  // We expect someting like that
+  // [[
+  //   "@type",
+  //   "error_code",
+  //   "message",
+  //   "statementText"?,
+  //   "entities"?
+  // ]],
+  // or
+  // [["message"]]
+
+  if (!responseValues || !responseValues.length) {
+    return {
+      title: 'Unknown error',
+      message: 'Recieved empty response',
+    };
+  }
+
+  let title = '';
+  let message = '';
+  if (responseValues[0].length < 2) {
+    const [messageText] = responseValues[0];
+    title = messageText;
+  } else {
+    const [type, errorCode, messageText, statementText, entities] =
+      responseValues[0];
+    title = `[Error #${errorCode}] ${type}`;
+    message =
+      (entities?.length ? `[${entities.join(', ')}] ` : '') +
+      (statementText ? `"${statementText}" ` : '') +
+      messageText;
+  }
 
-const validationSchema = yup.object({
-  ksql: yup.string().trim().required(),
-});
+  return {
+    title,
+    message,
+  };
+};
 
 const Query: FC = () => {
   const { clusterName } = useParams<{ clusterName: string }>();
+
+  const sseRef = React.useRef<{ sse: EventSource | null; isOpen: boolean }>({
+    sse: null,
+    isOpen: false,
+  });
+  const [fetching, setFetching] = useState(false);
   const dispatch = useDispatch();
 
-  const { executionResult, fetching } = useSelector(getKsqlExecution);
+  const { executionResult } = useSelector(getKsqlExecution);
+  const [KSQLTable, setKSQLTable] = useState<KsqlTableResponse | null>(null);
 
   const reset = useCallback(() => {
     dispatch(resetExecutionResult());
@@ -44,21 +81,131 @@ const Query: FC = () => {
     return reset;
   }, [reset]);
 
-  const { handleSubmit, setValue, control } = useForm<FormValues>({
-    mode: 'onTouched',
-    resolver: yupResolver(validationSchema),
-    defaultValues: {
-      ksql: '',
-      streamsProperties: '',
+  const destroySSE = () => {
+    if (sseRef.current?.sse) {
+      sseRef.current.sse.close();
+      setFetching(false);
+      sseRef.current.sse = null;
+      sseRef.current.isOpen = false;
+    }
+  };
+
+  const handleSSECancel = useCallback(() => {
+    reset();
+    destroySSE();
+  }, [reset]);
+
+  const createSSE = useCallback(
+    (pipeId: string) => {
+      const url = `${BASE_PARAMS.basePath}/api/clusters/${clusterName}/ksql/response?pipeId=${pipeId}`;
+      const sse = new EventSource(url);
+      sseRef.current.sse = sse;
+      setFetching(true);
+
+      sse.onopen = () => {
+        sseRef.current.isOpen = true;
+      };
+
+      sse.onmessage = ({ data }) => {
+        const { table }: KsqlResponse = JSON.parse(data);
+        if (table) {
+          switch (table?.header) {
+            case 'Execution error': {
+              const { title, message } = getFormattedErrorFromTableData(
+                table.values
+              );
+              const id = `${url}-executionError`;
+              dispatch(
+                alertAdded({
+                  id,
+                  type: 'error',
+                  title,
+                  message,
+                  createdAt: now(),
+                })
+              );
+
+              setTimeout(() => {
+                dispatch(alertDissmissed(id));
+              }, AUTO_DISMISS_TIME);
+              break;
+            }
+            case 'Schema': {
+              setKSQLTable(table);
+              break;
+            }
+            case 'Row': {
+              setKSQLTable((PrevKSQLTable) => {
+                return {
+                  header: PrevKSQLTable?.header,
+                  columnNames: PrevKSQLTable?.columnNames,
+                  values: [
+                    ...(PrevKSQLTable?.values || []),
+                    ...(table?.values || []),
+                  ],
+                };
+              });
+              break;
+            }
+            case 'Query Result': {
+              const id = `${url}-querySuccess`;
+              dispatch(
+                alertAdded({
+                  id,
+                  type: 'success',
+                  title: 'Query succeed',
+                  message: '',
+                  createdAt: now(),
+                })
+              );
+
+              setTimeout(() => {
+                dispatch(alertDissmissed(id));
+              }, AUTO_DISMISS_TIME);
+              break;
+            }
+            case 'Source Description':
+            case 'properties':
+            default: {
+              setKSQLTable(table);
+              break;
+            }
+          }
+        }
+        return sse;
+      };
+
+      sse.onerror = () => {
+        // if it's open - we know that server responded without opening SSE
+        if (!sseRef.current.isOpen) {
+          const id = `${url}-connectionClosedError`;
+          dispatch(
+            alertAdded({
+              id,
+              type: 'error',
+              title: 'SSE connection closed',
+              message: '',
+              createdAt: now(),
+            })
+          );
+
+          setTimeout(() => {
+            dispatch(alertDissmissed(id));
+          }, AUTO_DISMISS_TIME);
+        }
+        destroySSE();
+      };
     },
-  });
+    [clusterName, dispatch]
+  );
 
   const submitHandler = useCallback(
-    async (values: FormValues) => {
+    (values: FormValues) => {
+      setFetching(true);
       dispatch(
         executeKsql({
           clusterName,
-          ksqlCommand: {
+          ksqlCommandV2: {
             ...values,
             streamsProperties: values.streamsProperties
               ? JSON.parse(values.streamsProperties)
@@ -67,76 +214,28 @@ const Query: FC = () => {
         })
       );
     },
-    [clusterName, dispatch]
+    [dispatch, clusterName]
   );
+  useEffect(() => {
+    if (executionResult?.pipeId) {
+      createSSE(executionResult.pipeId);
+    }
+    return () => {
+      destroySSE();
+    };
+  }, [createSSE, executionResult]);
 
   return (
     <>
-      <QueryWrapper>
-        <form onSubmit={handleSubmit(submitHandler)}>
-          <KSQLInputsWrapper>
-            <div>
-              <KSQLInputHeader>
-                <label>KSQL</label>
-                <Button
-                  onClick={() => setValue('ksql', '')}
-                  buttonType="primary"
-                  buttonSize="S"
-                  isInverted
-                >
-                  Clear
-                </Button>
-              </KSQLInputHeader>
-              <Controller
-                control={control}
-                name="ksql"
-                render={({ field }) => (
-                  <SQLEditor {...field} readOnly={fetching} />
-                )}
-              />
-            </div>
-            <div>
-              <KSQLInputHeader>
-                <label>Stream properties</label>
-                <Button
-                  onClick={() => setValue('streamsProperties', '')}
-                  buttonType="primary"
-                  buttonSize="S"
-                  isInverted
-                >
-                  Clear
-                </Button>
-              </KSQLInputHeader>
-              <Controller
-                control={control}
-                name="streamsProperties"
-                render={({ field }) => (
-                  <Editor {...field} readOnly={fetching} />
-                )}
-              />
-            </div>
-          </KSQLInputsWrapper>
-          <KSQLButtons>
-            <Button
-              buttonType="primary"
-              buttonSize="M"
-              type="submit"
-              disabled={fetching}
-            >
-              Execute
-            </Button>
-            <Button
-              buttonType="secondary"
-              buttonSize="M"
-              disabled={!executionResult}
-              onClick={reset}
-            >
-              Clear results
-            </Button>
-          </KSQLButtons>
-        </form>
-      </QueryWrapper>
-      <ResultRenderer result={executionResult} />
+      <QueryForm
+        fetching={fetching}
+        hasResults={!!KSQLTable}
+        handleClearResults={() => setKSQLTable(null)}
+        handleSSECancel={handleSSECancel}
+        submitHandler={submitHandler}
+      />
+      {KSQLTable && <TableRenderer table={KSQLTable} />}
+      {fetching && <S.ContinuousLoader />}
     </>
   );
 };

+ 57 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/QueryForm.styled.ts

@@ -0,0 +1,57 @@
+import styled, { css } from 'styled-components';
+import BaseSQLEditor from 'components/common/SQLEditor/SQLEditor';
+import BaseEditor from 'components/common/Editor/Editor';
+
+export const QueryWrapper = styled.div`
+  padding: 16px;
+`;
+
+export const KSQLInputsWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  gap: 24px;
+
+  padding-bottom: 16px;
+  & > div {
+    flex-grow: 1;
+  }
+`;
+
+export const KSQLInputHeader = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
+export const KSQLButtons = styled.div`
+  display: flex;
+  gap: 16px;
+`;
+
+export const Fieldset = styled.fieldset`
+  width: 100%;
+`;
+
+export const Editor = styled(BaseEditor)(
+  ({ readOnly, theme }) =>
+    readOnly &&
+    css`
+      &,
+      &.ace-tomorrow {
+        background: ${theme.ksqlDb.query.editor.readonly.background};
+      }
+      .ace-cursor {
+        ${theme.ksqlDb.query.editor.readonly.cursor}
+      }
+    `
+);
+
+export const SQLEditor = styled(BaseSQLEditor)(
+  ({ readOnly, theme }) =>
+    readOnly &&
+    css`
+      background: ${theme.ksqlDb.query.editor.readonly.background};
+      .ace-cursor {
+        ${theme.ksqlDb.query.editor.readonly.cursor}
+      }
+    `
+);

+ 170 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx

@@ -0,0 +1,170 @@
+import React from 'react';
+import { FormError } from 'components/common/Input/Input.styled';
+import { ErrorMessage } from '@hookform/error-message';
+import { yupResolver } from '@hookform/resolvers/yup';
+import yup from 'lib/yupExtended';
+import { useForm, Controller } from 'react-hook-form';
+import { Button } from 'components/common/Button/Button';
+import { SchemaType } from 'generated-sources';
+
+import * as S from './QueryForm.styled';
+
+export interface Props {
+  fetching: boolean;
+  hasResults: boolean;
+  handleClearResults: () => void;
+  handleSSECancel: () => void;
+  submitHandler: (values: FormValues) => void;
+}
+
+export type FormValues = {
+  ksql: string;
+  streamsProperties: string;
+};
+
+const validationSchema = yup.object({
+  ksql: yup.string().trim().required(),
+  streamsProperties: yup.lazy((value) =>
+    value === '' ? yup.string().trim() : yup.string().trim().isJsonObject()
+  ),
+});
+
+const QueryForm: React.FC<Props> = ({
+  fetching,
+  hasResults,
+  handleClearResults,
+  handleSSECancel,
+  submitHandler,
+}) => {
+  const {
+    handleSubmit,
+    setValue,
+    control,
+    formState: { errors },
+  } = useForm<FormValues>({
+    mode: 'onTouched',
+    resolver: yupResolver(validationSchema),
+    defaultValues: {
+      ksql: '',
+      streamsProperties: '',
+    },
+  });
+
+  return (
+    <S.QueryWrapper>
+      <form onSubmit={handleSubmit(submitHandler)}>
+        <S.KSQLInputsWrapper>
+          <S.Fieldset aria-labelledby="ksqlLabel">
+            <S.KSQLInputHeader>
+              <label id="ksqlLabel">KSQL</label>
+              <Button
+                onClick={() => setValue('ksql', '')}
+                buttonType="primary"
+                buttonSize="S"
+                isInverted
+              >
+                Clear
+              </Button>
+            </S.KSQLInputHeader>
+            <Controller
+              control={control}
+              name="ksql"
+              render={({ field }) => (
+                <S.SQLEditor
+                  {...field}
+                  commands={[
+                    {
+                      // commands is array of key bindings.
+                      // name for the key binding.
+                      name: 'commandName',
+                      // key combination used for the command.
+                      bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' },
+                      // function to execute when keys are pressed.
+                      exec: () => {
+                        handleSubmit(submitHandler)();
+                      },
+                    },
+                  ]}
+                  readOnly={fetching}
+                />
+              )}
+            />
+            <FormError>
+              <ErrorMessage errors={errors} name="ksql" />
+            </FormError>
+          </S.Fieldset>
+          <S.Fieldset aria-labelledby="streamsPropertiesLabel">
+            <S.KSQLInputHeader>
+              <label id="streamsPropertiesLabel">
+                Stream properties (JSON format)
+              </label>
+              <Button
+                onClick={() => setValue('streamsProperties', '')}
+                buttonType="primary"
+                buttonSize="S"
+                isInverted
+              >
+                Clear
+              </Button>
+            </S.KSQLInputHeader>
+            <Controller
+              control={control}
+              name="streamsProperties"
+              render={({ field }) => (
+                <S.Editor
+                  {...field}
+                  commands={[
+                    {
+                      // commands is array of key bindings.
+                      // name for the key binding.
+                      name: 'commandName',
+                      // key combination used for the command.
+                      bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' },
+                      // function to execute when keys are pressed.
+                      exec: () => {
+                        handleSubmit(submitHandler)();
+                      },
+                    },
+                  ]}
+                  schemaType={SchemaType.JSON}
+                  readOnly={fetching}
+                />
+              )}
+            />
+            <FormError>
+              <ErrorMessage errors={errors} name="streamsProperties" />
+            </FormError>
+          </S.Fieldset>
+        </S.KSQLInputsWrapper>
+        <S.KSQLButtons>
+          <Button
+            buttonType="primary"
+            buttonSize="M"
+            type="submit"
+            disabled={fetching}
+          >
+            Execute
+          </Button>
+          <Button
+            buttonType="secondary"
+            buttonSize="M"
+            disabled={!fetching}
+            onClick={handleSSECancel}
+          >
+            Stop query
+          </Button>
+          <Button
+            buttonType="secondary"
+            buttonSize="M"
+            disabled={fetching || !hasResults}
+            onClick={handleClearResults}
+          >
+            Clear results
+          </Button>
+        </S.KSQLButtons>
+      </form>
+    </S.QueryWrapper>
+  );
+};
+
+export default QueryForm;

+ 332 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/QueryForm/__test__/QueryForm.spec.tsx

@@ -0,0 +1,332 @@
+import { render } from 'lib/testHelpers';
+import React from 'react';
+import QueryForm, { Props } from 'components/KsqlDb/Query/QueryForm/QueryForm';
+import { screen, waitFor, within } from '@testing-library/dom';
+import userEvent from '@testing-library/user-event';
+
+const renderComponent = (props: Props) => render(<QueryForm {...props} />);
+
+describe('QueryForm', () => {
+  it('renders', () => {
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    const KSQLBlock = screen.getByLabelText('KSQL');
+    expect(KSQLBlock).toBeInTheDocument();
+    expect(within(KSQLBlock).getByText('KSQL')).toBeInTheDocument();
+    expect(
+      within(KSQLBlock).getByRole('button', { name: 'Clear' })
+    ).toBeInTheDocument();
+    // Represents SQL editor
+    expect(within(KSQLBlock).getByRole('textbox')).toBeInTheDocument();
+
+    const streamPropertiesBlock = screen.getByLabelText(
+      'Stream properties (JSON format)'
+    );
+    expect(streamPropertiesBlock).toBeInTheDocument();
+    expect(
+      within(streamPropertiesBlock).getByText('Stream properties (JSON format)')
+    ).toBeInTheDocument();
+    expect(
+      within(streamPropertiesBlock).getByRole('button', { name: 'Clear' })
+    ).toBeInTheDocument();
+    // Represents JSON editor
+    expect(
+      within(streamPropertiesBlock).getByRole('textbox')
+    ).toBeInTheDocument();
+
+    // Form controls
+    expect(screen.getByRole('button', { name: 'Execute' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Execute' })).toBeEnabled();
+    expect(
+      screen.getByRole('button', { name: 'Stop query' })
+    ).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Stop query' })).toBeDisabled();
+    expect(
+      screen.getByRole('button', { name: 'Clear results' })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('button', { name: 'Clear results' })
+    ).toBeDisabled();
+  });
+
+  it('renders error with empty input', async () => {
+    const submitFn = jest.fn();
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: submitFn,
+    });
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+    expect(screen.getByText('ksql is a required field')).toBeInTheDocument();
+    expect(submitFn).not.toBeCalled();
+  });
+
+  it('renders error with non-JSON streamProperties', async () => {
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    await waitFor(() =>
+      // the use of `paste` is a hack that i found somewhere,
+      // `type` won't work
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        'not-a-JSON-string'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+
+    expect(
+      screen.getByText('streamsProperties is not JSON object')
+    ).toBeInTheDocument();
+  });
+
+  it('renders without error with correct JSON', async () => {
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"totallyJSON": "string"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+
+    expect(
+      screen.queryByText('streamsProperties is not JSON object')
+    ).not.toBeInTheDocument();
+  });
+
+  it('submits with correct inputs', async () => {
+    const submitFn = jest.fn();
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: submitFn,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"totallyJSON": "string"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+
+    expect(
+      screen.queryByText('ksql is a required field')
+    ).not.toBeInTheDocument();
+
+    expect(
+      screen.queryByText('streamsProperties is not JSON object')
+    ).not.toBeInTheDocument();
+
+    expect(submitFn).toBeCalled();
+  });
+
+  it('clear results is enabled when has results', async () => {
+    const clearFn = jest.fn();
+    renderComponent({
+      fetching: false,
+      hasResults: true,
+      handleClearResults: clearFn,
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    expect(screen.getByRole('button', { name: 'Clear results' })).toBeEnabled();
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Clear results' }))
+    );
+
+    expect(clearFn).toBeCalled();
+  });
+
+  it('stop query query is enabled when is fetching', async () => {
+    const cancelFn = jest.fn();
+    renderComponent({
+      fetching: true,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: cancelFn,
+      submitHandler: jest.fn(),
+    });
+
+    expect(screen.getByRole('button', { name: 'Stop query' })).toBeEnabled();
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Stop query' }))
+    );
+
+    expect(cancelFn).toBeCalled();
+  });
+
+  it('submits form with ctrl+enter on KSQL editor', async () => {
+    const submitFn = jest.fn();
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: submitFn,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.type(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        '{ctrl}{enter}'
+      )
+    );
+
+    expect(submitFn.mock.calls.length).toBe(1);
+  });
+
+  it('submits form with ctrl+enter on streamProperties editor', async () => {
+    const submitFn = jest.fn();
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: submitFn,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"some":"json"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.type(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{ctrl}{enter}'
+      )
+    );
+
+    expect(submitFn.mock.calls.length).toBe(1);
+  });
+
+  it('clears KSQL with Clear button', async () => {
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(
+        within(screen.getByLabelText('KSQL')).getByRole('button', {
+          name: 'Clear',
+        })
+      )
+    );
+
+    expect(screen.queryByText('show tables;')).not.toBeInTheDocument();
+  });
+
+  it('clears streamProperties with Clear button', async () => {
+    renderComponent({
+      fetching: false,
+      hasResults: false,
+      handleClearResults: jest.fn(),
+      handleSSECancel: jest.fn(),
+      submitHandler: jest.fn(),
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"some":"json"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('button', {
+          name: 'Clear',
+        })
+      )
+    );
+
+    expect(screen.queryByText('{"some":"json"}')).not.toBeInTheDocument();
+  });
+});

+ 0 - 7
kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.styled.tsx

@@ -1,7 +0,0 @@
-import styled from 'styled-components';
-
-export const Wrapper = styled.div`
-  display: block;
-  padding: 1.25rem;
-  border-radius: 6px;
-`;

+ 0 - 67
kafka-ui-react-app/src/components/KsqlDb/Query/ResultRenderer.tsx

@@ -1,67 +0,0 @@
-import React from 'react';
-import { KsqlCommandResponse, Table } from 'generated-sources';
-
-import * as S from './ResultRenderer.styled';
-
-const ResultRenderer: React.FC<{ result: KsqlCommandResponse | null }> = ({
-  result,
-}) => {
-  if (!result) return null;
-
-  const isMessage = !!result.message;
-
-  if (isMessage) return <S.Wrapper>{result.message}</S.Wrapper>;
-
-  const isTable = result.data !== undefined;
-
-  if (!isTable) return null;
-
-  const rawTable = result.data as Table;
-
-  const { headers, rows } = rawTable;
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const transformedRows = React.useMemo(
-    () =>
-      rows.map((row) =>
-        row.reduce(
-          (res, acc, index) => ({
-            ...res,
-            [rawTable.headers[index]]: acc,
-          }),
-          {} as Dictionary<string>
-        )
-      ),
-    [rawTable.headers, rows]
-  );
-
-  return (
-    <S.Wrapper>
-      <table className="table is-fullwidth">
-        <thead>
-          <tr>
-            {headers.map((header) => (
-              <th key={header}>{header}</th>
-            ))}
-          </tr>
-        </thead>
-        <tbody>
-          {transformedRows.map((row) => (
-            <tr key={row.name}>
-              {headers.map((header) => (
-                <td key={header}>{row[header]}</td>
-              ))}
-            </tr>
-          ))}
-          {rows.length === 0 && (
-            <tr>
-              <td colSpan={headers.length}>No tables or streams found</td>
-            </tr>
-          )}
-        </tbody>
-      </table>
-    </S.Wrapper>
-  );
-};
-
-export default ResultRenderer;

+ 172 - 63
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/Query.spec.tsx

@@ -1,73 +1,182 @@
+import { render } from 'lib/testHelpers';
 import React from 'react';
-import { mount } from 'enzyme';
-import Query from 'components/KsqlDb/Query/Query';
-import { StaticRouter } from 'react-router';
-import configureStore from 'redux-mock-store';
-import { RootState } from 'redux/interfaces';
-import { ksqlCommandResponse } from 'redux/reducers/ksqlDb/__test__/fixtures';
-import { Provider } from 'react-redux';
-import { ThemeProvider } from 'styled-components';
-import theme from 'theme/theme';
-
-const mockStore = configureStore();
-
-describe('KsqlDb Query Component', () => {
-  const pathname = `clusters/local/ksql-db/query`;
-
-  it('Renders result', () => {
-    const initialState: Partial<RootState> = {
-      ksqlDb: {
-        streams: [],
-        tables: [],
-        executionResult: ksqlCommandResponse,
-      },
-      loader: {
-        'ksqlDb/executeKsql': 'fulfilled',
-      },
-    };
-    const store = mockStore(initialState);
-
-    const component = mount(
-      <ThemeProvider theme={theme}>
-        <StaticRouter location={{ pathname }} context={{}}>
-          <Provider store={store}>
-            <Query />
-          </Provider>
-        </StaticRouter>
-      </ThemeProvider>
+import Query, {
+  getFormattedErrorFromTableData,
+} from 'components/KsqlDb/Query/Query';
+import { screen, waitFor, within } from '@testing-library/dom';
+import fetchMock from 'fetch-mock';
+import userEvent from '@testing-library/user-event';
+import { Route } from 'react-router-dom';
+import { clusterKsqlDbQueryPath } from 'lib/paths';
+
+const clusterName = 'testLocal';
+const renderComponent = () =>
+  render(
+    <Route path={clusterKsqlDbQueryPath(':clusterName')}>
+      <Query />
+    </Route>,
+    {
+      pathname: clusterKsqlDbQueryPath(clusterName),
+    }
+  );
+
+// Small mock to get rid of reference error
+class EventSourceMock {
+  url: string;
+
+  close: () => void;
+
+  open: () => void;
+
+  error: () => void;
+
+  onmessage: () => void;
+
+  constructor(url: string) {
+    this.url = url;
+    this.open = jest.fn();
+    this.error = jest.fn();
+    this.onmessage = jest.fn();
+    this.close = jest.fn();
+  }
+}
+
+describe('Query', () => {
+  it('renders', () => {
+    renderComponent();
+
+    expect(screen.getByLabelText('KSQL')).toBeInTheDocument();
+    expect(
+      screen.getByLabelText('Stream properties (JSON format)')
+    ).toBeInTheDocument();
+  });
+
+  afterEach(() => fetchMock.reset());
+  it('fetch on execute', async () => {
+    renderComponent();
+
+    const mock = fetchMock.postOnce(`/api/clusters/${clusterName}/ksql/v2`, {
+      pipeId: 'testPipeID',
+    });
+
+    Object.defineProperty(window, 'EventSource', {
+      value: EventSourceMock,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
     );
 
-    // 2 streams and 1 head tr
-    expect(component.find('tr').length).toEqual(3);
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+    expect(mock.calls().length).toBe(1);
   });
 
-  it('Renders result message', () => {
-    const initialState: Partial<RootState> = {
-      ksqlDb: {
-        streams: [],
-        tables: [],
-        executionResult: {
-          message: 'No available data',
-        },
-      },
-      loader: {
-        'ksqlDb/executeKsql': 'fulfilled',
-      },
-    };
-    const store = mockStore(initialState);
-
-    const component = mount(
-      <ThemeProvider theme={theme}>
-        <StaticRouter location={{ pathname }} context={{}}>
-          <Provider store={store}>
-            <Query />
-          </Provider>
-        </StaticRouter>
-      </ThemeProvider>
+  it('fetch on execute with streamParams', async () => {
+    renderComponent();
+
+    const mock = fetchMock.postOnce(`/api/clusters/${clusterName}/ksql/v2`, {
+      pipeId: 'testPipeID',
+    });
+
+    Object.defineProperty(window, 'EventSource', {
+      value: EventSourceMock,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
     );
 
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"some":"json"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+    expect(mock.calls().length).toBe(1);
+  });
+
+  it('fetch on execute with streamParams', async () => {
+    renderComponent();
+
+    const mock = fetchMock.postOnce(`/api/clusters/${clusterName}/ksql/v2`, {
+      pipeId: 'testPipeID',
+    });
+
+    Object.defineProperty(window, 'EventSource', {
+      value: EventSourceMock,
+    });
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(screen.getByLabelText('KSQL')).getByRole('textbox'),
+        'show tables;'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.paste(
+        within(
+          screen.getByLabelText('Stream properties (JSON format)')
+        ).getByRole('textbox'),
+        '{"some":"json"}'
+      )
+    );
+
+    await waitFor(() =>
+      userEvent.click(screen.getByRole('button', { name: 'Execute' }))
+    );
+    expect(mock.calls().length).toBe(1);
+  });
+});
+
+describe('getFormattedErrorFromTableData', () => {
+  it('works', () => {
+    expect(getFormattedErrorFromTableData([['Test Error']])).toStrictEqual({
+      title: 'Test Error',
+      message: '',
+    });
+
+    expect(
+      getFormattedErrorFromTableData([
+        ['some_type', 'errorCode', 'messageText'],
+      ])
+    ).toStrictEqual({
+      title: '[Error #errorCode] some_type',
+      message: 'messageText',
+    });
+
     expect(
-      component.find({ children: 'No available data' }).exists()
-    ).toBeTruthy();
+      getFormattedErrorFromTableData([
+        [
+          'some_type',
+          'errorCode',
+          'messageText',
+          'statementText',
+          ['test1', 'test2'],
+        ],
+      ])
+    ).toStrictEqual({
+      title: '[Error #errorCode] some_type',
+      message: '[test1, test2] "statementText" messageText',
+    });
+
+    expect(getFormattedErrorFromTableData([])).toStrictEqual({
+      title: 'Unknown error',
+      message: 'Recieved empty response',
+    });
   });
 });

+ 0 - 14
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/ResultRenderer.spec.tsx

@@ -1,14 +0,0 @@
-import { mount } from 'enzyme';
-import { KsqlCommandResponse } from 'generated-sources';
-import React from 'react';
-import ResultRenderer from 'components/KsqlDb/Query/ResultRenderer';
-
-describe('Result Renderer', () => {
-  const result: KsqlCommandResponse = {};
-
-  it('Matches snapshot', () => {
-    const component = mount(<ResultRenderer result={result} />);
-
-    expect(component).toMatchSnapshot();
-  });
-});

+ 0 - 7
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/__snapshots__/ResultRenderer.spec.tsx.snap

@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Result Renderer Matches snapshot 1`] = `
-<ResultRenderer
-  result={Object {}}
-/>
-`;

+ 15 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/TableRenderer.styled.tsx

@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+import { Table } from 'components/common/table/Table/Table.styled';
+
+export const Wrapper = styled.div`
+  display: block;
+  overflow-y: scroll;
+`;
+
+export const ScrollableTable = styled(Table)`
+  overflow-y: scroll;
+
+  td {
+    vertical-align: top;
+  }
+`;

+ 87 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/TableRenderer.tsx

@@ -0,0 +1,87 @@
+import React from 'react';
+import { KsqlTableResponse } from 'generated-sources';
+import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
+import { nanoid } from '@reduxjs/toolkit';
+import { TableTitle } from 'components/common/table/TableTitle/TableTitle.styled';
+
+import * as S from './TableRenderer.styled';
+
+export interface Props {
+  table: KsqlTableResponse;
+}
+
+export function hasJsonStructure(
+  str: string | Record<string, unknown>
+): boolean {
+  if (typeof str === 'object') {
+    return true;
+  }
+
+  if (typeof str === 'string') {
+    try {
+      const result = JSON.parse(str);
+      const type = Object.prototype.toString.call(result);
+      return type === '[object Object]' || type === '[object Array]';
+    } catch (err) {
+      return false;
+    }
+  }
+
+  return false;
+}
+
+const TableRenderer: React.FC<Props> = ({ table }) => {
+  const heading = React.useMemo(() => {
+    return table.header || '';
+  }, [table.header]);
+  const ths = React.useMemo(() => {
+    return table.columnNames || [];
+  }, [table.columnNames]);
+  const rows = React.useMemo(() => {
+    return (table.values || []).map((row) => {
+      return {
+        id: nanoid(),
+        cells: row.map((cell) => {
+          return {
+            id: nanoid(),
+            value: hasJsonStructure(cell)
+              ? JSON.stringify(cell, null, 2)
+              : cell,
+          };
+        }),
+      };
+    });
+  }, [table.values]);
+
+  return (
+    <S.Wrapper>
+      <TableTitle>{heading}</TableTitle>
+      <S.ScrollableTable>
+        <thead>
+          <tr>
+            {ths.map((th) => (
+              <TableHeaderCell title={th} key={th} />
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {ths.length === 0 ? (
+            <tr>
+              <td colSpan={ths.length}>No tables or streams found</td>
+            </tr>
+          ) : (
+            rows.map((row) => (
+              <tr key={row.id}>
+                {row.cells.map((cell) => (
+                  <td key={cell.id}>{cell.value}</td>
+                ))}
+              </tr>
+            ))
+          )}
+        </tbody>
+      </S.ScrollableTable>
+    </S.Wrapper>
+  );
+};
+
+export default TableRenderer;

+ 71 - 0
kafka-ui-react-app/src/components/KsqlDb/Query/renderer/TableRenderer/__test__/TableRenderer.spec.tsx

@@ -0,0 +1,71 @@
+import { render } from 'lib/testHelpers';
+import React from 'react';
+import TableRenderer, {
+  Props,
+  hasJsonStructure,
+} from 'components/KsqlDb/Query/renderer/TableRenderer/TableRenderer';
+import { screen } from '@testing-library/dom';
+
+const renderComponent = (props: Props) => render(<TableRenderer {...props} />);
+
+describe('TableRenderer', () => {
+  it('renders', () => {
+    renderComponent({
+      table: {
+        header: 'Test header',
+        columnNames: ['Test column name'],
+        values: [['Table row #1'], ['Table row #2'], ['{"jsonrow": "#3"}']],
+      },
+    });
+
+    expect(
+      screen.getByRole('heading', { name: 'Test header' })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', { name: 'Test column name' })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('cell', { name: 'Table row #1' })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('cell', { name: 'Table row #2' })
+    ).toBeInTheDocument();
+  });
+
+  it('renders with empty arrays', () => {
+    renderComponent({
+      table: {},
+    });
+
+    expect(screen.getByText('No tables or streams found')).toBeInTheDocument();
+  });
+});
+
+describe('hasJsonStructure', () => {
+  it('works', () => {
+    expect(hasJsonStructure('simplestring')).toBeFalsy();
+    expect(
+      hasJsonStructure("{'looksLikeJson': 'but has wrong quotes'}")
+    ).toBeFalsy();
+    expect(
+      hasJsonStructure('{"json": "but doesnt have closing brackets"')
+    ).toBeFalsy();
+    expect(hasJsonStructure('"string":"that looks like json"')).toBeFalsy();
+
+    expect(hasJsonStructure('1')).toBeFalsy();
+    expect(hasJsonStructure('{1:}')).toBeFalsy();
+    expect(hasJsonStructure('{1:"1"}')).toBeFalsy();
+
+    // @ts-expect-error We suppress error because this function works with unknown data from server
+    expect(hasJsonStructure(1)).toBeFalsy();
+
+    expect(hasJsonStructure('{}')).toBeTruthy();
+    expect(hasJsonStructure('{"correct": "json"}')).toBeTruthy();
+
+    expect(hasJsonStructure('[]')).toBeTruthy();
+    expect(hasJsonStructure('[{}]')).toBeTruthy();
+
+    expect(hasJsonStructure({})).toBeTruthy();
+    expect(hasJsonStructure({ correct: 'json' })).toBeTruthy();
+  });
+});

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

@@ -30,10 +30,10 @@ import {
 import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice';
 import { getResponse } from 'lib/errorHandling';
 import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
+import { TableTitle } from 'components/common/table/TableTitle/TableTitle.styled';
 
 import LatestVersionItem from './LatestVersion/LatestVersionItem';
 import SchemaVersion from './SchemaVersion/SchemaVersion';
-import { OldVersionsTitle } from './SchemaVersion/SchemaVersion.styled';
 
 const Details: React.FC = () => {
   const history = useHistory();
@@ -124,7 +124,7 @@ const Details: React.FC = () => {
         )}
       </PageHeading>
       <LatestVersionItem schema={schema} />
-      <OldVersionsTitle>Old versions</OldVersionsTitle>
+      <TableTitle>Old versions</TableTitle>
       {areVersionsFetched ? (
         <Table isFullwidth>
           <thead>

+ 7 - 9
kafka-ui-react-app/src/components/Schemas/Details/LatestVersion/LatestVersionItem.styled.ts → kafka-ui-react-app/src/components/Schemas/Details/LatestVersion/LatestVersionItem.styled.tsx

@@ -1,7 +1,9 @@
+import Heading from 'components/common/heading/Heading.styled';
+import React from 'react';
 import styled from 'styled-components';
 import theme from 'theme/theme';
 
-export const LatestVersionWrapper = styled.div`
+export const Wrapper = styled.div`
   width: 100%;
   background-color: ${theme.layout.stuffColor};
   padding: 16px;
@@ -20,11 +22,6 @@ export const LatestVersionWrapper = styled.div`
   & > div:first-child {
     border-radius: 8px 0 0 8px;
     flex-grow: 2;
-
-    & > h1 {
-      font-size: 16px;
-      font-weight: 500;
-    }
   }
 
   & > div:last-child {
@@ -39,8 +36,9 @@ export const LatestVersionWrapper = styled.div`
   }
 `;
 
-export const MetaDataLabel = styled.h3`
-  color: ${theme.heading.h3.color};
+export const MetaDataLabel = styled((props) => (
+  <Heading level={4} {...props} />
+))`
+  color: ${theme.lastestVersionItem.metaDataLabel.color};
   width: 110px;
-  font-size: ${theme.heading.h3.fontSize};
 `;

+ 9 - 11
kafka-ui-react-app/src/components/Schemas/Details/LatestVersion/LatestVersionItem.tsx

@@ -1,11 +1,9 @@
 import React from 'react';
 import { SchemaSubject } from 'generated-sources';
 import EditorViewer from 'components/common/EditorViewer/EditorViewer';
+import Heading from 'components/common/heading/Heading.styled';
 
-import {
-  LatestVersionWrapper,
-  MetaDataLabel,
-} from './LatestVersionItem.styled';
+import * as S from './LatestVersionItem.styled';
 
 interface LatestVersionProps {
   schema: SchemaSubject;
@@ -14,30 +12,30 @@ interface LatestVersionProps {
 const LatestVersionItem: React.FC<LatestVersionProps> = ({
   schema: { id, subject, schema, compatibilityLevel, version, schemaType },
 }) => (
-  <LatestVersionWrapper>
+  <S.Wrapper>
     <div>
-      <h1>Relevant version</h1>
+      <Heading level={3}>Relevant version</Heading>
       <EditorViewer data={schema} schemaType={schemaType} maxLines={28} />
     </div>
     <div data-testid="meta-data">
       <div>
-        <MetaDataLabel>Latest version</MetaDataLabel>
+        <S.MetaDataLabel>Latest version</S.MetaDataLabel>
         <p>{version}</p>
       </div>
       <div>
-        <MetaDataLabel>ID</MetaDataLabel>
+        <S.MetaDataLabel>ID</S.MetaDataLabel>
         <p>{id}</p>
       </div>
       <div>
-        <MetaDataLabel>Subject</MetaDataLabel>
+        <S.MetaDataLabel>Subject</S.MetaDataLabel>
         <p>{subject}</p>
       </div>
       <div>
-        <MetaDataLabel>Compatibility</MetaDataLabel>
+        <S.MetaDataLabel>Compatibility</S.MetaDataLabel>
         <p>{compatibilityLevel}</p>
       </div>
     </div>
-  </LatestVersionWrapper>
+  </S.Wrapper>
 );
 
 export default LatestVersionItem;

+ 1 - 8
kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion/SchemaVersion.styled.ts

@@ -1,6 +1,6 @@
 import styled from 'styled-components';
 
-export const SchemaVersionWrapper = styled.tr`
+export const Wrapper = styled.tr`
   background-color: ${({ theme }) => theme.schema.backgroundColor.tr};
   & > td {
     padding: 16px !important;
@@ -11,10 +11,3 @@ export const SchemaVersionWrapper = styled.tr`
     }
   }
 `;
-
-export const OldVersionsTitle = styled.h1`
-  font-weight: 500;
-  font-size: 16px;
-  line-height: 24px;
-  padding: 16px;
-`;

+ 3 - 3
kafka-ui-react-app/src/components/Schemas/Details/SchemaVersion/SchemaVersion.tsx

@@ -4,7 +4,7 @@ import MessageToggleIcon from 'components/common/Icons/MessageToggleIcon';
 import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
 import EditorViewer from 'components/common/EditorViewer/EditorViewer';
 
-import { SchemaVersionWrapper } from './SchemaVersion.styled';
+import * as S from './SchemaVersion.styled';
 
 interface SchemaVersionProps {
   version: SchemaSubject;
@@ -28,11 +28,11 @@ const SchemaVersion: React.FC<SchemaVersionProps> = ({
         <td>{id}</td>
       </tr>
       {isOpen && (
-        <SchemaVersionWrapper>
+        <S.Wrapper>
           <td colSpan={3}>
             <EditorViewer data={schema} schemaType={schemaType} />
           </td>
-        </SchemaVersionWrapper>
+        </S.Wrapper>
       )}
     </>
   );

+ 2 - 4
kafka-ui-react-app/src/components/common/ConfirmationModal/ConfirmationModal.tsx

@@ -25,9 +25,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
     }
   }, [isConfirming, onCancel]);
 
-  if (!isOpen) return null;
-
-  return (
+  return isOpen ? (
     <ConfirmationModalWrapper>
       <div onClick={cancelHandler} aria-hidden="true" />
       <div>
@@ -58,7 +56,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
         </footer>
       </div>
     </ConfirmationModalWrapper>
-  );
+  ) : null;
 };
 
 export default ConfirmationModal;

+ 14 - 20
kafka-ui-react-app/src/components/common/PageHeading/PageHeading.tsx

@@ -1,5 +1,6 @@
-import styled, { css } from 'styled-components';
+import styled from 'styled-components';
 import React from 'react';
+import Heading from 'components/common/heading/Heading.styled';
 
 interface Props {
   text: string;
@@ -9,28 +10,21 @@ interface Props {
 const PageHeading: React.FC<Props> = ({ text, className, children }) => {
   return (
     <div className={className}>
-      <h1>{text}</h1>
+      <Heading>{text}</Heading>
       <div>{children}</div>
     </div>
   );
 };
 
-export default styled(PageHeading)(
-  ({ theme }) => css`
-    height: 56px;
+export default styled(PageHeading)`
+  height: 56px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0px 16px;
+
+  & > div {
     display: flex;
-    justify-content: space-between;
-    align-items: center;
-    padding: 0px 16px;
-    & h1 {
-      font-size: 24px;
-      font-weight: 500;
-      line-height: 32px;
-      color: ${theme.heading.h1.color};
-    }
-    & > div {
-      display: flex;
-      gap: 16px;
-    }
-  `
-);
+    gap: 16px;
+  }
+`;

+ 2 - 0
kafka-ui-react-app/src/components/common/SQLEditor/SQLEditor.tsx

@@ -31,4 +31,6 @@ const SQLEditor = React.forwardRef<ReactAce | null, SQLEditorProps>(
   }
 );
 
+SQLEditor.displayName = 'SQLEditor';
+
 export default SQLEditor;

+ 20 - 0
kafka-ui-react-app/src/components/common/heading/Heading.styled.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import styled from 'styled-components';
+
+type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+interface HeadingBaseProps {
+  $level: HeadingLevel;
+}
+const HeadingBase = styled.h1<HeadingBaseProps>`
+  ${({ theme }) => theme.heading.base}
+  ${({ theme, $level }) => theme.heading.variants[$level]}
+`;
+
+export interface Props {
+  level?: HeadingLevel;
+}
+const Heading: React.FC<Props> = ({ level = 1, ...rest }) => {
+  return <HeadingBase as={`h${level}`} $level={level} {...rest} />;
+};
+
+export default Heading;

+ 7 - 0
kafka-ui-react-app/src/components/common/table/TableTitle/TableTitle.styled.tsx

@@ -0,0 +1,7 @@
+import React from 'react';
+import Heading from 'components/common/heading/Heading.styled';
+import styled from 'styled-components';
+
+export const TableTitle = styled((props) => <Heading level={3} {...props} />)`
+  padding: 16px;
+`;

+ 2 - 2
kafka-ui-react-app/src/redux/interfaces/ksqlDb.ts

@@ -1,4 +1,4 @@
-import { KsqlCommandResponse } from 'generated-sources';
+import { KsqlCommandV2Response } from 'generated-sources';
 
 export interface KsqlTables {
   data: {
@@ -10,5 +10,5 @@ export interface KsqlTables {
 export interface KsqlState {
   tables: Dictionary<string>[];
   streams: Dictionary<string>[];
-  executionResult: KsqlCommandResponse | null;
+  executionResult: KsqlCommandV2Response | null;
 }

+ 0 - 96
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/__snapshots__/reducer.spec.ts.snap

@@ -1,96 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`KsqlDb reducer Exexute ksql and get result 1`] = `
-Object {
-  "executionResult": Object {
-    "streams": Array [
-      Object {
-        "isWindowed": "false",
-        "keyFormat": "KAFKA",
-        "name": "KSQL_PROCESSING_LOG",
-        "topic": "default_ksql_processing_log",
-        "type": "STREAM",
-        "valueFormat": "JSON",
-      },
-      Object {
-        "isWindowed": "false",
-        "keyFormat": "KAFKA",
-        "name": "PAGEVIEWS",
-        "topic": "pageviews",
-        "type": "STREAM",
-        "valueFormat": "AVRO",
-      },
-    ],
-    "tables": Array [
-      Object {
-        "isWindowed": "false",
-        "keyFormat": "KAFKA",
-        "name": "USERS",
-        "topic": "users",
-        "type": "TABLE",
-        "valueFormat": "AVRO",
-      },
-      Object {
-        "isWindowed": "false",
-        "keyFormat": "KAFKA",
-        "name": "USERS2",
-        "topic": "users",
-        "type": "TABLE",
-        "valueFormat": "AVRO",
-      },
-    ],
-  },
-  "streams": Array [],
-  "tables": Array [],
-}
-`;
-
-exports[`KsqlDb reducer Fetches tables and streams 1`] = `
-Object {
-  "executionResult": null,
-  "streams": Array [
-    Object {
-      "isWindowed": "false",
-      "keyFormat": "KAFKA",
-      "name": "KSQL_PROCESSING_LOG",
-      "topic": "default_ksql_processing_log",
-      "type": "STREAM",
-      "valueFormat": "JSON",
-    },
-    Object {
-      "isWindowed": "false",
-      "keyFormat": "KAFKA",
-      "name": "PAGEVIEWS",
-      "topic": "pageviews",
-      "type": "STREAM",
-      "valueFormat": "AVRO",
-    },
-  ],
-  "tables": Array [
-    Object {
-      "isWindowed": "false",
-      "keyFormat": "KAFKA",
-      "name": "USERS",
-      "topic": "users",
-      "type": "TABLE",
-      "valueFormat": "AVRO",
-    },
-    Object {
-      "isWindowed": "false",
-      "keyFormat": "KAFKA",
-      "name": "USERS2",
-      "topic": "users",
-      "type": "TABLE",
-      "valueFormat": "AVRO",
-    },
-  ],
-}
-`;
-
-exports[`KsqlDb reducer Resets execution result 1`] = `
-Object {
-  "executionResult": null,
-  "streams": Array [],
-  "tables": Array [],
-}
-`;

+ 19 - 20
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/fixtures.ts

@@ -41,25 +41,24 @@ export const fetchKsqlDbTablesPayload: {
 };
 
 export const ksqlCommandResponse = {
-  data: {
-    headers: [
-      'type',
-      'name',
-      'topic',
-      'keyFormat',
-      'valueFormat',
-      'isWindowed',
-    ],
-    rows: [
-      [
-        'STREAM',
-        'KSQL_PROCESSING_LOG',
-        'default_ksql_processing_log',
-        'KAFKA',
-        'JSON',
-        'false',
-      ],
-      ['STREAM', 'PAGEVIEWS', 'pageviews', 'KAFKA', 'AVRO', 'false'],
+  header: 'Test header',
+  columnNames: [
+    'type',
+    'name',
+    'topic',
+    'keyFormat',
+    'valueFormat',
+    'isWindowed',
+  ],
+  rows: [
+    [
+      'STREAM',
+      'KSQL_PROCESSING_LOG',
+      'default_ksql_processing_log',
+      'KAFKA',
+      'JSON',
+      'false',
     ],
-  },
+    ['STREAM', 'PAGEVIEWS', 'pageviews', 'KAFKA', 'AVRO', 'false'],
+  ],
 };

+ 0 - 65
kafka-ui-react-app/src/redux/reducers/ksqlDb/__test__/reducer.spec.ts

@@ -1,65 +0,0 @@
-import reducer, {
-  initialState,
-  fetchKsqlDbTables,
-  resetExecutionResult,
-  executeKsql,
-  transformKsqlResponse,
-} from 'redux/reducers/ksqlDb/ksqlDbSlice';
-import { Table } from 'generated-sources';
-
-import { fetchKsqlDbTablesPayload } from './fixtures';
-
-describe('KsqlDb reducer', () => {
-  it('returns the initial state', () => {
-    expect(reducer(undefined, { type: fetchKsqlDbTables.pending })).toEqual(
-      initialState
-    );
-  });
-
-  it('It should transform data with given headers and rows', () => {
-    const data: Table = {
-      headers: ['header1'],
-      rows: [['value1'], ['value2'], ['value3']],
-    };
-    const transformedData = transformKsqlResponse(data);
-    expect(transformedData).toEqual([
-      { header1: 'value1' },
-      { header1: 'value2' },
-      { header1: 'value3' },
-    ]);
-  });
-
-  it('Fetches tables and streams', () => {
-    const state = reducer(undefined, {
-      type: fetchKsqlDbTables.fulfilled,
-      payload: fetchKsqlDbTablesPayload,
-    });
-    expect(state.tables.length).toEqual(2);
-    expect(state.streams.length).toEqual(2);
-    expect(state).toMatchSnapshot();
-  });
-
-  it('Exexute ksql and get result', () => {
-    const state = reducer(undefined, {
-      type: executeKsql.fulfilled,
-      payload: fetchKsqlDbTablesPayload,
-    });
-    expect(state.executionResult).toBeTruthy();
-    expect(state).toMatchSnapshot();
-  });
-
-  it('Resets execution result', () => {
-    const state = reducer(
-      {
-        tables: [],
-        streams: [],
-        executionResult: {
-          message: 'No available data',
-        },
-      },
-      resetExecutionResult()
-    );
-    expect(state.executionResult).toEqual(null);
-    expect(state).toMatchSnapshot();
-  });
-});

+ 6 - 5
kafka-ui-react-app/src/redux/reducers/ksqlDb/ksqlDbSlice.ts

@@ -3,7 +3,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
 import { BASE_PARAMS } from 'lib/constants';
 import {
   Configuration,
-  ExecuteKsqlCommandRequest,
+  ExecuteKsqlRequest,
   KsqlApi,
   Table as KsqlTable,
 } from 'generated-sources';
@@ -40,8 +40,10 @@ const getStreams = (clusterName: ClusterName) =>
 export const fetchKsqlDbTables = createAsyncThunk(
   'ksqlDb/fetchKsqlDbTables',
   async (clusterName: ClusterName) => {
-    const tables = await getTables(clusterName);
-    const streams = await getStreams(clusterName);
+    const [tables, streams] = await Promise.all([
+      getTables(clusterName),
+      getStreams(clusterName),
+    ]);
 
     return {
       tables: tables.data ? transformKsqlResponse(tables.data) : [],
@@ -52,8 +54,7 @@ export const fetchKsqlDbTables = createAsyncThunk(
 
 export const executeKsql = createAsyncThunk(
   'ksqlDb/executeKsql',
-  (params: ExecuteKsqlCommandRequest) =>
-    ksqlDbApiClient.executeKsqlCommand(params)
+  (params: ExecuteKsqlRequest) => ksqlDbApiClient.executeKsql(params)
 );
 
 export const initialState: KsqlState = {

+ 55 - 1
kafka-ui-react-app/src/theme/theme.ts

@@ -2,7 +2,8 @@
 export const Colors = {
   neutral: {
     '0': '#FFFFFF',
-    '3': '#F9FAFA',
+    '3': '#f9fafa',
+    '4': '#f0f0f0',
     '5': '#F1F2F3',
     '10': '#E3E6E8',
     '15': '#D5DADD',
@@ -15,6 +16,7 @@ export const Colors = {
     '70': '#454F54',
     '80': '#2F3639',
     '90': '#171A1C',
+    '100': '#000',
   },
   transparency: {
     '10': 'rgba(10, 10, 10, 0.1)',
@@ -72,6 +74,21 @@ const theme = {
   dropdown: {
     color: Colors.red[50],
   },
+  ksqlDb: {
+    query: {
+      editor: {
+        readonly: {
+          background: Colors.neutral[3],
+          selection: {
+            backgroundColor: 'transparent',
+          },
+          cursor: {
+            color: 'transparent',
+          },
+        },
+      },
+    },
+  },
   heading: {
     h1: {
       color: Colors.neutral[90],
@@ -80,6 +97,43 @@ const theme = {
       color: Colors.neutral[50],
       fontSize: '14px',
     },
+    base: {
+      fontFamily: 'Inter, sans-serif',
+      fontStyle: 'normal',
+      fontWeight: 500,
+      color: Colors.neutral[100],
+    },
+    variants: {
+      1: {
+        fontSize: '24px',
+        lineHeight: '32px',
+      },
+      2: {
+        fontSize: '20px',
+        lineHeight: '32px',
+      },
+      3: {
+        fontSize: '16px',
+        lineHeight: '24px',
+      },
+      4: {
+        fontSize: '14px',
+        lineHeight: '20px',
+      },
+      5: {
+        fontSize: '12px',
+        lineHeight: '16px',
+      },
+      6: {
+        fontSize: '12px',
+        lineHeight: '16px',
+      },
+    },
+  },
+  lastestVersionItem: {
+    metaDataLabel: {
+      color: Colors.neutral[50],
+    },
   },
   alert: {
     color: {