Parcourir la source

Message fields previews (#2756)

* #2544 message fields preview

* #2544 add test coverage

* #2544 add tests to message table

* #2544 code review fix and validation message

* #2544 fix bug with validation and add edit values

* #2544 improve test coverage

* #2544 fix code review comments

* #2544 fix build fails

Co-authored-by: Kris-K-Dev <92114648+Kris-K-Dev@users.noreply.github.com>
Co-authored-by: Roman Zabaluev <rzabaluev@provectus.com>
kristi-dev il y a 2 ans
Parent
commit
fdf82986dc

+ 43 - 2
kafka-ui-react-app/src/components/Topics/Topic/Messages/Message.tsx

@@ -1,3 +1,4 @@
+import get from 'lodash/get';
 import React from 'react';
 import styled from 'styled-components';
 import useDataSaver from 'lib/hooks/useDataSaver';
@@ -22,7 +23,14 @@ const ClickableRow = styled.tr`
   cursor: pointer;
 `;
 
+export interface PreviewFilter {
+  field: string;
+  path: string;
+}
+
 export interface Props {
+  keyFilters: PreviewFilter[];
+  contentFilters: PreviewFilter[];
   message: TopicMessage;
 }
 
@@ -38,6 +46,8 @@ const Message: React.FC<Props> = ({
     keyFormat,
     headers,
   },
+  keyFilters,
+  contentFilters,
 }) => {
   const [isOpen, setIsOpen] = React.useState(false);
   const savedMessageJson = {
@@ -60,6 +70,33 @@ const Message: React.FC<Props> = ({
 
   const [vEllipsisOpen, setVEllipsisOpen] = React.useState(false);
 
+  const getParsedJson = (jsonValue: string) => {
+    try {
+      return JSON.parse(jsonValue);
+    } catch (e) {
+      return {};
+    }
+  };
+
+  const renderFilteredJson = (
+    jsonValue?: string,
+    filters?: PreviewFilter[]
+  ) => {
+    if (!filters?.length || !jsonValue) return jsonValue;
+
+    const parsedJson = getParsedJson(jsonValue);
+
+    return (
+      <>
+        {filters.map((item) => (
+          <div>
+            {item.field}: {get(parsedJson, item.path)}
+          </div>
+        ))}
+      </>
+    );
+  };
+
   return (
     <>
       <ClickableRow
@@ -77,10 +114,14 @@ const Message: React.FC<Props> = ({
         <td>
           <div>{formatTimestamp(timestamp)}</div>
         </td>
-        <StyledDataCell title={key}>{key}</StyledDataCell>
+        <StyledDataCell title={key}>
+          {renderFilteredJson(key, keyFilters)}
+        </StyledDataCell>
         <StyledDataCell>
           <S.Metadata>
-            <S.MetadataValue>{content}</S.MetadataValue>
+            <S.MetadataValue>
+              {renderFilteredJson(content, contentFilters)}
+            </S.MetadataValue>
           </S.Metadata>
         </StyledDataCell>
         <td style={{ width: '5%' }}>

+ 36 - 5
kafka-ui-react-app/src/components/Topics/Topic/Messages/MessagesTable.tsx

@@ -2,7 +2,7 @@ import PageLoader from 'components/common/PageLoader/PageLoader';
 import { Table } from 'components/common/table/Table/Table.styled';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
 import { TopicMessage } from 'generated-sources';
-import React, { useContext } from 'react';
+import React, { useContext, useState } from 'react';
 import {
   getTopicMessges,
   getIsTopicMessagesFetching,
@@ -10,14 +10,19 @@ import {
 import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
 import { useAppSelector } from 'lib/hooks/redux';
 
-import Message from './Message';
+import PreviewModal from './PreviewModal';
+import Message, { PreviewFilter } from './Message';
 
 const MessagesTable: React.FC = () => {
+  const [previewFor, setPreviewFor] = useState<string | null>(null);
+
+  const [keyFilters, setKeyFilters] = useState<PreviewFilter[]>([]);
+  const [contentFilters, setContentFilters] = useState<PreviewFilter[]>([]);
+
   const { isLive } = useContext(TopicMessagesContext);
 
   const messages = useAppSelector(getTopicMessges);
   const isFetching = useAppSelector(getIsTopicMessagesFetching);
-
   return (
     <Table isFullwidth>
       <thead>
@@ -26,9 +31,33 @@ const MessagesTable: React.FC = () => {
           <TableHeaderCell title="Offset" />
           <TableHeaderCell title="Partition" />
           <TableHeaderCell title="Timestamp" />
-          <TableHeaderCell title="Key" />
-          <TableHeaderCell title="Value" />
+          <TableHeaderCell
+            title="Key"
+            previewText={`Preview ${
+              keyFilters.length ? `(${keyFilters.length} selected)` : ''
+            }`}
+            onPreview={() => setPreviewFor('key')}
+          />
+          <TableHeaderCell
+            title="Value"
+            previewText={`Preview ${
+              contentFilters.length ? `(${contentFilters.length} selected)` : ''
+            }`}
+            onPreview={() => setPreviewFor('content')}
+          />
           <TableHeaderCell> </TableHeaderCell>
+
+          {previewFor !== null && (
+            <PreviewModal
+              values={previewFor === 'key' ? keyFilters : contentFilters}
+              toggleIsOpen={() => setPreviewFor(null)}
+              setFilters={(payload: PreviewFilter[]) =>
+                previewFor === 'key'
+                  ? setKeyFilters(payload)
+                  : setContentFilters(payload)
+              }
+            />
+          )}
         </tr>
       </thead>
       <tbody>
@@ -41,6 +70,8 @@ const MessagesTable: React.FC = () => {
               message.partition,
             ].join('-')}
             message={message}
+            keyFilters={keyFilters}
+            contentFilters={contentFilters}
           />
         ))}
         {isFetching && isLive && !messages.length && (

+ 35 - 0
kafka-ui-react-app/src/components/Topics/Topic/Messages/PreviewModal.styled.ts

@@ -0,0 +1,35 @@
+import styled from 'styled-components';
+
+export const PreviewModal = styled.div`
+  height: auto;
+  width: 560px;
+  border-radius: 8px;
+  background: ${({ theme }) => theme.modal.backgroundColor};
+  position: absolute;
+  left: 25%;
+  border: 1px solid ${({ theme }) => theme.modal.border.contrast};
+  box-shadow: ${({ theme }) => theme.modal.shadow};
+  padding: 32px;
+  z-index: 1;
+`;
+
+export const ButtonWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  margin-top: 20px;
+  gap: 10px;
+`;
+
+export const EditForm = styled.div`
+  font-weight: 500;
+  padding-bottom: 7px;
+  display: flex;
+`;
+
+export const Field = styled.div`
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  overflow: hidden;
+  margin-right: 5px;
+`;

+ 137 - 0
kafka-ui-react-app/src/components/Topics/Topic/Messages/PreviewModal.tsx

@@ -0,0 +1,137 @@
+import React, { useEffect } from 'react';
+import { Button } from 'components/common/Button/Button';
+import { FormError } from 'components/common/Input/Input.styled';
+import Input from 'components/common/Input/Input';
+import { InputLabel } from 'components/common/Input/InputLabel.styled';
+import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
+import EditIcon from 'components/common/Icons/EditIcon';
+import CancelIcon from 'components/common/Icons/CancelIcon';
+
+import * as S from './PreviewModal.styled';
+import { PreviewFilter } from './Message';
+
+export interface InfoModalProps {
+  values: PreviewFilter[];
+  toggleIsOpen(): void;
+  setFilters: (payload: PreviewFilter[]) => void;
+}
+
+const PreviewModal: React.FC<InfoModalProps> = ({
+  values,
+  toggleIsOpen,
+  setFilters,
+}) => {
+  const [field, setField] = React.useState('');
+  const [path, setPath] = React.useState('');
+  const [errors, setErrors] = React.useState<string[]>([]);
+  const [editIndex, setEditIndex] = React.useState<number | undefined>();
+
+  const handleOk = () => {
+    const newErrors = [];
+
+    if (field === '') {
+      newErrors.push('field');
+    }
+
+    if (path === '') {
+      newErrors.push('path');
+    }
+
+    if (newErrors?.length) {
+      setErrors(newErrors);
+      return;
+    }
+
+    const newValues = [...values];
+
+    if (typeof editIndex !== 'undefined') {
+      newValues.splice(editIndex, 1, { field, path });
+    } else {
+      newValues.push({ field, path });
+    }
+
+    setFilters(newValues);
+    toggleIsOpen();
+  };
+
+  const handleRemove = (filter: PreviewFilter) => {
+    const newValues = values.filter(
+      (item) => item.field !== filter.field && item.path !== filter.path
+    );
+
+    setFilters(newValues);
+  };
+
+  useEffect(() => {
+    if (values?.length && typeof editIndex !== 'undefined') {
+      setField(values[editIndex].field);
+      setPath(values[editIndex].path);
+    }
+  }, [editIndex]);
+
+  return (
+    <S.PreviewModal>
+      {values.map((item, index) => (
+        <S.EditForm key="index">
+          <S.Field>
+            {' '}
+            {item.field} : {item.path}
+          </S.Field>
+          <IconButtonWrapper role="button" onClick={() => setEditIndex(index)}>
+            <EditIcon />
+          </IconButtonWrapper>
+          {'  '}
+          <IconButtonWrapper role="button" onClick={() => handleRemove(item)}>
+            <CancelIcon />
+          </IconButtonWrapper>
+        </S.EditForm>
+      ))}
+      <div>
+        <InputLabel htmlFor="previewFormField">Field</InputLabel>
+        <Input
+          type="text"
+          id="previewFormField"
+          min="1"
+          value={field}
+          placeholder="Field"
+          onChange={({ target }) => setField(target?.value)}
+        />
+        <FormError>{errors.includes('field') && 'Field is required'}</FormError>
+      </div>
+      <div>
+        <InputLabel htmlFor="previewFormJsonPath">Json path</InputLabel>
+        <Input
+          type="text"
+          id="previewFormJsonPath"
+          min="1"
+          value={path}
+          placeholder="Json Path"
+          onChange={({ target }) => setPath(target?.value)}
+        />
+        <FormError>
+          {errors.includes('path') && 'Json path is required'}
+        </FormError>
+      </div>
+      <S.ButtonWrapper>
+        <Button
+          buttonSize="M"
+          buttonType="secondary"
+          type="button"
+          onClick={toggleIsOpen}
+        >
+          Close
+        </Button>
+        <Button
+          buttonSize="M"
+          buttonType="secondary"
+          type="button"
+          onClick={handleOk}
+        >
+          Save
+        </Button>
+      </S.ButtonWrapper>
+    </S.PreviewModal>
+  );
+};
+
+export default PreviewModal;

+ 12 - 2
kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx

@@ -1,6 +1,9 @@
 import React from 'react';
 import { TopicMessage, TopicMessageTimestampTypeEnum } from 'generated-sources';
-import Message, { Props } from 'components/Topics/Topic/Messages/Message';
+import Message, {
+  PreviewFilter,
+  Props,
+} from 'components/Topics/Topic/Messages/Message';
 import { screen } from '@testing-library/react';
 import { render } from 'lib/testHelpers';
 import userEvent from '@testing-library/user-event';
@@ -30,6 +33,9 @@ describe('Message component', () => {
     headers: { header: 'test' },
   };
 
+  const mockKeyFilters: PreviewFilter[] = [];
+  const mockContentFilters: PreviewFilter[] = [];
+
   const renderComponent = (
     props: Partial<Props> = {
       message: mockMessage,
@@ -38,7 +44,11 @@ describe('Message component', () => {
     return render(
       <table>
         <tbody>
-          <Message message={props.message || mockMessage} />
+          <Message
+            message={props.message || mockMessage}
+            keyFilters={mockKeyFilters}
+            contentFilters={mockContentFilters}
+          />
         </tbody>
       </table>
     );

+ 14 - 0
kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { render } from 'lib/testHelpers';
 import MessagesTable from 'components/Topics/Topic/Messages/MessagesTable';
 import { SeekDirection, SeekType, TopicMessage } from 'generated-sources';
@@ -65,6 +66,19 @@ describe('MessagesTable', () => {
       expect(screen.getByRole('table')).toBeInTheDocument();
     });
 
+    it('should check preview buttons', async () => {
+      const previewButtons = await screen.findAllByRole('button', {
+        name: 'Preview',
+      });
+      expect(previewButtons).toHaveLength(2);
+    });
+
+    it('should show preview modal with validation', async () => {
+      await userEvent.click(screen.getAllByText('Preview')[0]);
+      expect(screen.getByPlaceholderText('Field')).toHaveValue('');
+      expect(screen.getByPlaceholderText('Json Path')).toHaveValue('');
+    });
+
     it('should check the if no elements is rendered in the table', () => {
       expect(screen.getByText(/No messages found/i)).toBeInTheDocument();
     });

+ 112 - 0
kafka-ui-react-app/src/components/Topics/Topic/Messages/__test__/PreviewModal.spec.tsx

@@ -0,0 +1,112 @@
+import userEvent from '@testing-library/user-event';
+import { act, screen } from '@testing-library/react';
+import { render } from 'lib/testHelpers';
+import React from 'react';
+import { PreviewFilter } from 'components/Topics/Topic/Messages/Message';
+import { serdesPayload } from 'lib/fixtures/topicMessages';
+import { useSerdes } from 'lib/hooks/api/topicMessages';
+import PreviewModal, {
+  InfoModalProps,
+} from 'components/Topics/Topic/Messages/PreviewModal';
+
+jest.mock('components/common/Icons/CloseIcon', () => () => 'mock-CloseIcon');
+
+jest.mock('lib/hooks/api/topicMessages', () => ({
+  useSerdes: jest.fn(),
+}));
+
+beforeEach(async () => {
+  (useSerdes as jest.Mock).mockImplementation(() => ({
+    data: serdesPayload,
+  }));
+});
+
+const toggleInfoModal = jest.fn();
+const mockValues: PreviewFilter[] = [
+  {
+    field: '',
+    path: '',
+  },
+];
+
+const renderComponent = (props?: Partial<InfoModalProps>) => {
+  render(
+    <PreviewModal
+      toggleIsOpen={toggleInfoModal}
+      values={mockValues}
+      setFilters={jest.fn()}
+      {...props}
+    />
+  );
+};
+
+describe('PreviewModal component', () => {
+  it('closes PreviewModal', async () => {
+    renderComponent();
+    await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+    expect(toggleInfoModal).toHaveBeenCalledTimes(1);
+  });
+
+  it('return if empty inputs', async () => {
+    renderComponent();
+    await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+    expect(screen.getByText('Json path is required')).toBeInTheDocument();
+    expect(screen.getByText('Field is required')).toBeInTheDocument();
+  });
+
+  describe('Input elements', () => {
+    const fieldValue = 'type';
+    const pathValue = 'schema.type';
+
+    beforeEach(async () => {
+      await act(() => {
+        renderComponent();
+      });
+    });
+
+    it('field input', async () => {
+      const fieldInput = screen.getByPlaceholderText('Field');
+      expect(fieldInput).toHaveValue('');
+      await userEvent.type(fieldInput, fieldValue);
+      expect(fieldInput).toHaveValue(fieldValue);
+    });
+
+    it('path input', async () => {
+      const pathInput = screen.getByPlaceholderText('Json Path');
+      expect(pathInput).toHaveValue('');
+      await userEvent.type(pathInput, pathValue);
+      expect(pathInput).toHaveValue(pathValue.toString());
+    });
+  });
+
+  describe('edit and remove functionality', () => {
+    const fieldValue = 'type new';
+    const pathValue = 'schema.type.new';
+
+    it('remove values', async () => {
+      const setFilters = jest.fn();
+      await act(() => {
+        renderComponent({ setFilters });
+      });
+      await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+      expect(setFilters).toHaveBeenCalledTimes(1);
+    });
+
+    it('edit values', async () => {
+      const setFilters = jest.fn();
+      const toggleIsOpen = jest.fn();
+      await act(() => {
+        renderComponent({ setFilters });
+      });
+      userEvent.click(screen.getByRole('button', { name: 'Edit' }));
+      const fieldInput = screen.getByPlaceholderText('Field');
+      userEvent.type(fieldInput, fieldValue);
+      const pathInput = screen.getByPlaceholderText('Json Path');
+      userEvent.type(pathInput, pathValue);
+      userEvent.click(screen.getByRole('button', { name: 'Save' }));
+      await act(() => {
+        renderComponent({ setFilters, toggleIsOpen });
+      });
+    });
+  });
+});