Jelajahi Sumber

Merge branch 'master' into ISSUE-3427_frontend

Roman Zabaluev 2 tahun lalu
induk
melakukan
f5a29e85cd

+ 19 - 5
kafka-ui-react-app/src/components/Dashboard/ClusterTableActionsCell.tsx

@@ -1,17 +1,31 @@
-import React from 'react';
-import { Cluster } from 'generated-sources';
+import React, { useMemo } from 'react';
+import { Cluster, ResourceType } from 'generated-sources';
 import { CellContext } from '@tanstack/react-table';
 import { CellContext } from '@tanstack/react-table';
-import { Button } from 'components/common/Button/Button';
 import { clusterConfigPath } from 'lib/paths';
 import { clusterConfigPath } from 'lib/paths';
+import { useGetUserInfo } from 'lib/hooks/api/roles';
+import { ActionCanButton } from 'components/common/ActionComponent';
 
 
 type Props = CellContext<Cluster, unknown>;
 type Props = CellContext<Cluster, unknown>;
 
 
 const ClusterTableActionsCell: React.FC<Props> = ({ row }) => {
 const ClusterTableActionsCell: React.FC<Props> = ({ row }) => {
   const { name } = row.original;
   const { name } = row.original;
+  const { data } = useGetUserInfo();
+
+  const isApplicationConfig = useMemo(() => {
+    return !!data?.userInfo?.permissions.some(
+      (permission) => permission.resource === ResourceType.APPLICATIONCONFIG
+    );
+  }, [data]);
+
   return (
   return (
-    <Button buttonType="secondary" buttonSize="S" to={clusterConfigPath(name)}>
+    <ActionCanButton
+      buttonType="secondary"
+      buttonSize="S"
+      to={clusterConfigPath(name)}
+      canDoAction={isApplicationConfig}
+    >
       Configure
       Configure
-    </Button>
+    </ActionCanButton>
   );
   );
 };
 };
 
 

+ 17 - 5
kafka-ui-react-app/src/components/Dashboard/Dashboard.tsx

@@ -1,23 +1,25 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import PageHeading from 'components/common/PageHeading/PageHeading';
 import * as Metrics from 'components/common/Metrics';
 import * as Metrics from 'components/common/Metrics';
 import { Tag } from 'components/common/Tag/Tag.styled';
 import { Tag } from 'components/common/Tag/Tag.styled';
 import Switch from 'components/common/Switch/Switch';
 import Switch from 'components/common/Switch/Switch';
 import { useClusters } from 'lib/hooks/api/clusters';
 import { useClusters } from 'lib/hooks/api/clusters';
-import { Cluster, ServerStatus } from 'generated-sources';
+import { Cluster, ResourceType, ServerStatus } from 'generated-sources';
 import { ColumnDef } from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/react-table';
 import Table, { SizeCell } from 'components/common/NewTable';
 import Table, { SizeCell } from 'components/common/NewTable';
 import useBoolean from 'lib/hooks/useBoolean';
 import useBoolean from 'lib/hooks/useBoolean';
-import { Button } from 'components/common/Button/Button';
 import { clusterNewConfigPath } from 'lib/paths';
 import { clusterNewConfigPath } from 'lib/paths';
 import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
 import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
+import { ActionCanButton } from 'components/common/ActionComponent';
+import { useGetUserInfo } from 'lib/hooks/api/roles';
 
 
 import * as S from './Dashboard.styled';
 import * as S from './Dashboard.styled';
 import ClusterName from './ClusterName';
 import ClusterName from './ClusterName';
 import ClusterTableActionsCell from './ClusterTableActionsCell';
 import ClusterTableActionsCell from './ClusterTableActionsCell';
 
 
 const Dashboard: React.FC = () => {
 const Dashboard: React.FC = () => {
+  const { data } = useGetUserInfo();
   const clusters = useClusters();
   const clusters = useClusters();
   const { value: showOfflineOnly, toggle } = useBoolean(false);
   const { value: showOfflineOnly, toggle } = useBoolean(false);
   const appInfo = React.useContext(GlobalSettingsContext);
   const appInfo = React.useContext(GlobalSettingsContext);
@@ -62,6 +64,11 @@ const Dashboard: React.FC = () => {
     }
     }
   }, [clusters, appInfo.hasDynamicConfig]);
   }, [clusters, appInfo.hasDynamicConfig]);
 
 
