浏览代码

[FE} Refactor Consumers Section (#3508)

* refactor CG List & details page

* Refactor ResetOffset page

* get rid of redux reducer
Oleg Shur 2 年之前
父节点
当前提交
8d3bac8834
共有 22 个文件被更改,包括 402 次插入1104 次删除
  1. 3 2
      kafka-ui-react-app/src/components/ConsumerGroups/ConsumerGroups.tsx
  2. 18 35
      kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx
  3. 197 0
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx
  4. 23 36
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.styled.ts
  5. 29 292
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx
  6. 0 158
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/__test__/ResetOffsets.spec.tsx
  7. 1 1
      kafka-ui-react-app/src/components/ConsumerGroups/Details/TopicContents/__test__/TopicContents.spec.tsx
  8. 0 114
      kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/Details.spec.tsx
  9. 0 48
      kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/ListItem.spec.tsx
  10. 20 27
      kafka-ui-react-app/src/components/ConsumerGroups/List.tsx
  11. 0 16
      kafka-ui-react-app/src/components/ConsumerGroups/List/ListContainer.tsx
  12. 0 60
      kafka-ui-react-app/src/components/ConsumerGroups/List/__test__/List.spec.tsx
  13. 2 4
      kafka-ui-react-app/src/components/ConsumerGroups/__test__/ConsumerGroups.spec.tsx
  14. 10 3
      kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts
  15. 4 1
      kafka-ui-react-app/src/components/common/NewTable/Table.tsx
  16. 0 25
      kafka-ui-react-app/src/lib/fixtures/consumerGroups.ts
  17. 92 0
      kafka-ui-react-app/src/lib/hooks/api/consumers.ts
  18. 2 6
      kafka-ui-react-app/src/lib/paths.ts
  19. 1 2
      kafka-ui-react-app/src/redux/interfaces/consumerGroup.ts
  20. 0 49
      kafka-ui-react-app/src/redux/reducers/consumerGroups/__test__/consumerGroupSlice.spec.ts
  21. 0 223
      kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts
  22. 0 2
      kafka-ui-react-app/src/redux/reducers/index.ts

+ 3 - 2
kafka-ui-react-app/src/components/ConsumerGroups/ConsumerGroups.tsx

@@ -1,17 +1,18 @@
 import React from 'react';
 import { Route, Routes } from 'react-router-dom';
 import Details from 'components/ConsumerGroups/Details/Details';
-import ListContainer from 'components/ConsumerGroups/List/ListContainer';
 import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
 import {
   clusterConsumerGroupResetOffsetsRelativePath,
   RouteParams,
 } from 'lib/paths';
 
+import List from './List';
+
 const ConsumerGroups: React.FC = () => {
   return (
     <Routes>
-      <Route index element={<ListContainer />} />
+      <Route index element={<List />} />
       <Route path={RouteParams.consumerGroupID} element={<Details />} />
       <Route
         path={clusterConsumerGroupResetOffsetsRelativePath}

+ 18 - 35
kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx

@@ -7,26 +7,22 @@ import {
   ClusterGroupParam,
 } from 'lib/paths';
 import Search from 'components/common/Search/Search';
-import PageLoader from 'components/common/PageLoader/PageLoader';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import * as Metrics from 'components/common/Metrics';
 import { Tag } from 'components/common/Tag/Tag.styled';
 import groupBy from 'lodash/groupBy';
 import { Table } from 'components/common/table/Table/Table.styled';
-import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
-import {
-  deleteConsumerGroup,
-  selectById,
-  fetchConsumerGroupDetails,
-  getAreConsumerGroupDetailsFulfilled,
-} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
 import getTagColor from 'components/common/Tag/getTagColor';
 import { Dropdown } from 'components/common/Dropdown';
 import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled';
 import { Action, ResourceType } from 'generated-sources';
 import { ActionDropdownItem } from 'components/common/ActionComponent';
 import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
+import {
+  useConsumerGroupDetails,
+  useDeleteConsumerGroupMutation,
+} from 'lib/hooks/api/consumers';
 
 import ListItem from './ListItem';
 
@@ -35,38 +31,25 @@ const Details: React.FC = () => {
   const [searchParams] = useSearchParams();
   const searchValue = searchParams.get('q') || '';
   const { isReadOnly } = React.useContext(ClusterContext);
-  const { consumerGroupID, clusterName } = useAppParams<ClusterGroupParam>();
-  const dispatch = useAppDispatch();
-  const consumerGroup = useAppSelector((state) =>
-    selectById(state, consumerGroupID)
-  );
-  const isFetched = useAppSelector(getAreConsumerGroupDetailsFulfilled);
+  const routeParams = useAppParams<ClusterGroupParam>();
+  const { clusterName, consumerGroupID } = routeParams;
 
-  React.useEffect(() => {
-    dispatch(fetchConsumerGroupDetails({ clusterName, consumerGroupID }));
-  }, [clusterName, consumerGroupID, dispatch]);
+  const consumerGroup = useConsumerGroupDetails(routeParams);
+  const deleteConsumerGroup = useDeleteConsumerGroupMutation(routeParams);
 
   const onDelete = async () => {
-    const res = await dispatch(
-      deleteConsumerGroup({ clusterName, consumerGroupID })
-    ).unwrap();
-    if (res) navigate('../');
+    await deleteConsumerGroup.mutateAsync();
+    navigate('../');
   };
 
   const onResetOffsets = () => {
     navigate(clusterConsumerGroupResetRelativePath);
   };
 
-  if (!isFetched || !consumerGroup) {
-    return <PageLoader />;
-  }
-
-  const partitionsByTopic = groupBy(consumerGroup.partitions, 'topic');
-
+  const partitionsByTopic = groupBy(consumerGroup.data?.partitions, 'topic');
   const filteredPartitionsByTopic = Object.keys(partitionsByTopic).filter(
     (el) => el.includes(searchValue)
   );
-
   const currentPartitionsByTopic = searchValue.length
     ? filteredPartitionsByTopic
     : Object.keys(partitionsByTopic);
@@ -110,24 +93,24 @@ const Details: React.FC = () => {
       <Metrics.Wrapper>
         <Metrics.Section>
           <Metrics.Indicator label="State">
-            <Tag color={getTagColor(consumerGroup.state)}>
-              {consumerGroup.state}
+            <Tag color={getTagColor(consumerGroup.data?.state)}>
+              {consumerGroup.data?.state}
             </Tag>
           </Metrics.Indicator>
           <Metrics.Indicator label="Members">
-            {consumerGroup.members}
+            {consumerGroup.data?.members}
           </Metrics.Indicator>
           <Metrics.Indicator label="Assigned Topics">
-            {consumerGroup.topics}
+            {consumerGroup.data?.topics}
           </Metrics.Indicator>
           <Metrics.Indicator label="Assigned Partitions">
-            {consumerGroup.partitions?.length}
+            {consumerGroup.data?.partitions?.length}
           </Metrics.Indicator>
           <Metrics.Indicator label="Coordinator ID">
-            {consumerGroup.coordinator?.id}
+            {consumerGroup.data?.coordinator?.id}
           </Metrics.Indicator>
           <Metrics.Indicator label="Total lag">
-            {consumerGroup.messagesBehind}
+            {consumerGroup.data?.messagesBehind}
           </Metrics.Indicator>
         </Metrics.Section>
       </Metrics.Wrapper>

+ 197 - 0
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx

@@ -0,0 +1,197 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+  ConsumerGroupDetails,
+  ConsumerGroupOffsetsReset,
+  ConsumerGroupOffsetsResetType,
+} from 'generated-sources';
+import { ClusterGroupParam } from 'lib/paths';
+import {
+  Controller,
+  FormProvider,
+  useFieldArray,
+  useForm,
+} from 'react-hook-form';
+import { MultiSelect, Option } from 'react-multi-select-component';
+import 'react-datepicker/dist/react-datepicker.css';
+import { ErrorMessage } from '@hookform/error-message';
+import { InputLabel } from 'components/common/Input/InputLabel.styled';
+import { Button } from 'components/common/Button/Button';
+import Input from 'components/common/Input/Input';
+import { FormError } from 'components/common/Input/Input.styled';
+import useAppParams from 'lib/hooks/useAppParams';
+import { useResetConsumerGroupOffsetsMutation } from 'lib/hooks/api/consumers';
+import { FlexFieldset, StyledForm } from 'components/common/Form/Form.styled';
+import ControlledSelect from 'components/common/Select/ControlledSelect';
+
+import * as S from './ResetOffsets.styled';
+
+interface FormProps {
+  defaultValues: ConsumerGroupOffsetsReset;
+  topics: string[];
+  partitions: ConsumerGroupDetails['partitions'];
+}
+
+const resetTypeOptions = Object.values(ConsumerGroupOffsetsResetType).map(
+  (value) => ({ value, label: value })
+);
+
+const Form: React.FC<FormProps> = ({ defaultValues, partitions, topics }) => {
+  const navigate = useNavigate();
+  const routerParams = useAppParams<ClusterGroupParam>();
+  const reset = useResetConsumerGroupOffsetsMutation(routerParams);
+  const topicOptions = React.useMemo(
+    () => topics.map((value) => ({ value, label: value })),
+    [topics]
+  );
+  const methods = useForm<ConsumerGroupOffsetsReset>({
+    mode: 'onChange',
+    defaultValues,
+  });
+
+  const {
+    handleSubmit,
+    setValue,
+    watch,
+    control,
+    formState: { errors },
+  } = methods;
+  const { fields } = useFieldArray({
+    control,
+    name: 'partitionsOffsets',
+  });
+
+  const resetTypeValue = watch('resetType');
+  const topicValue = watch('topic');
+  const offsetsValue = watch('partitionsOffsets');
+  const partitionsValue = watch('partitions') || [];
+
+  const partitionOptions =
+    partitions
+      ?.filter((p) => p.topic === topicValue)
+      .map((p) => ({
+        label: `Partition #${p.partition.toString()}`,
+        value: p.partition,
+      })) || [];
+
+  const onSelectedPartitionsChange = (selected: Option[]) => {
+    setValue(
+      'partitions',
+      selected.map(({ value }) => value)
+    );
+
+    setValue(
+      'partitionsOffsets',
+      selected.map(({ value }) => {
+        const currentOffset = offsetsValue?.find(
+          ({ partition }) => partition === value
+        );
+        return { offset: currentOffset?.offset, partition: value };
+      })
+    );
+  };
+
+  React.useEffect(() => {
+    onSelectedPartitionsChange([]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [topicValue]);
+
+  const onSubmit = async (data: ConsumerGroupOffsetsReset) => {
+    await reset.mutateAsync(data);
+    navigate('../');
+  };
+
+  return (
+    <FormProvider {...methods}>
+      <StyledForm onSubmit={handleSubmit(onSubmit)}>
+        <FlexFieldset>
+          <ControlledSelect
+            name="topic"
+            label="Topic"
+            placeholder="Select Topic"
+            options={topicOptions}
+          />
+          <ControlledSelect
+            name="resetType"
+            label="Reset Type"
+            placeholder="Select Reset Type"
+            options={resetTypeOptions}
+          />
+          <div>
+            <InputLabel>Partitions</InputLabel>
+            <MultiSelect
+              options={partitionOptions}
+              value={partitionsValue.map((p) => ({
+                value: p,
+                label: String(p),
+              }))}
+              onChange={onSelectedPartitionsChange}
+              labelledBy="Select partitions"
+            />
+          </div>
+          {resetTypeValue === ConsumerGroupOffsetsResetType.TIMESTAMP &&
+            partitionsValue.length > 0 && (
+              <div>
+                <InputLabel>Timestamp</InputLabel>
+                <Controller
+                  control={control}
+                  name="resetToTimestamp"
+                  rules={{
+                    required: 'Timestamp is required',
+                  }}
+                  render={({ field: { onChange, onBlur, value, ref } }) => (
+                    <S.DatePickerInput
+                      ref={ref}
+                      selected={new Date(value as number)}
+                      onChange={(e: Date | null) => onChange(e?.getTime())}
+                      onBlur={onBlur}
+                    />
+                  )}
+                />
+                <ErrorMessage
+                  errors={errors}
+                  name="resetToTimestamp"
+                  render={({ message }) => <FormError>{message}</FormError>}
+                />
+              </div>
+            )}
+
+          {resetTypeValue === ConsumerGroupOffsetsResetType.OFFSET &&
+            partitionsValue.length > 0 && (
+              <S.OffsetsWrapper>
+                {fields.map((field, index) => (
+                  <Input
+                    key={field.id}
+                    label={`Partition #${field.partition} Offset`}
+                    type="number"
+                    name={`partitionsOffsets.${index}.offset` as const}
+                    hookFormOptions={{
+                      shouldUnregister: true,
+                      required: 'Offset is required',
+                      min: {
+                        value: 0,
+                        message: 'must be greater than or equal to 0',
+                      },
+                    }}
+                    withError
+                  />
+                ))}
+              </S.OffsetsWrapper>
+            )}
+        </FlexFieldset>
+        <div>
+          <Button
+            buttonSize="M"
+            buttonType="primary"
+            type="submit"
+            disabled={partitionsValue.length === 0}
+          >
+            Submit
+          </Button>
+        </div>
+      </StyledForm>
+    </FormProvider>
+  );
+};
+
+export default Form;

+ 23 - 36
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.styled.ts

@@ -1,37 +1,5 @@
 import styled from 'styled-components';
-
-export const Wrapper = styled.div`
-  padding: 16px;
-  padding-top: 0;
-
-  & > form {
-    display: flex;
-    flex-direction: column;
-    gap: 16px;
-
-    & > button:last-child {
-      align-self: flex-start;
-    }
-  }
-
-  & .multi-select {
-    height: 32px;
-    & > .dropdown-container {
-      height: 32px;
-      & > .dropdown-heading {
-        height: 32px;
-      }
-    }
-  }
-`;
-
-export const MainSelectors = styled.div`
-  display: flex;
-  gap: 16px;
-  & > * {
-    flex-grow: 1;
-  }
-`;
+import DatePicker from 'react-datepicker';
 
 export const OffsetsWrapper = styled.div`
   display: flex;
@@ -40,7 +8,26 @@ export const OffsetsWrapper = styled.div`
   gap: 16px;
 `;
 
-export const OffsetsTitle = styled.h1`
-  font-size: 18px;
-  font-weight: 500;
+export const DatePickerInput = styled(DatePicker).attrs({
+  showTimeInput: true,
+  timeInputLabel: 'Time:',
+  dateFormat: 'MMMM d, yyyy h:mm aa',
+})`
+  height: 40px;
+  border: 1px ${({ theme }) => theme.select.borderColor.normal} solid;
+  border-radius: 4px;
+  font-size: 14px;
+  width: 270px;
+  padding-left: 12px;
+  background-color: ${({ theme }) => theme.input.backgroundColor.normal};
+  color: ${({ theme }) => theme.input.color.normal};
+  &::placeholder {
+    color: ${({ theme }) => theme.input.color.normal};
+  }
+  &:hover {
+    cursor: pointer;
+  }
+  &:focus {
+    outline: none;
+  }
 `;

+ 29 - 292
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx

@@ -1,315 +1,52 @@
 import React from 'react';
-import { useNavigate } from 'react-router-dom';
-import { ConsumerGroupOffsetsResetType } from 'generated-sources';
 import { clusterConsumerGroupsPath, ClusterGroupParam } from 'lib/paths';
-import {
-  Controller,
-  FormProvider,
-  useFieldArray,
-  useForm,
-} from 'react-hook-form';
-import { MultiSelect, Option } from 'react-multi-select-component';
-import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
-import groupBy from 'lodash/groupBy';
-import PageLoader from 'components/common/PageLoader/PageLoader';
-import { ErrorMessage } from '@hookform/error-message';
-import Select from 'components/common/Select/Select';
-import { InputLabel } from 'components/common/Input/InputLabel.styled';
-import { Button } from 'components/common/Button/Button';
-import Input from 'components/common/Input/Input';
-import { FormError } from 'components/common/Input/Input.styled';
 import PageHeading from 'components/common/PageHeading/PageHeading';
-import {
-  fetchConsumerGroupDetails,
-  selectById,
-  getAreConsumerGroupDetailsFulfilled,
-  getIsOffsetReseted,
-  resetConsumerGroupOffsets,
-} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
-import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
 import useAppParams from 'lib/hooks/useAppParams';
-import { resetLoaderById } from 'redux/reducers/loader/loaderSlice';
-
-import * as S from './ResetOffsets.styled';
+import { useConsumerGroupDetails } from 'lib/hooks/api/consumers';
+import PageLoader from 'components/common/PageLoader/PageLoader';
+import {
+  ConsumerGroupOffsetsReset,
+  ConsumerGroupOffsetsResetType,
+} from 'generated-sources';
 
-interface FormType {
-  topic: string;
-  resetType: ConsumerGroupOffsetsResetType;
-  partitionsOffsets: { offset: string | undefined; partition: number }[];
-  resetToTimestamp: Date;
-}
+import Form from './Form';
 
 const ResetOffsets: React.FC = () => {
-  const dispatch = useAppDispatch();
-  const { consumerGroupID, clusterName } = useAppParams<ClusterGroupParam>();
-  const consumerGroup = useAppSelector((state) =>
-    selectById(state, consumerGroupID)
-  );
-
-  const isFetched = useAppSelector(getAreConsumerGroupDetailsFulfilled);
-  const isOffsetReseted = useAppSelector(getIsOffsetReseted);
-
-  React.useEffect(() => {
-    dispatch(fetchConsumerGroupDetails({ clusterName, consumerGroupID }));
-  }, [clusterName, consumerGroupID, dispatch]);
+  const routerParams = useAppParams<ClusterGroupParam>();
 
-  const [uniqueTopics, setUniqueTopics] = React.useState<string[]>([]);
-  const [selectedPartitions, setSelectedPartitions] = React.useState<Option[]>(
-    []
-  );
+  const consumerGroup = useConsumerGroupDetails(routerParams);
 
-  const methods = useForm<FormType>({
-    mode: 'onChange',
-    defaultValues: {
-      resetType: ConsumerGroupOffsetsResetType.EARLIEST,
-      topic: '',
-      partitionsOffsets: [],
-    },
-  });
-  const {
-    handleSubmit,
-    setValue,
-    watch,
-    control,
-    setError,
-    clearErrors,
-    formState: { errors, isValid },
-  } = methods;
-  const { fields } = useFieldArray({
-    control,
-    name: 'partitionsOffsets',
-  });
-  const resetTypeValue = watch('resetType');
-  const topicValue = watch('topic');
-  const offsetsValue = watch('partitionsOffsets');
+  if (consumerGroup.isLoading || !consumerGroup.isSuccess)
+    return <PageLoader />;
 
-  React.useEffect(() => {
-    if (isFetched && consumerGroup?.partitions) {
-      setValue('topic', consumerGroup.partitions[0].topic);
-      setUniqueTopics(Object.keys(groupBy(consumerGroup.partitions, 'topic')));
-    }
-  }, [consumerGroup?.partitions, isFetched, setValue]);
+  const partitions = consumerGroup.data.partitions || [];
+  const { topic } = partitions[0];
 
-  const onSelectedPartitionsChange = (value: Option[]) => {
-    clearErrors();
-    setValue(
-      'partitionsOffsets',
-      value.map((partition) => {
-        const currentOffset = offsetsValue.find(
-          (offset) => offset.partition === partition.value
-        );
-        return {
-          offset: currentOffset ? currentOffset?.offset : undefined,
-          partition: partition.value,
-        };
-      })
-    );
-    setSelectedPartitions(value);
-  };
-
-  React.useEffect(() => {
-    onSelectedPartitionsChange([]);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [topicValue]);
+  const uniqTopics = Array.from(
+    new Set(partitions.map((partition) => partition.topic))
+  );
 
-  const onSubmit = (data: FormType) => {
-    const augmentedData = {
-      ...data,
-      partitions: selectedPartitions.map((partition) => partition.value),
-      partitionsOffsets: data.partitionsOffsets as {
-        offset: string;
-        partition: number;
-      }[],
-    };
-    let isValidAugmentedData = true;
-    if (augmentedData.resetType === ConsumerGroupOffsetsResetType.OFFSET) {
-      augmentedData.partitionsOffsets.forEach((offset, index) => {
-        if (!offset.offset) {
-          setError(`partitionsOffsets.${index}.offset`, {
-            type: 'manual',
-            message: "This field shouldn't be empty!",
-          });
-          isValidAugmentedData = false;
-        }
-      });
-    } else if (
-      augmentedData.resetType === ConsumerGroupOffsetsResetType.TIMESTAMP
-    ) {
-      if (!augmentedData.resetToTimestamp) {
-        setError(`resetToTimestamp`, {
-          type: 'manual',
-          message: "This field shouldn't be empty!",
-        });
-        isValidAugmentedData = false;
-      }
-    }
-    if (isValidAugmentedData) {
-      dispatch(
-        resetConsumerGroupOffsets({
-          clusterName,
-          consumerGroupID,
-          requestBody: augmentedData,
-        })
-      );
-    }
+  const defaultValues: ConsumerGroupOffsetsReset = {
+    resetType: ConsumerGroupOffsetsResetType.EARLIEST,
+    topic,
+    partitionsOffsets: [],
+    resetToTimestamp: new Date().getTime(),
   };
 
-  const navigate = useNavigate();
-  React.useEffect(() => {
-    if (isOffsetReseted) {
-      dispatch(resetLoaderById('consumerGroups/resetConsumerGroupOffsets'));
-      navigate('../');
-    }
-  }, [clusterName, consumerGroupID, dispatch, navigate, isOffsetReseted]);
-
-  if (!isFetched || !consumerGroup) {
-    return <PageLoader />;
-  }
-
   return (
-    <FormProvider {...methods}>
+    <>
       <PageHeading
         text="Reset offsets"
-        backTo={clusterConsumerGroupsPath(clusterName)}
+        backTo={clusterConsumerGroupsPath(routerParams.clusterName)}
         backText="Consumers"
       />
-      <S.Wrapper>
-        <form onSubmit={handleSubmit(onSubmit)}>
-          <S.MainSelectors>
-            <div>
-              <InputLabel id="topicLabel">Topic</InputLabel>
-              <Controller
-                control={control}
-                name="topic"
-                render={({ field: { name, onChange, value } }) => (
-                  <Select
-                    id="topic"
-                    selectSize="M"
-                    aria-labelledby="topicLabel"
-                    minWidth="100%"
-                    name={name}
-                    onChange={onChange}
-                    defaultValue={value}
-                    value={value}
-                    options={uniqueTopics.map((topic) => ({
-                      value: topic,
-                      label: topic,
-                    }))}
-                  />
-                )}
-              />
-            </div>
-            <div>
-              <InputLabel id="resetTypeLabel">Reset Type</InputLabel>
-              <Controller
-                control={control}
-                name="resetType"
-                render={({ field: { name, onChange, value } }) => (
-                  <Select
-                    id="resetType"
-                    selectSize="M"
-                    aria-labelledby="resetTypeLabel"
-                    minWidth="100%"
-                    name={name}
-                    onChange={onChange}
-                    value={value}
-                    options={Object.values(ConsumerGroupOffsetsResetType).map(
-                      (type) => ({ value: type, label: type })
-                    )}
-                  />
-                )}
-              />
-            </div>
-            <div>
-              <InputLabel>Partitions</InputLabel>
-              <MultiSelect
-                options={
-                  consumerGroup.partitions
-                    ?.filter((p) => p.topic === topicValue)
-                    .map((p) => ({
-                      label: `Partition #${p.partition.toString()}`,
-                      value: p.partition,
-                    })) || []
-                }
-                value={selectedPartitions}
-                onChange={onSelectedPartitionsChange}
-                labelledBy="Select partitions"
-              />
-            </div>
-          </S.MainSelectors>
-          {resetTypeValue === ConsumerGroupOffsetsResetType.TIMESTAMP &&
-            selectedPartitions.length > 0 && (
-              <div>
-                <InputLabel>Timestamp</InputLabel>
-                <Controller
-                  control={control}
-                  name="resetToTimestamp"
-                  render={({ field: { onChange, onBlur, value, ref } }) => (
-                    <DatePicker
-                      ref={ref}
-                      selected={value}
-                      onChange={onChange}
-                      onBlur={onBlur}
-                      showTimeInput
-                      timeInputLabel="Time:"
-                      dateFormat="MMMM d, yyyy h:mm aa"
-                    />
-                  )}
-                />
-                <ErrorMessage
-                  errors={errors}
-                  name="resetToTimestamp"
-                  render={({ message }) => <FormError>{message}</FormError>}
-                />
-              </div>
-            )}
-          {resetTypeValue === ConsumerGroupOffsetsResetType.OFFSET &&
-            selectedPartitions.length > 0 && (
-              <div>
-                <S.OffsetsTitle>Offsets</S.OffsetsTitle>
-                <S.OffsetsWrapper>
-                  {fields.map((field, index) => (
-                    <div key={field.id}>
-                      <InputLabel htmlFor={`partitionsOffsets.${index}.offset`}>
-                        Partition #{field.partition}
-                      </InputLabel>
-                      <Input
-                        id={`partitionsOffsets.${index}.offset`}
-                        type="number"
-                        name={`partitionsOffsets.${index}.offset` as const}
-                        hookFormOptions={{
-                          shouldUnregister: true,
-                          min: {
-                            value: 0,
-                            message: 'must be greater than or equal to 0',
-                          },
-                        }}
-                        defaultValue={field.offset}
-                      />
-                      <ErrorMessage
-                        errors={errors}
-                        name={`partitionsOffsets.${index}.offset`}
-                        render={({ message }) => (
-                          <FormError>{message}</FormError>
-                        )}
-                      />
-                    </div>
-                  ))}
-                </S.OffsetsWrapper>
-              </div>
-            )}
-          <Button
-            buttonSize="M"
-            buttonType="primary"
-            type="submit"
-            disabled={!isValid || selectedPartitions.length === 0}
-          >
-            Submit
-          </Button>
-        </form>
-      </S.Wrapper>
-    </FormProvider>
+      <Form
+        defaultValues={defaultValues}
+        topics={uniqTopics}
+        partitions={partitions}
+      />
+    </>
   );
 };
 

+ 0 - 158
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/__test__/ResetOffsets.spec.tsx

@@ -1,158 +0,0 @@
-import React from 'react';
-import fetchMock from 'fetch-mock';
-import { act, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render, WithRoute } from 'lib/testHelpers';
-import { clusterConsumerGroupResetOffsetsPath } from 'lib/paths';
-import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
-import ResetOffsets from 'components/ConsumerGroups/Details/ResetOffsets/ResetOffsets';
-
-const clusterName = 'cluster1';
-const { groupId } = consumerGroupPayload;
-
-const renderComponent = () =>
-  render(
-    <WithRoute path={clusterConsumerGroupResetOffsetsPath()}>
-      <ResetOffsets />
-    </WithRoute>,
-    {
-      initialEntries: [
-        clusterConsumerGroupResetOffsetsPath(
-          clusterName,
-          consumerGroupPayload.groupId
-        ),
-      ],
-    }
-  );
-
-const resetConsumerGroupOffsetsMockCalled = () =>
-  expect(
-    fetchMock.called(
-      `/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`
-    )
-  ).toBeTruthy();
-
-const selectresetTypeAndPartitions = async (resetType: string) => {
-  await userEvent.click(screen.getByLabelText('Reset Type'));
-  await userEvent.click(screen.getByText(resetType));
-  await userEvent.click(screen.getByText('Select...'));
-  await userEvent.click(screen.getByText('Partition #0'));
-};
-
-const resetConsumerGroupOffsetsWith = async (
-  resetType: string,
-  offset: null | number = null
-) => {
-  await userEvent.click(screen.getByLabelText('Reset Type'));
-  const options = screen.getAllByText(resetType);
-  await userEvent.click(options.length > 1 ? options[1] : options[0]);
-  await userEvent.click(screen.getByText('Select...'));
-
-  await userEvent.click(screen.getByText('Partition #0'));
-
-  fetchMock.postOnce(
-    `/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
-    200,
-    {
-      body: {
-        topic: '__amazon_msk_canary',
-        resetType,
-        partitions: [0],
-        partitionsOffsets: [{ partition: 0, offset }],
-      },
-    }
-  );
-  await userEvent.click(screen.getByText('Submit'));
-  await waitFor(() => resetConsumerGroupOffsetsMockCalled());
-};
-
-describe('ResetOffsets', () => {
-  afterEach(() => {
-    fetchMock.reset();
-  });
-
-  xit('renders progress bar for initial state', async () => {
-    fetchMock.getOnce(
-      `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
-      404
-    );
-    await act(() => {
-      renderComponent();
-    });
-    expect(screen.getByRole('progressbar')).toBeInTheDocument();
-  });
-
-  describe('with consumer group', () => {
-    describe('submit handles resetConsumerGroupOffsets', () => {
-      beforeEach(async () => {
-        const fetchConsumerGroupMock = fetchMock.getOnce(
-          `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
-          consumerGroupPayload
-        );
-        await act(() => {
-          renderComponent();
-        });
-        expect(fetchConsumerGroupMock.called()).toBeTruthy();
-      });
-
-      it('calls resetConsumerGroupOffsets with EARLIEST', async () => {
-        await resetConsumerGroupOffsetsWith('EARLIEST');
-      });
-
-      it('calls resetConsumerGroupOffsets with LATEST', async () => {
-        await resetConsumerGroupOffsetsWith('LATEST');
-      });
-      it('calls resetConsumerGroupOffsets with OFFSET', async () => {
-        await selectresetTypeAndPartitions('OFFSET');
-        fetchMock.postOnce(
-          `/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
-          200,
-          {
-            body: {
-              topic: '__amazon_msk_canary',
-              resetType: 'OFFSET',
-              partitions: [0],
-              partitionsOffsets: [{ partition: 0, offset: 10 }],
-            },
-          }
-        );
-
-        await userEvent.click(screen.getAllByLabelText('Partition #0')[1]);
-        await userEvent.keyboard('10');
-        await userEvent.click(screen.getByText('Submit'));
-        await resetConsumerGroupOffsetsMockCalled();
-      });
-
-      // focus doesn't work for datepicker
-      it.skip('calls resetConsumerGroupOffsets with TIMESTAMP', async () => {
-        await selectresetTypeAndPartitions('TIMESTAMP');
-        const resetConsumerGroupOffsetsMock = fetchMock.postOnce(
-          `/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`,
-          200,
-          {
-            body: {
-              topic: '__amazon_msk_canary',
-              resetType: 'OFFSET',
-              partitions: [0],
-              partitionsOffsets: [{ partition: 0, offset: 10 }],
-            },
-          }
-        );
-        await userEvent.click(screen.getByText('Submit'));
-        await waitFor(() =>
-          expect(
-            screen.getByText("This field shouldn't be empty!")
-          ).toBeInTheDocument()
-        );
-
-        await waitFor(() =>
-          expect(
-            resetConsumerGroupOffsetsMock.called(
-              `/api/clusters/${clusterName}/consumer-groups/${groupId}/offsets`
-            )
-          ).toBeFalsy()
-        );
-      });
-    });
-  });
-});

+ 1 - 1
kafka-ui-react-app/src/components/ConsumerGroups/Details/TopicContents/__test__/TopicContents.spec.tsx

@@ -2,9 +2,9 @@ import React from 'react';
 import { clusterConsumerGroupDetailsPath } from 'lib/paths';
 import { screen } from '@testing-library/react';
 import TopicContents from 'components/ConsumerGroups/Details/TopicContents/TopicContents';
-import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
 import { render, WithRoute } from 'lib/testHelpers';
 import { ConsumerGroupTopicPartition } from 'generated-sources';
+import { consumerGroupPayload } from 'lib/fixtures/consumerGroups';
 
 const clusterName = 'cluster1';
 

+ 0 - 114
kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/Details.spec.tsx

@@ -1,114 +0,0 @@
-import Details from 'components/ConsumerGroups/Details/Details';
-import React from 'react';
-import fetchMock from 'fetch-mock';
-import { render, WithRoute } from 'lib/testHelpers';
-import {
-  clusterConsumerGroupDetailsPath,
-  clusterConsumerGroupResetRelativePath,
-} from 'lib/paths';
-import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
-import {
-  screen,
-  waitFor,
-  waitForElementToBeRemoved,
-} from '@testing-library/dom';
-import userEvent from '@testing-library/user-event';
-
-const clusterName = 'cluster1';
-const { groupId } = consumerGroupPayload;
-
-const mockNavigate = jest.fn();
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
-  useNavigate: () => mockNavigate,
-}));
-
-const renderComponent = () => {
-  render(
-    <WithRoute path={clusterConsumerGroupDetailsPath()}>
-      <Details />
-    </WithRoute>,
-    { initialEntries: [clusterConsumerGroupDetailsPath(clusterName, groupId)] }
-  );
-};
-describe('Details component', () => {
-  afterEach(() => {
-    fetchMock.reset();
-    mockNavigate.mockClear();
-  });
-
-  describe('when consumer groups are NOT fetched', () => {
-    it('renders progress bar for initial state', () => {
-      fetchMock.getOnce(
-        `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
-        404
-      );
-      renderComponent();
-      expect(screen.getByRole('progressbar')).toBeInTheDocument();
-    });
-  });
-
-  describe('when consumer gruops are fetched', () => {
-    beforeEach(async () => {
-      const fetchConsumerGroupMock = fetchMock.getOnce(
-        `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
-        consumerGroupPayload
-      );
-      renderComponent();
-      await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
-      await waitFor(() => expect(fetchConsumerGroupMock.called()).toBeTruthy());
-    });
-
-    it('renders component', () => {
-      expect(screen.getByRole('heading')).toBeInTheDocument();
-      expect(screen.getByText(groupId)).toBeInTheDocument();
-
-      expect(screen.getByRole('table')).toBeInTheDocument();
-      expect(screen.getAllByRole('columnheader').length).toEqual(2);
-
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-    });
-
-    it('handles [Reset offset] click', async () => {
-      await userEvent.click(screen.getByText('Reset offset'));
-      expect(mockNavigate).toHaveBeenLastCalledWith(
-        clusterConsumerGroupResetRelativePath
-      );
-    });
-
-    it('renders search input', async () => {
-      expect(
-        screen.getByPlaceholderText('Search by Topic Name')
-      ).toBeInTheDocument();
-    });
-
-    it('shows confirmation modal on consumer group delete', async () => {
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-      await userEvent.click(screen.getByText('Delete consumer group'));
-      await waitFor(() =>
-        expect(screen.queryByRole('dialog')).toBeInTheDocument()
-      );
-      await userEvent.click(screen.getByText('Cancel'));
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-    });
-
-    it('handles [Delete consumer group] click', async () => {
-      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-
-      await userEvent.click(screen.getByText('Delete consumer group'));
-
-      expect(screen.queryByRole('dialog')).toBeInTheDocument();
-      const deleteConsumerGroupMock = fetchMock.deleteOnce(
-        `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
-        200
-      );
-      await waitFor(() => {
-        userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
-      });
-      expect(deleteConsumerGroupMock.called()).toBeTruthy();
-
-      await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
-      await waitFor(() => expect(mockNavigate).toHaveBeenLastCalledWith('../'));
-    });
-  });
-});

+ 0 - 48
kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/ListItem.spec.tsx

@@ -1,48 +0,0 @@
-import React from 'react';
-import { clusterConsumerGroupDetailsPath } from 'lib/paths';
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import ListItem from 'components/ConsumerGroups/Details/ListItem';
-import { consumerGroupPayload } from 'redux/reducers/consumerGroups/__test__/fixtures';
-import { render, WithRoute } from 'lib/testHelpers';
-import { ConsumerGroupTopicPartition } from 'generated-sources';
-
-const clusterName = 'cluster1';
-
-const renderComponent = (consumers: ConsumerGroupTopicPartition[] = []) =>
-  render(
-    <WithRoute path={clusterConsumerGroupDetailsPath()}>
-      <table>
-        <tbody>
-          <ListItem
-            clusterName={clusterName}
-            name={clusterName}
-            consumers={consumers}
-          />
-        </tbody>
-      </table>
-    </WithRoute>,
-    {
-      initialEntries: [
-        clusterConsumerGroupDetailsPath(
-          clusterName,
-          consumerGroupPayload.groupId
-        ),
-      ],
-    }
-  );
-
-describe('ListItem', () => {
-  beforeEach(() => renderComponent(consumerGroupPayload.partitions));
-
-  it('should renders list item with topic content closed and check if element exists', () => {
-    expect(screen.getByRole('row')).toBeInTheDocument();
-  });
-
-  it('should renders list item with topic content open', async () => {
-    await userEvent.click(
-      screen.getByRole('cell', { name: 'cluster1' }).children[0].children[0]
-    );
-    expect(screen.getByText('Consumer ID')).toBeInTheDocument();
-  });
-});

+ 20 - 27
kafka-ui-react-app/src/components/ConsumerGroups/List/List.tsx → kafka-ui-react-app/src/components/ConsumerGroups/List.tsx

@@ -7,41 +7,29 @@ import {
   ConsumerGroupOrdering,
   SortOrder,
 } from 'generated-sources';
-import { useAppDispatch } from 'lib/hooks/redux';
 import useAppParams from 'lib/hooks/useAppParams';
 import { clusterConsumerGroupDetailsPath, ClusterNameRoute } from 'lib/paths';
-import { fetchConsumerGroupsPaged } from 'redux/reducers/consumerGroups/consumerGroupsSlice';
 import { ColumnDef } from '@tanstack/react-table';
 import Table, { TagCell, LinkCell } from 'components/common/NewTable';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { PER_PAGE } from 'lib/constants';
+import { useConsumerGroups } from 'lib/hooks/api/consumers';
 
-export interface Props {
-  consumerGroups: ConsumerGroupDetails[];
-  totalPages: number;
-}
-
-const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
-  const dispatch = useAppDispatch();
+const List = () => {
   const { clusterName } = useAppParams<ClusterNameRoute>();
   const [searchParams] = useSearchParams();
   const navigate = useNavigate();
 
-  React.useEffect(() => {
-    dispatch(
-      fetchConsumerGroupsPaged({
-        clusterName,
-        orderBy:
-          (searchParams.get('sortBy') as ConsumerGroupOrdering) || undefined,
-        sortOrder:
-          (searchParams.get('sortDirection')?.toUpperCase() as SortOrder) ||
-          undefined,
-        page: Number(searchParams.get('page') || 1),
-        perPage: Number(searchParams.get('perPage') || PER_PAGE),
-        search: searchParams.get('q') || '',
-      })
-    );
-  }, [clusterName, dispatch, searchParams]);
+  const consumerGroups = useConsumerGroups({
+    clusterName,
+    orderBy: (searchParams.get('sortBy') as ConsumerGroupOrdering) || undefined,
+    sortOrder:
+      (searchParams.get('sortDirection')?.toUpperCase() as SortOrder) ||
+      undefined,
+    page: Number(searchParams.get('page') || 1),
+    perPage: Number(searchParams.get('perPage') || PER_PAGE),
+    search: searchParams.get('q') || '',
+  });
 
   const columns = React.useMemo<ColumnDef<ConsumerGroupDetails>[]>(
     () => [
@@ -95,9 +83,13 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
       </ControlPanelWrapper>
       <Table
         columns={columns}
-        pageCount={totalPages}
-        data={consumerGroups}
-        emptyMessage="No active consumer groups found"
+        pageCount={consumerGroups.data?.pageCount || 0}
+        data={consumerGroups.data?.consumerGroups || []}
+        emptyMessage={
+          consumerGroups.isSuccess
+            ? 'No active consumer groups found'
+            : 'Loading...'
+        }
         serverSideProcessing
         enableSorting
         onRowClick={({ original }) =>
@@ -105,6 +97,7 @@ const List: React.FC<Props> = ({ consumerGroups, totalPages }) => {
             clusterConsumerGroupDetailsPath(clusterName, original.groupId)
           )
         }
+        disabled={consumerGroups.isFetching}
       />
     </>
   );

+ 0 - 16
kafka-ui-react-app/src/components/ConsumerGroups/List/ListContainer.tsx

@@ -1,16 +0,0 @@
-import { connect } from 'react-redux';
-import { RootState } from 'redux/interfaces';
-import {
-  getConsumerGroupsOrderBy,
-  getConsumerGroupsTotalPages,
-  selectAll,
-} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
-import List from 'components/ConsumerGroups/List/List';
-
-const mapStateToProps = (state: RootState) => ({
-  consumerGroups: selectAll(state),
-  orderBy: getConsumerGroupsOrderBy(state),
-  totalPages: getConsumerGroupsTotalPages(state),
-});
-
-export default connect(mapStateToProps)(List);

+ 0 - 60
kafka-ui-react-app/src/components/ConsumerGroups/List/__test__/List.spec.tsx

@@ -1,60 +0,0 @@
-import React from 'react';
-import List, { Props } from 'components/ConsumerGroups/List/List';
-import { screen } from '@testing-library/react';
-import { render } from 'lib/testHelpers';
-import { consumerGroups as consumerGroupMock } from 'redux/reducers/consumerGroups/__test__/fixtures';
-import { clusterConsumerGroupDetailsPath } from 'lib/paths';
-import userEvent from '@testing-library/user-event';
-import ListContainer from 'components/ConsumerGroups/List/ListContainer';
-
-const mockedUsedNavigate = jest.fn();
-
-jest.mock('react-router-dom', () => ({
-  ...jest.requireActual('react-router-dom'),
-  useNavigate: () => mockedUsedNavigate,
-}));
-
-describe('ListContainer', () => {
-  it('renders correctly', () => {
-    render(<ListContainer />);
-    expect(screen.getByRole('table')).toBeInTheDocument();
-  });
-});
-
-describe('List', () => {
-  const renderComponent = (props: Partial<Props> = {}) => {
-    const { consumerGroups, totalPages } = props;
-    return render(
-      <List
-        consumerGroups={consumerGroups || []}
-        totalPages={totalPages || 1}
-      />
-    );
-  };
-
-  it('renders empty table', () => {
-    renderComponent();
-    expect(screen.getByRole('table')).toBeInTheDocument();
-    expect(
-      screen.getByText('No active consumer groups found')
-    ).toBeInTheDocument();
-  });
-
-  describe('consumerGroups are fetched', () => {
-    beforeEach(() => renderComponent({ consumerGroups: consumerGroupMock }));
-
-    it('renders all rows with consumers', () => {
-      expect(screen.getByText('groupId1')).toBeInTheDocument();
-      expect(screen.getByText('groupId2')).toBeInTheDocument();
-    });
-
-    it('handles onRowClick', async () => {
-      const row = screen.getByRole('row', { name: 'groupId1 0 1 1' });
-      expect(row).toBeInTheDocument();
-      await userEvent.click(row);
-      expect(mockedUsedNavigate).toHaveBeenCalledWith(
-        clusterConsumerGroupDetailsPath(':clusterName', 'groupId1')
-      );
-    });
-  });
-});

+ 2 - 4
kafka-ui-react-app/src/components/ConsumerGroups/__test__/ConsumerGroups.spec.tsx

@@ -11,9 +11,7 @@ import { render, WithRoute } from 'lib/testHelpers';
 
 const clusterName = 'cluster1';
 
-jest.mock('components/ConsumerGroups/List/ListContainer', () => () => (
-  <div>ListContainerMock</div>
-));
+jest.mock('components/ConsumerGroups/List', () => () => <div>ListPage</div>);
 jest.mock('components/ConsumerGroups/Details/Details', () => () => (
   <div>DetailsMock</div>
 ));
@@ -35,7 +33,7 @@ const renderComponent = (path?: string) =>
 describe('ConsumerGroups', () => {
   it('renders ListContainer', async () => {
     renderComponent();
-    expect(screen.getByText('ListContainerMock')).toBeInTheDocument();
+    expect(screen.getByText('ListPage')).toBeInTheDocument();
   });
   it('renders ResetOffsets', async () => {
     renderComponent(

+ 10 - 3
kafka-ui-react-app/src/components/common/NewTable/Table.styled.ts

@@ -225,6 +225,13 @@ export const Ellipsis = styled.div`
   display: block;
 `;
 
-export const TableWrapper = styled.div`
-  overflow-x: auto;
-`;
+export const TableWrapper = styled.div<{ $disabled: boolean }>(
+  ({ $disabled }) => css`
+    overflow-x: auto;
+    ${$disabled &&
+    css`
+      pointer-events: none;
+      opacity: 0.5;
+    `}
+  `
+);

+ 4 - 1
kafka-ui-react-app/src/components/common/NewTable/Table.tsx

@@ -48,6 +48,8 @@ export interface TableProps<TData> {
   // Placeholder for empty table
   emptyMessage?: React.ReactNode;
 
+  disabled?: boolean;
+
   // Handles row click. Can not be combined with `enableRowSelection` && expandable rows.
   onRowClick?: (row: Row<TData>) => void;
 }
@@ -123,6 +125,7 @@ const Table: React.FC<TableProps<any>> = ({
   enableRowSelection = false,
   batchActionsBar: BatchActionsBar,
   emptyMessage,
+  disabled,
   onRowClick,
 }) => {
   const [searchParams, setSearchParams] = useSearchParams();
@@ -200,7 +203,7 @@ const Table: React.FC<TableProps<any>> = ({
           />
         </S.TableActionsBar>
       )}
-      <S.TableWrapper>
+      <S.TableWrapper $disabled={!!disabled}>
         <S.Table>
           <thead>
             {table.getHeaderGroups().map((headerGroup) => (

+ 0 - 25
kafka-ui-react-app/src/redux/reducers/consumerGroups/__test__/fixtures.ts → kafka-ui-react-app/src/lib/fixtures/consumerGroups.ts

@@ -1,30 +1,5 @@
 import { ConsumerGroupState } from 'generated-sources';
 
-export const consumerGroups = [
-  {
-    groupId: 'groupId1',
-    members: 0,
-    topics: 1,
-    simple: false,
-    partitionAssignor: '',
-    coordinator: {
-      id: 1,
-      host: 'host',
-    },
-  },
-  {
-    groupId: 'groupId2',
-    members: 0,
-    topics: 1,
-    simple: false,
-    partitionAssignor: '',
-    coordinator: {
-      id: 1,
-      host: 'host',
-    },
-  },
-];
-
 export const consumerGroupPayload = {
   groupId: 'amazon.msk.canary.group.broker-1',
   members: 0,

+ 92 - 0
kafka-ui-react-app/src/lib/hooks/api/consumers.ts

@@ -0,0 +1,92 @@
+import { consumerGroupsApiClient as api } from 'lib/api';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { ClusterName } from 'redux/interfaces';
+import {
+  ConsumerGroup,
+  ConsumerGroupOffsetsReset,
+  ConsumerGroupOrdering,
+  SortOrder,
+} from 'generated-sources';
+import { showSuccessAlert } from 'lib/errorHandling';
+
+export type ConsumerGroupID = ConsumerGroup['groupId'];
+
+type UseConsumerGroupsProps = {
+  clusterName: ClusterName;
+  orderBy?: ConsumerGroupOrdering;
+  sortOrder?: SortOrder;
+  page?: number;
+  perPage?: number;
+  search: string;
+};
+
+type UseConsumerGroupDetailsProps = {
+  clusterName: ClusterName;
+  consumerGroupID: ConsumerGroupID;
+};
+
+export function useConsumerGroups(props: UseConsumerGroupsProps) {
+  const { clusterName, ...rest } = props;
+  return useQuery(
+    ['clusters', clusterName, 'consumerGroups', rest],
+    () => api.getConsumerGroupsPage(props),
+    { suspense: false, keepPreviousData: true }
+  );
+}
+
+export function useConsumerGroupDetails(props: UseConsumerGroupDetailsProps) {
+  const { clusterName, consumerGroupID } = props;
+  return useQuery(
+    ['clusters', clusterName, 'consumerGroups', consumerGroupID],
+    () => api.getConsumerGroup({ clusterName, id: consumerGroupID })
+  );
+}
+
+export const useDeleteConsumerGroupMutation = ({
+  clusterName,
+  consumerGroupID,
+}: UseConsumerGroupDetailsProps) => {
+  const queryClient = useQueryClient();
+  return useMutation(
+    () => api.deleteConsumerGroup({ clusterName, id: consumerGroupID }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Consumer ${consumerGroupID} group deleted`,
+        });
+        queryClient.invalidateQueries([
+          'clusters',
+          clusterName,
+          'consumerGroups',
+        ]);
+      },
+    }
+  );
+};
+
+export const useResetConsumerGroupOffsetsMutation = ({
+  clusterName,
+  consumerGroupID,
+}: UseConsumerGroupDetailsProps) => {
+  const queryClient = useQueryClient();
+  return useMutation(
+    (props: ConsumerGroupOffsetsReset) =>
+      api.resetConsumerGroupOffsets({
+        clusterName,
+        id: consumerGroupID,
+        consumerGroupOffsetsReset: props,
+      }),
+    {
+      onSuccess: () => {
+        showSuccessAlert({
+          message: `Consumer ${consumerGroupID} group offsets reset`,
+        });
+        queryClient.invalidateQueries([
+          'clusters',
+          clusterName,
+          'consumerGroups',
+        ]);
+      },
+    }
+  );
+};

+ 2 - 6
kafka-ui-react-app/src/lib/paths.ts

@@ -1,12 +1,8 @@
 import { Broker, Connect, Connector } from 'generated-sources';
-import {
-  ClusterName,
-  ConsumerGroupID,
-  SchemaName,
-  TopicName,
-} from 'redux/interfaces';
+import { ClusterName, SchemaName, TopicName } from 'redux/interfaces';
 
 import { GIT_REPO_LINK } from './constants';
+import { ConsumerGroupID } from './hooks/api/consumers';
 
 export const gitCommitPath = (commit: string) =>
   `${GIT_REPO_LINK}/commit/${commit}`;

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

@@ -5,10 +5,9 @@ import {
 
 import { ClusterName } from './cluster';
 
-export type ConsumerGroupID = ConsumerGroup['groupId'];
 export interface ConsumerGroupResetOffsetRequestParams {
   clusterName: ClusterName;
-  consumerGroupID: ConsumerGroupID;
+  consumerGroupID: ConsumerGroup['groupId'];
   requestBody: {
     topic: string;
     resetType: ConsumerGroupOffsetsResetType;

+ 0 - 49
kafka-ui-react-app/src/redux/reducers/consumerGroups/__test__/consumerGroupSlice.spec.ts

@@ -1,49 +0,0 @@
-import { store } from 'redux/store';
-import {
-  sortBy,
-  getConsumerGroupsOrderBy,
-  getConsumerGroupsSortOrder,
-  getAreConsumerGroupsPagedFulfilled,
-  fetchConsumerGroupsPaged,
-  selectAll,
-} from 'redux/reducers/consumerGroups/consumerGroupsSlice';
-import { ConsumerGroupOrdering, SortOrder } from 'generated-sources';
-import { consumerGroups } from 'redux/reducers/consumerGroups/__test__/fixtures';
-
-describe('Consumer Groups Slice', () => {
-  describe('Actions', () => {
-    it('should test the sortBy actions', () => {
-      expect(store.getState().consumerGroups.sortOrder).toBe(SortOrder.ASC);
-
-      store.dispatch(sortBy(ConsumerGroupOrdering.STATE));
-      expect(getConsumerGroupsOrderBy(store.getState())).toBe(
-        ConsumerGroupOrdering.STATE
-      );
-      expect(getConsumerGroupsSortOrder(store.getState())).toBe(SortOrder.DESC);
-      store.dispatch(sortBy(ConsumerGroupOrdering.STATE));
-      expect(getConsumerGroupsSortOrder(store.getState())).toBe(SortOrder.ASC);
-    });
-  });
-
-  describe('Thunk Actions', () => {
-    it('should check the fetchConsumerPaged ', () => {
-      store.dispatch({
-        type: fetchConsumerGroupsPaged.fulfilled.type,
-        payload: {
-          consumerGroups,
-        },
-      });
-
-      expect(getAreConsumerGroupsPagedFulfilled(store.getState())).toBeTruthy();
-      expect(selectAll(store.getState())).toEqual(consumerGroups);
-
-      store.dispatch({
-        type: fetchConsumerGroupsPaged.fulfilled.type,
-        payload: {
-          consumerGroups: null,
-        },
-      });
-      expect(selectAll(store.getState())).toEqual([]);
-    });
-  });
-});

+ 0 - 223
kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts

@@ -1,223 +0,0 @@
-import {
-  createAsyncThunk,
-  createEntityAdapter,
-  createSlice,
-  createSelector,
-  PayloadAction,
-} from '@reduxjs/toolkit';
-import {
-  ConsumerGroupDetails,
-  ConsumerGroupOrdering,
-  ConsumerGroupsPageResponse,
-  SortOrder,
-} from 'generated-sources';
-import { AsyncRequestStatus } from 'lib/constants';
-import {
-  getResponse,
-  showServerError,
-  showSuccessAlert,
-} from 'lib/errorHandling';
-import {
-  ClusterName,
-  ConsumerGroupID,
-  ConsumerGroupResetOffsetRequestParams,
-  RootState,
-} from 'redux/interfaces';
-import { createFetchingSelector } from 'redux/reducers/loader/selectors';
-import { EntityState } from '@reduxjs/toolkit/src/entities/models';
-import { consumerGroupsApiClient } from 'lib/api';
-
-export const fetchConsumerGroupsPaged = createAsyncThunk<
-  ConsumerGroupsPageResponse,
-  {
-    clusterName: ClusterName;
-    orderBy?: ConsumerGroupOrdering;
-    sortOrder?: SortOrder;
-    page?: number;
-    perPage?: number;
-    search: string;
-  }
->(
-  'consumerGroups/fetchConsumerGroupsPaged',
-  async (
-    { clusterName, orderBy, sortOrder, page, perPage, search },
-    { rejectWithValue }
-  ) => {
-    try {
-      return await consumerGroupsApiClient.getConsumerGroupsPage({
-        clusterName,
-        orderBy,
-        sortOrder,
-        page,
-        perPage,
-        search,
-      });
-    } catch (error) {
-      showServerError(error as Response);
-      return rejectWithValue(await getResponse(error as Response));
-    }
-  }
-);
-
-export const fetchConsumerGroupDetails = createAsyncThunk<
-  ConsumerGroupDetails,
-  { clusterName: ClusterName; consumerGroupID: ConsumerGroupID }
->(
-  'consumerGroups/fetchConsumerGroupDetails',
-  async ({ clusterName, consumerGroupID }, { rejectWithValue }) => {
-    try {
-      return await consumerGroupsApiClient.getConsumerGroup({
-        clusterName,
-        id: consumerGroupID,
-      });
-    } catch (error) {
-      showServerError(error as Response);
-      return rejectWithValue(await getResponse(error as Response));
-    }
-  }
-);
-
-export const deleteConsumerGroup = createAsyncThunk<
-  ConsumerGroupID,
-  { clusterName: ClusterName; consumerGroupID: ConsumerGroupID }
->(
-  'consumerGroups/deleteConsumerGroup',
-  async ({ clusterName, consumerGroupID }, { rejectWithValue }) => {
-    try {
-      await consumerGroupsApiClient.deleteConsumerGroup({
-        clusterName,
-        id: consumerGroupID,
-      });
-      showSuccessAlert({
-        message: `Consumer ${consumerGroupID} group deleted`,
-      });
-      return consumerGroupID;
-    } catch (error) {
-      showServerError(error as Response);
-      return rejectWithValue(await getResponse(error as Response));
-    }
-  }
-);
-
-export const resetConsumerGroupOffsets = createAsyncThunk<
-  ConsumerGroupID,
-  ConsumerGroupResetOffsetRequestParams
->(
-  'consumerGroups/resetConsumerGroupOffsets',
-  async (
-    { clusterName, consumerGroupID, requestBody },
-    { rejectWithValue }
-  ) => {
-    try {
-      await consumerGroupsApiClient.resetConsumerGroupOffsets({
-        clusterName,
-        id: consumerGroupID,
-        consumerGroupOffsetsReset: {
-          topic: requestBody.topic,
-          resetType: requestBody.resetType,
-          partitions: requestBody.partitions,
-          partitionsOffsets: requestBody.partitionsOffsets?.map((offset) => ({
-            ...offset,
-            offset: +offset.offset,
-          })),
-          resetToTimestamp: requestBody.resetToTimestamp?.getTime(),
-        },
-      });
-      showSuccessAlert({
-        message: `Consumer ${consumerGroupID} group offsets reset`,
-      });
-      return consumerGroupID;
-    } catch (error) {
-      showServerError(error as Response);
-      return rejectWithValue(await getResponse(error as Response));
-    }
-  }
-);
-const SCHEMAS_PAGE_COUNT = 1;
-
-const consumerGroupsAdapter = createEntityAdapter<ConsumerGroupDetails>({
-  selectId: (consumerGroup) => consumerGroup.groupId,
-});
-
-interface ConsumerGroupState extends EntityState<ConsumerGroupDetails> {
-  orderBy: ConsumerGroupOrdering | null;
-  sortOrder: SortOrder;
-  totalPages: number;
-}
-
-const initialState: ConsumerGroupState = {
-  orderBy: ConsumerGroupOrdering.NAME,
-  sortOrder: SortOrder.ASC,
-  totalPages: SCHEMAS_PAGE_COUNT,
-  ...consumerGroupsAdapter.getInitialState(),
-};
-
-const consumerGroupsSlice = createSlice({
-  name: 'consumerGroups',
-  initialState,
-  reducers: {
-    sortBy: (state, action: PayloadAction<ConsumerGroupOrdering>) => {
-      state.orderBy = action.payload;
-      state.sortOrder =
-        state.orderBy === action.payload && state.sortOrder === SortOrder.ASC
-          ? SortOrder.DESC
-          : SortOrder.ASC;
-    },
-  },
-  extraReducers: (builder) => {
-    builder.addCase(
-      fetchConsumerGroupsPaged.fulfilled,
-      (state, { payload }) => {
-        state.totalPages = payload.pageCount || SCHEMAS_PAGE_COUNT;
-        consumerGroupsAdapter.setAll(state, payload.consumerGroups || []);
-      }
-    );
-    builder.addCase(fetchConsumerGroupDetails.fulfilled, (state, { payload }) =>
-      consumerGroupsAdapter.upsertOne(state, payload)
-    );
-    builder.addCase(deleteConsumerGroup.fulfilled, (state, { payload }) =>
-      consumerGroupsAdapter.removeOne(state, payload)
-    );
-  },
-});
-
-export const { sortBy } = consumerGroupsSlice.actions;
-
-const consumerGroupsState = ({
-  consumerGroups,
-}: RootState): ConsumerGroupState => consumerGroups;
-
-export const { selectAll, selectById } =
-  consumerGroupsAdapter.getSelectors<RootState>(consumerGroupsState);
-
-export const getAreConsumerGroupsPagedFulfilled = createSelector(
-  createFetchingSelector('consumerGroups/fetchConsumerGroupsPaged'),
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-export const getAreConsumerGroupDetailsFulfilled = createSelector(
-  createFetchingSelector('consumerGroups/fetchConsumerGroupDetails'),
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-export const getIsOffsetReseted = createSelector(
-  createFetchingSelector('consumerGroups/resetConsumerGroupOffsets'),
-  (status) => status === AsyncRequestStatus.fulfilled
-);
-
-export const getConsumerGroupsOrderBy = createSelector(
-  consumerGroupsState,
-  (state) => state.orderBy
-);
-
-export const getConsumerGroupsSortOrder = createSelector(
-  consumerGroupsState,
-  (state) => state.sortOrder
-);
-
-export const getConsumerGroupsTotalPages = createSelector(
-  consumerGroupsState,
-  (state) => state.totalPages
-);
-
-export default consumerGroupsSlice.reducer;

+ 0 - 2
kafka-ui-react-app/src/redux/reducers/index.ts

@@ -2,11 +2,9 @@ import { combineReducers } from '@reduxjs/toolkit';
 import loader from 'redux/reducers/loader/loaderSlice';
 import schemas from 'redux/reducers/schemas/schemasSlice';
 import topicMessages from 'redux/reducers/topicMessages/topicMessagesSlice';
-import consumerGroups from 'redux/reducers/consumerGroups/consumerGroupsSlice';
 
 export default combineReducers({
   loader,
   topicMessages,
-  consumerGroups,
   schemas,
 });