Browse Source

Issues/1740 live tailing improvements (#1774)

* Implementing Context to the Topic messages pages

* Using TopicContext in the Topics Topic MessageTable component

* Using TopicContext variable in the Filters component

* Fixing the Ordering of the Live mode Topic messaging

* Fixing isLive parameter bug during page refresh

* Minor code modification in Topic Filter Message page

* Implement the correct seekType during live mode in url as well as in api call

* Add Test cases to Messages and refactor eventSource Mock

* Add initial Testing file for messages table

* improve the MessagesTable test File

* improve the MessagesTable test File + Filter Test File

* improve the MessagesTable test File

* Change the function name toggleSeekDirection  to changeSeekDirection

* change the name of the test suites to be more declarative

* Display the table progress bar in live mode only when no data is fetched
Mgrdich 3 years ago
parent
commit
68f8eed8f8

+ 1 - 22
kafka-ui-react-app/src/components/KsqlDb/Query/__test__/Query.spec.tsx

@@ -1,4 +1,4 @@
-import { render } from 'lib/testHelpers';
+import { render, EventSourceMock } from 'lib/testHelpers';
 import React from 'react';
 import Query, {
   getFormattedErrorFromTableData,
@@ -20,27 +20,6 @@ const renderComponent = () =>
     }
   );
 
-// 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();

+ 16 - 40
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.tsx

@@ -10,7 +10,7 @@ import {
   TopicMessageEventTypeEnum,
   MessageFilterType,
 } from 'generated-sources';
-import React from 'react';
+import React, { useContext } from 'react';
 import { omitBy } from 'lodash';
 import { useHistory, useLocation } from 'react-router';
 import DatePicker from 'react-datepicker';
@@ -25,6 +25,8 @@ import { Button } from 'components/common/Button/Button';
 import FilterModal, {
   FilterEdit,
 } from 'components/Topics/Topic/Details/Messages/Filters/FilterModal';