+  const isApplicationConfig = useMemo(() => {
+    return !!data?.userInfo?.permissions.some(
+      (permission) => permission.resource === ResourceType.APPLICATIONCONFIG
+    );
+  }, [data]);
   return (
   return (
     <>
     <>
       <PageHeading text="Dashboard" />
       <PageHeading text="Dashboard" />
@@ -87,9 +94,14 @@ const Dashboard: React.FC = () => {
           <label>Only offline clusters</label>
           <label>Only offline clusters</label>
         </div>
         </div>
         {appInfo.hasDynamicConfig && (
         {appInfo.hasDynamicConfig && (
-          <Button buttonType="primary" buttonSize="M" to={clusterNewConfigPath}>
+          <ActionCanButton
+            buttonType="primary"
+            buttonSize="M"
+            to={clusterNewConfigPath}
+            canDoAction={isApplicationConfig}
+          >
             Configure new cluster
             Configure new cluster
-          </Button>
+          </ActionCanButton>
         )}
         )}
       </S.Toolbar>
       </S.Toolbar>
       <Table
       <Table

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

@@ -8,6 +8,7 @@ import { formatTimestamp } from 'lib/dateTimeHelpers';
 import { JSONPath } from 'jsonpath-plus';
 import { JSONPath } from 'jsonpath-plus';
 import Ellipsis from 'components/common/Ellipsis/Ellipsis';
 import Ellipsis from 'components/common/Ellipsis/Ellipsis';
 import WarningRedIcon from 'components/common/Icons/WarningRedIcon';
 import WarningRedIcon from 'components/common/Icons/WarningRedIcon';
+import Tooltip from 'components/common/Tooltip/Tooltip';
 
 
 import MessageContent from './MessageContent/MessageContent';
 import MessageContent from './MessageContent/MessageContent';
 import * as S from './MessageContent/MessageContent.styled';
 import * as S from './MessageContent/MessageContent.styled';
@@ -110,14 +111,26 @@ const Message: React.FC<Props> = ({
         </td>
         </td>
         <S.DataCell title={key}>
         <S.DataCell title={key}>
           <Ellipsis text={renderFilteredJson(key, keyFilters)}>
           <Ellipsis text={renderFilteredJson(key, keyFilters)}>
-            {keySerde === 'Fallback' && <WarningRedIcon />}
+            {keySerde === 'Fallback' && (
+              <Tooltip
+                value={<WarningRedIcon />}
+                content="Fallback serde was used"
+                placement="left"
+              />
+            )}
           </Ellipsis>
           </Ellipsis>
         </S.DataCell>
         </S.DataCell>
         <S.DataCell title={content}>
         <S.DataCell title={content}>
           <S.Metadata>
           <S.Metadata>
             <S.MetadataValue>
             <S.MetadataValue>
               <Ellipsis text={renderFilteredJson(content, contentFilters)}>
               <Ellipsis text={renderFilteredJson(content, contentFilters)}>
-                {valueSerde === 'Fallback' && <WarningRedIcon />}
+                {valueSerde === 'Fallback' && (
+                  <Tooltip
+                    value={<WarningRedIcon />}
+                    content="Fallback serde was used"
+                    placement="left"
+                  />
+                )}
               </Ellipsis>
               </Ellipsis>
             </S.MetadataValue>
             </S.MetadataValue>
           </S.Metadata>
           </S.Metadata>

+ 3 - 1
kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx

@@ -210,6 +210,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
                   name={name}
                   name={name}
                   onChange={onChange}
                   onChange={onChange}
                   value={value}
                   value={value}
+                  height="40px"
                 />
                 />
               )}
               )}
             />
             />
@@ -225,6 +226,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
                   name={name}
                   name={name}
                   onChange={onChange}
                   onChange={onChange}
                   value={value}
                   value={value}
+                  height="280px"
                 />
                 />
               )}
               )}
             />
             />
@@ -242,7 +244,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
                   defaultValue="{}"
                   defaultValue="{}"
                   name={name}
                   name={name}
                   onChange={onChange}
                   onChange={onChange}
-                  height="200px"
+                  height="40px"
                 />
                 />
               )}
               )}
             />
             />

+ 10 - 0
kafka-ui-react-app/src/components/common/Input/Input.styled.ts

@@ -29,6 +29,16 @@ export const Wrapper = styled.div`
     width: 16px;
     width: 16px;
     fill: ${({ theme }) => theme.input.icon.color};
     fill: ${({ theme }) => theme.input.icon.color};
   }
   }
+  svg:last-child {
+    position: absolute;
+    top: 8px;
+    line-height: 0;
+    z-index: 1;
+    left: unset;
+    right: 12px;
+    height: 16px;
+    width: 16px;
+  }
 `;
 `;
 
 
 export const Input = styled.input<InputProps>(
 export const Input = styled.input<InputProps>(

+ 21 - 15
kafka-ui-react-app/src/components/common/Input/Input.tsx

@@ -16,6 +16,7 @@ export interface InputProps
   withError?: boolean;
   withError?: boolean;
   label?: React.ReactNode;
   label?: React.ReactNode;
   hint?: React.ReactNode;
   hint?: React.ReactNode;
+  clearIcon?: React.ReactNode;
 
 
   // Some may only accept integer, like `Number of Partitions`
   // Some may only accept integer, like `Number of Partitions`
   // some may accept decimal
   // some may accept decimal
@@ -99,19 +100,22 @@ function pasteNumberCheck(
   return value;
   return value;
 }
 }
 
 
-const Input: React.FC<InputProps> = ({
-  name,
-  hookFormOptions,
-  search,
-  inputSize = 'L',
-  type,
-  positiveOnly,
-  integerOnly,
-  withError = false,
-  label,
-  hint,
-  ...rest
-}) => {
+const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
+  const {
+    name,
+    hookFormOptions,
+    search,
+    inputSize = 'L',
+    type,
+    positiveOnly,
+    integerOnly,
+    withError = false,
+    label,
+    hint,
+    clearIcon,
+    ...rest
+  } = props;
+
   const methods = useFormContext();
   const methods = useFormContext();
 
 
   const fieldId = React.useId();
   const fieldId = React.useId();
@@ -168,7 +172,6 @@ const Input: React.FC<InputProps> = ({
     // if the field is a part of react-hook-form form
     // if the field is a part of react-hook-form form
     inputOptions = { ...rest, ...methods.register(name, hookFormOptions) };
     inputOptions = { ...rest, ...methods.register(name, hookFormOptions) };
   }
   }
-
   return (
   return (
     <div>
     <div>
       {label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>}
       {label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>}
@@ -181,8 +184,11 @@ const Input: React.FC<InputProps> = ({
           type={type}
           type={type}
           onKeyPress={keyPressEventHandler}
           onKeyPress={keyPressEventHandler}
           onPaste={pasteEventHandler}
           onPaste={pasteEventHandler}
+          ref={ref}
           {...inputOptions}
           {...inputOptions}
         />
         />
+        {clearIcon}
+
         {withError && isHookFormField && (
         {withError && isHookFormField && (
           <S.FormError>
           <S.FormError>
             <ErrorMessage name={name} />
             <ErrorMessage name={name} />
@@ -192,6 +198,6 @@ const Input: React.FC<InputProps> = ({
       </S.Wrapper>
       </S.Wrapper>
     </div>
     </div>
   );
   );
-};
+});
 
 
 export default Input;
 export default Input;

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

@@ -1,7 +1,9 @@
-import React from 'react';
+import React, { useRef } from 'react';
 import { useDebouncedCallback } from 'use-debounce';
 import { useDebouncedCallback } from 'use-debounce';
 import Input from 'components/common/Input/Input';
 import Input from 'components/common/Input/Input';
 import { useSearchParams } from 'react-router-dom';
 import { useSearchParams } from 'react-router-dom';
+import CloseIcon from 'components/common/Icons/CloseIcon';
+import styled from 'styled-components';
 
 
 interface SearchProps {
 interface SearchProps {
   placeholder?: string;
   placeholder?: string;
@@ -10,6 +12,16 @@ interface SearchProps {
   value?: string;
   value?: string;
 }
 }
 
 
+const IconButtonWrapper = styled.span.attrs(() => ({
+  role: 'button',
+  tabIndex: '0',
+}))`
+  height: 16px !important;
+  display: inline-block;
+  &:hover {
+    cursor: pointer;
+  }
+`;
 const Search: React.FC<SearchProps> = ({
 const Search: React.FC<SearchProps> = ({
   placeholder = 'Search',
   placeholder = 'Search',
   disabled = false,
   disabled = false,
@@ -17,7 +29,11 @@ const Search: React.FC<SearchProps> = ({
   onChange,
   onChange,
 }) => {
 }) => {
   const [searchParams, setSearchParams] = useSearchParams();
   const [searchParams, setSearchParams] = useSearchParams();
+  const ref = useRef<HTMLInputElement>(null);
   const handleChange = useDebouncedCallback((e) => {
   const handleChange = useDebouncedCallback((e) => {
+    if (ref.current != null) {
+      ref.current.value = e.target.value;
+    }
     if (onChange) {
     if (onChange) {
       onChange(e.target.value);
       onChange(e.target.value);
     } else {
     } else {
@@ -28,6 +44,15 @@ const Search: React.FC<SearchProps> = ({
       setSearchParams(searchParams);
       setSearchParams(searchParams);
     }
     }
   }, 500);
   }, 500);
+  const clearSearchValue = () => {
+    if (searchParams.get('q')) {
+      searchParams.set('q', '');
+      setSearchParams(searchParams);
+    }
+    if (ref.current != null) {
+      ref.current.value = '';
+    }
+  };
 
 
   return (
   return (
     <Input
     <Input
@@ -37,7 +62,13 @@ const Search: React.FC<SearchProps> = ({
       defaultValue={value || searchParams.get('q') || ''}
       defaultValue={value || searchParams.get('q') || ''}
       inputSize="M"
       inputSize="M"
       disabled={disabled}
       disabled={disabled}
+      ref={ref}
       search
       search
+      clearIcon={
+        <IconButtonWrapper onClick={clearSearchValue}>
+          <CloseIcon />
+        </IconButtonWrapper>
+      }
     />
     />
   );
   );
 };
 };

+ 20 - 0
kafka-ui-react-app/src/components/common/Search/__tests__/Search.spec.tsx

@@ -41,4 +41,24 @@ describe('Search', () => {
     render(<Search />);
     render(<Search />);
     expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
     expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
   });
   });
+
+  it('Clear button is visible', () => {
+    render(<Search placeholder={placeholder} />);
+
+    const clearButton = screen.getByRole('button');
+    expect(clearButton).toBeInTheDocument();
+  });
+
+  it('Clear button should clear text from input', async () => {
+    render(<Search placeholder={placeholder} />);
+
+    const searchField = screen.getAllByRole('textbox')[0];
+    await userEvent.type(searchField, 'some text');
+    expect(searchField).toHaveValue('some text');
+
+    const clearButton = screen.getByRole('button');
+    await userEvent.click(clearButton);
+
+    expect(searchField).toHaveValue('');
+  });
 });
 });

+ 1 - 2
kafka-ui-react-app/src/lib/dateTimeHelpers.ts

@@ -1,6 +1,6 @@
 export const formatTimestamp = (
 export const formatTimestamp = (
   timestamp?: number | string | Date,
   timestamp?: number | string | Date,
-  format: Intl.DateTimeFormatOptions = { hour12: false }
+  format: Intl.DateTimeFormatOptions = { hourCycle: 'h23' }
 ): string => {
 ): string => {
   if (!timestamp) {
   if (!timestamp) {
     return '';
     return '';
@@ -8,7 +8,6 @@ export const formatTimestamp = (
 
 
   // empty array gets the default one from the browser
   // empty array gets the default one from the browser
   const date = new Date(timestamp);
   const date = new Date(timestamp);
-
   // invalid date
   // invalid date
   if (Number.isNaN(date.getTime())) {
   if (Number.isNaN(date.getTime())) {
     return '';
     return '';