+import { SeekDirectionOptions } from 'components/Topics/Topic/Details/Messages/Messages';
+import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
 
 import * as S from './Filters.styled';
 import {
@@ -66,11 +68,6 @@ export const SeekTypeOptions = [
   { value: SeekType.OFFSET, label: 'Offset' },
   { value: SeekType.TIMESTAMP, label: 'Timestamp' },
 ];
-export const SeekDirectionOptions = [
-  { value: SeekDirection.FORWARD, label: 'Oldest First', isLive: false },
-  { value: SeekDirection.BACKWARD, label: 'Newest First', isLive: false },
-  { value: SeekDirection.TAILING, label: 'Live Mode', isLive: true },
-];
 
 const Filters: React.FC<FiltersProps> = ({
   clusterName,
@@ -88,16 +85,14 @@ const Filters: React.FC<FiltersProps> = ({
   const location = useLocation();
   const history = useHistory();
 
+  const { searchParams, seekDirection, isLive, changeSeekDirection } =
+    useContext(TopicMessagesContext);
+
   const [isOpen, setIsOpen] = React.useState(false);
   const toggleIsOpen = () => setIsOpen(!isOpen);
 
   const source = React.useRef<EventSource | null>(null);
 
-  const searchParams = React.useMemo(
-    () => new URLSearchParams(location.search),
-    [location]
-  );
-
   const [selectedPartitions, setSelectedPartitions] = React.useState<Option[]>(
     getSelectedPartitionsFromSeekToParam(searchParams, partitions)
   );
@@ -132,10 +127,6 @@ const Filters: React.FC<FiltersProps> = ({
       : MessageFilterType.STRING_CONTAINS
   );
   const [query, setQuery] = React.useState<string>(searchParams.get('q') || '');
-  const [seekDirection, setSeekDirection] = React.useState<SeekDirection>(
-    (searchParams.get('seekDirection') as SeekDirection) ||
-      SeekDirection.FORWARD
-  );
   const isSeekTypeControlVisible = React.useMemo(
     () => selectedPartitions.length > 0,
     [selectedPartitions]
@@ -178,7 +169,7 @@ const Filters: React.FC<FiltersProps> = ({
     setAttempt(attempt + 1);
 
     if (isSeekTypeControlVisible) {
-      props.seekType = currentSeekType;
+      props.seekType = isLive ? SeekType.LATEST : currentSeekType;
       props.seekTo = selectedPartitions.map(({ value }) => {
         let seekToOffset;
 
@@ -217,21 +208,6 @@ const Filters: React.FC<FiltersProps> = ({
     query,
   ]);
 
-  const toggleSeekDirection = (val: string) => {
-    switch (val) {
-      case SeekDirection.FORWARD:
-        setSeekDirection(SeekDirection.FORWARD);
-        break;
-      case SeekDirection.BACKWARD:
-        setSeekDirection(SeekDirection.BACKWARD);
-        break;
-      case SeekDirection.TAILING:
-        setSeekDirection(SeekDirection.TAILING);
-        break;
-      default:
-    }
-  };
-
   const handleSSECancel = () => {
     if (!source.current) return;
 
@@ -295,7 +271,7 @@ const Filters: React.FC<FiltersProps> = ({
   };
   // eslint-disable-next-line consistent-return
   React.useEffect(() => {
-    if (location.search.length !== 0) {
+    if (location.search?.length !== 0) {
       const url = `${BASE_PARAMS.basePath}/api/clusters/${clusterName}/topics/${topicName}/messages${location.search}`;
       const sse = new EventSource(url);
 
@@ -346,7 +322,7 @@ const Filters: React.FC<FiltersProps> = ({
     updatePhase,
   ]);
   React.useEffect(() => {
-    if (location.search.length === 0) {
+    if (location.search?.length === 0) {
       handleFiltersSubmit();
     }
   }, [handleFiltersSubmit, location]);
@@ -376,7 +352,7 @@ const Filters: React.FC<FiltersProps> = ({
                 selectSize="M"
                 minWidth="100px"
                 options={SeekTypeOptions}
-                disabled={seekDirection === SeekDirection.TAILING}
+                disabled={isLive}
               />
               {currentSeekType === SeekType.OFFSET ? (
                 <Input
@@ -387,7 +363,7 @@ const Filters: React.FC<FiltersProps> = ({
                   className="offset-selector"
                   placeholder="Offset"
                   onChange={({ target: { value } }) => setOffset(value)}
-                  disabled={seekDirection === SeekDirection.TAILING}
+                  disabled={isLive}
                 />
               ) : (
                 <DatePicker
@@ -398,7 +374,7 @@ const Filters: React.FC<FiltersProps> = ({
                   dateFormat="MMMM d, yyyy HH:mm"
                   className="date-picker"
                   placeholderText="Select timestamp"
-                  disabled={seekDirection === SeekDirection.TAILING}
+                  disabled={isLive}
                 />
               )}
             </S.SeekTypeSelectorWrapper>
@@ -440,11 +416,11 @@ const Filters: React.FC<FiltersProps> = ({
         </S.FilterInputs>
         <Select
           selectSize="M"
-          onChange={(option) => toggleSeekDirection(option as string)}
+          onChange={(option) => changeSeekDirection(option as string)}
           value={seekDirection}
           minWidth="120px"
           options={SeekDirectionOptions}
-          isLive={seekDirection === SeekDirection.TAILING}
+          isLive={isLive}
         />
       </div>
       <S.ActiveSmartFilterWrapper>
@@ -479,12 +455,12 @@ const Filters: React.FC<FiltersProps> = ({
             isFetching &&
             phaseMessage}
         </p>
-        <S.MessageLoading isLive={seekDirection === SeekDirection.TAILING}>
+        <S.MessageLoading isLive={isLive}>
           <S.MessageLoadingSpinner isFetching={isFetching} />
           Loading messages.
           <S.StopLoading
             onClick={() => {
-              setSeekDirection(SeekDirection.FORWARD);
+              changeSeekDirection(SeekDirection.FORWARD);
               setIsFetching(false);
             }}
           >

+ 32 - 15
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/__tests__/Filters.spec.tsx

@@ -1,29 +1,46 @@
 import React from 'react';
+import { SeekDirectionOptions } from 'components/Topics/Topic/Details/Messages/Messages';
 import Filters, {
   FiltersProps,
-  SeekDirectionOptions,
   SeekTypeOptions,
 } from 'components/Topics/Topic/Details/Messages/Filters/Filters';
 import { render } from 'lib/testHelpers';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import TopicMessagesContext, {
+  ContextProps,
+} from 'components/contexts/TopicMessagesContext';
+import { SeekDirection } from 'generated-sources';
 
-const setupWrapper = (props?: Partial<FiltersProps>) =>
+const defaultContextValue: ContextProps = {
+  isLive: false,
+  seekDirection: SeekDirection.FORWARD,
+  searchParams: new URLSearchParams(''),
+  changeSeekDirection: jest.fn(),
+};
+
+const setupWrapper = (
+  props: Partial<FiltersProps> = {},
+  ctx: ContextProps = defaultContextValue
+) => {
   render(
-    <Filters
-      clusterName="test-cluster"
-      topicName="test-topic"
-      partitions={[{ partition: 0, offsetMin: 0, offsetMax: 100 }]}
-      meta={{}}
-      isFetching={false}
-      addMessage={jest.fn()}
-      resetMessages={jest.fn()}
-      updatePhase={jest.fn()}
-      updateMeta={jest.fn()}
-      setIsFetching={jest.fn()}
-      {...props}
-    />
+    <TopicMessagesContext.Provider value={ctx}>
+      <Filters
+        clusterName="test-cluster"
+        topicName="test-topic"
+        partitions={[{ partition: 0, offsetMin: 0, offsetMax: 100 }]}
+        meta={{}}
+        isFetching={false}
+        addMessage={jest.fn()}
+        resetMessages={jest.fn()}
+        updatePhase={jest.fn()}
+        updateMeta={jest.fn()}
+        setIsFetching={jest.fn()}
+        {...props}
+      />
+    </TopicMessagesContext.Provider>
   );
+};
 describe('Filters component', () => {
   it('renders component', () => {
     setupWrapper();

+ 78 - 7
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Messages.tsx

@@ -1,13 +1,84 @@
-import React from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
+import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
+import { SeekDirection } from 'generated-sources';
+import { useLocation } from 'react-router';
 
 import FiltersContainer from './Filters/FiltersContainer';
 import MessagesTable from './MessagesTable';
 
-const Messages: React.FC = () => (
-  <div>
-    <FiltersContainer />
-    <MessagesTable />
-  </div>
-);
+export const SeekDirectionOptionsObj = {
+  [SeekDirection.FORWARD]: {
+    value: SeekDirection.FORWARD,
+    label: 'Oldest First',
+    isLive: false,
+  },
+  [SeekDirection.BACKWARD]: {
+    value: SeekDirection.BACKWARD,
+    label: 'Newest First',
+    isLive: false,
+  },
+  [SeekDirection.TAILING]: {
+    value: SeekDirection.TAILING,
+    label: 'Live Mode',
+    isLive: true,
+  },
+};
+
+export const SeekDirectionOptions = Object.values(SeekDirectionOptionsObj);
+
+const Messages: React.FC = () => {
+  const location = useLocation();
+
+  const searchParams = React.useMemo(
+    () => new URLSearchParams(location.search),
+    [location.search]
+  );
+
+  const defaultSeekValue = SeekDirectionOptions[0];
+
+  const [seekDirection, setSeekDirection] = React.useState<SeekDirection>(
+    (searchParams.get('seekDirection') as SeekDirection) ||
+      defaultSeekValue.value
+  );
+
+  const [isLive, setIsLive] = useState<boolean>(
+    SeekDirectionOptionsObj[seekDirection].isLive
+  );
+
+  const changeSeekDirection = useCallback((val: string) => {
+    switch (val) {
+      case SeekDirection.FORWARD:
+        setSeekDirection(SeekDirection.FORWARD);
+        setIsLive(SeekDirectionOptionsObj[SeekDirection.FORWARD].isLive);
+        break;
+      case SeekDirection.BACKWARD:
+        setSeekDirection(SeekDirection.BACKWARD);
+        setIsLive(SeekDirectionOptionsObj[SeekDirection.BACKWARD].isLive);
+        break;
+      case SeekDirection.TAILING:
+        setSeekDirection(SeekDirection.TAILING);
+        setIsLive(SeekDirectionOptionsObj[SeekDirection.TAILING].isLive);
+        break;
+      default:
+    }
+  }, []);
+
+  const contextValue = useMemo(
+    () => ({
+      seekDirection,
+      searchParams,
+      changeSeekDirection,
+      isLive,
+    }),
+    [seekDirection, searchParams, changeSeekDirection]
+  );
+
+  return (
+    <TopicMessagesContext.Provider value={contextValue}>
+      <FiltersContainer />
+      <MessagesTable />
+    </TopicMessagesContext.Provider>
+  );
+};
 
 export default Messages;

+ 12 - 11
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/MessagesTable.tsx

@@ -4,13 +4,14 @@ import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeader
 import { SeekDirection, TopicMessage } from 'generated-sources';
 import styled from 'styled-components';
 import { compact, concat, groupBy, map, maxBy, minBy } from 'lodash';
-import React from 'react';
+import React, { useContext } from 'react';
 import { useSelector } from 'react-redux';
-import { useHistory, useLocation } from 'react-router';
+import { useHistory } from 'react-router';
 import {
   getTopicMessges,
   getIsTopicMessagesFetching,
 } from 'redux/reducers/topicMessages/selectors';
+import TopicMessagesContext from 'components/contexts/TopicMessagesContext';
 
 import Message from './Message';
 import * as S from './MessageContent/MessageContent.styled';
@@ -22,13 +23,9 @@ const MessagesPaginationWrapperStyled = styled.div`
 `;
 
 const MessagesTable: React.FC = () => {
-  const location = useLocation();
   const history = useHistory();
 
-  const searchParams = React.useMemo(
-    () => new URLSearchParams(location.search),
-    [location]
-  );
+  const { searchParams, isLive } = useContext(TopicMessagesContext);
 
   const messages = useSelector(getTopicMessges);
   const isFetching = useSelector(getIsTopicMessagesFetching);
@@ -94,7 +91,7 @@ const MessagesTable: React.FC = () => {
               message={message}
             />
           ))}
-          {isFetching && (
+          {(isFetching || isLive) && !messages.length && (
             <tr>
               <td colSpan={10}>
                 <PageLoader />
@@ -108,9 +105,13 @@ const MessagesTable: React.FC = () => {
           )}
         </tbody>
       </Table>
-      <MessagesPaginationWrapperStyled>
-        <S.PaginationButton onClick={handleNextClick}>Next</S.PaginationButton>
-      </MessagesPaginationWrapperStyled>
+      {!isLive && (
+        <MessagesPaginationWrapperStyled>
+          <S.PaginationButton onClick={handleNextClick}>
+            Next
+          </S.PaginationButton>
+        </MessagesPaginationWrapperStyled>
+      )}
     </>
   );
 };

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

@@ -0,0 +1,77 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { render, EventSourceMock } from 'lib/testHelpers';
+import Messages, {
+  SeekDirectionOptions,
+  SeekDirectionOptionsObj,
+} from 'components/Topics/Topic/Details/Messages/Messages';
+import { Router } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { SeekDirection, SeekType } from 'generated-sources';
+import userEvent from '@testing-library/user-event';
+
+describe('Messages', () => {
+  const searchParams = `?filterQueryType=STRING_CONTAINS&attempt=0&limit=100&seekDirection=${SeekDirection.FORWARD}&seekType=${SeekType.OFFSET}&seekTo=0::9`;
+
+  const setUpComponent = (param: string = searchParams) => {
+    const history = createMemoryHistory();
+    history.push({
+      search: new URLSearchParams(param).toString(),
+    });
+    return render(
+      <Router history={history}>
+        <Messages />
+      </Router>
+    );
+  };
+
+  beforeEach(() => {
+    Object.defineProperty(window, 'EventSource', {
+      value: EventSourceMock,
+    });
+  });
+  describe('component rendering default behavior with the search params', () => {
+    beforeEach(() => {
+      setUpComponent();
+    });
+    it('should check default seekDirection if it actually take the value from the url', () => {
+      expect(screen.getByRole('listbox')).toHaveTextContent(
+        SeekDirectionOptionsObj[SeekDirection.FORWARD].label
+      );
+    });
+
+    it('should check the SeekDirection select changes', () => {
+      const seekDirectionSelect = screen.getByRole('listbox');
+      const seekDirectionOption = screen.getByRole('option');
+
+      expect(seekDirectionOption).toHaveTextContent(
+        SeekDirectionOptionsObj[SeekDirection.FORWARD].label
+      );
+
+      const labelValue1 = SeekDirectionOptions[1].label;
+      userEvent.click(seekDirectionSelect);
+      userEvent.selectOptions(seekDirectionSelect, [
+        SeekDirectionOptions[1].label,
+      ]);
+      expect(seekDirectionOption).toHaveTextContent(labelValue1);
+
+      const labelValue0 = SeekDirectionOptions[0].label;
+      userEvent.click(seekDirectionSelect);
+      userEvent.selectOptions(seekDirectionSelect, [
+        SeekDirectionOptions[0].label,
+      ]);
+      expect(seekDirectionOption).toHaveTextContent(labelValue0);
+    });
+  });
+
+  describe('Component rendering with custom Url search params', () => {
+    it('reacts to a change of seekDirection in the url which make the select pick up different value', () => {
+      setUpComponent(
+        searchParams.replace(SeekDirection.FORWARD, SeekDirection.BACKWARD)
+      );
+      expect(screen.getByRole('listbox')).toHaveTextContent(
+        SeekDirectionOptionsObj[SeekDirection.BACKWARD].label
+      );
+    });
+  });
+});

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

@@ -0,0 +1,73 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { render } from 'lib/testHelpers';
+import MessagesTable from 'components/Topics/Topic/Details/Messages/MessagesTable';
+import { Router } from 'react-router';
+import { createMemoryHistory } from 'history';
+import { SeekDirection, SeekType } from 'generated-sources';
+import userEvent from '@testing-library/user-event';
+import TopicMessagesContext, {
+  ContextProps,
+} from 'components/contexts/TopicMessagesContext';
+
+describe('MessagesTable', () => {
+  const searchParams = new URLSearchParams(
+    `?filterQueryType=STRING_CONTAINS&attempt=0&limit=100&seekDirection=${SeekDirection.FORWARD}&seekType=${SeekType.OFFSET}&seekTo=0::9`
+  );
+  const contextValue: ContextProps = {
+    isLive: false,
+    seekDirection: SeekDirection.FORWARD,
+    searchParams,
+    changeSeekDirection: jest.fn(),
+  };
+
+  const setUpComponent = (
+    params: URLSearchParams = searchParams,
+    ctx: ContextProps = contextValue
+  ) => {
+    const history = createMemoryHistory();
+    history.push({
+      search: params.toString(),
+    });
+    return render(
+      <Router history={history}>
+        <TopicMessagesContext.Provider value={ctx}>
+          <MessagesTable />
+        </TopicMessagesContext.Provider>
+      </Router>
+    );
+  };
+
+  describe('Default props Setup for MessagesTable component', () => {
+    beforeEach(() => {
+      setUpComponent();
+    });
+
+    it('should check the render', () => {
+      expect(screen.getByRole('table')).toBeInTheDocument();
+    });
+
+    it('should check the if no elements is rendered in the table', () => {
+      expect(screen.getByText(/No messages found/i)).toBeInTheDocument();
+    });
+
+    it('should check if next button exist and check the click after next click', () => {
+      const nextBtnElement = screen.getByText(/next/i);
+      expect(nextBtnElement).toBeInTheDocument();
+      userEvent.click(nextBtnElement);
+      expect(screen.getByText(/No messages found/i)).toBeInTheDocument();
+    });
+  });
+
+  describe('Custom Setup with different props value', () => {
+    it('should check if next click is gone during isLive Param', () => {
+      setUpComponent(searchParams, { ...contextValue, isLive: true });
+      expect(screen.queryByText(/next/i)).not.toBeInTheDocument();
+    });
+
+    it('should check the display of the loader element', () => {
+      setUpComponent(searchParams, { ...contextValue, isLive: true });
+      expect(screen.getByRole('progressbar')).toBeInTheDocument();
+    });
+  });
+});

+ 15 - 0
kafka-ui-react-app/src/components/contexts/TopicMessagesContext.ts

@@ -0,0 +1,15 @@
+import React from 'react';
+import { SeekDirection } from 'generated-sources';
+
+export interface ContextProps {
+  seekDirection: SeekDirection;
+  searchParams: URLSearchParams;
+  changeSeekDirection(val: string): void;
+  isLive: boolean;
+}
+
+const TopicMessagesContext = React.createContext<ContextProps>(
+  {} as ContextProps
+);
+
+export default TopicMessagesContext;

+ 20 - 0
kafka-ui-react-app/src/lib/testHelpers.tsx

@@ -101,3 +101,23 @@ const customRender = (
 };
 
 export { customRender as render };
+
+export 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();
+  }
+}

+ 1 - 1
kafka-ui-react-app/src/redux/reducers/topicMessages/reducer.ts

@@ -19,7 +19,7 @@ const reducer = (state = initialState, action: Action): TopicMessagesState => {
     case getType(actions.addTopicMessage): {
       return {
         ...state,
-        messages: [...state.messages, action.payload],
+        messages: [action.payload, ...state.messages],
       };
     }
     case getType(actions.resetTopicMessages):