From 14084f4148f779c8d39c0d39c013f4f51e414009 Mon Sep 17 00:00:00 2001 From: Aleksei Koziurov Date: Tue, 12 Mar 2024 15:14:35 +0800 Subject: [PATCH] FE: Brokers: Configs: Improvements (#179) --- .../Brokers/Broker/Configs/Configs.styled.ts | 30 ----- .../Brokers/Broker/Configs/Configs.tsx | 103 ++++-------------- .../Brokers/Broker/Configs/InputCell.tsx | 91 ---------------- .../ConfigSourceHeader.styled.ts | 12 ++ .../ConfigSourceHeader/ConfigSourceHeader.tsx | 27 +++++ .../InputCell/InputCellEditMode.tsx | 53 +++++++++ .../InputCell/InputCellViewMode.tsx | 64 +++++++++++ .../InputCell/__test__/InputCell.spec.tsx | 72 ++++++++++++ .../__test__/InputCellEditMode.spec.tsx | 42 +++++++ .../__test__/InputCellViewMode.spec.tsx | 69 ++++++++++++ .../TableComponents/InputCell/index.tsx | 60 ++++++++++ .../TableComponents/InputCell/styled.ts | 24 ++++ .../Broker/Configs/TableComponents/index.ts | 2 + .../Configs/lib/__test__/utils.spec.tsx | 47 ++++++++ .../Brokers/Broker/Configs/lib/constants.ts | 22 ++++ .../Brokers/Broker/Configs/lib/types.ts | 13 +++ .../Brokers/Broker/Configs/lib/utils.tsx | 77 +++++++++++++ .../ConfirmationModal/ConfirmationModal.tsx | 1 + .../src/components/common/Tooltip/Tooltip.tsx | 2 +- .../components/contexts/ConfirmContext.tsx | 33 ++++-- frontend/src/lib/hooks/useConfirm.ts | 19 ++-- 21 files changed, 643 insertions(+), 220 deletions(-) delete mode 100644 frontend/src/components/Brokers/Broker/Configs/InputCell.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.styled.ts create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellEditMode.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellViewMode.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCell.spec.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellEditMode.spec.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellViewMode.spec.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/index.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/styled.ts create mode 100644 frontend/src/components/Brokers/Broker/Configs/TableComponents/index.ts create mode 100644 frontend/src/components/Brokers/Broker/Configs/lib/__test__/utils.spec.tsx create mode 100644 frontend/src/components/Brokers/Broker/Configs/lib/constants.ts create mode 100644 frontend/src/components/Brokers/Broker/Configs/lib/types.ts create mode 100644 frontend/src/components/Brokers/Broker/Configs/lib/utils.tsx diff --git a/frontend/src/components/Brokers/Broker/Configs/Configs.styled.ts b/frontend/src/components/Brokers/Broker/Configs/Configs.styled.ts index cced53373..77768dc99 100644 --- a/frontend/src/components/Brokers/Broker/Configs/Configs.styled.ts +++ b/frontend/src/components/Brokers/Broker/Configs/Configs.styled.ts @@ -1,36 +1,6 @@ import styled from 'styled-components'; -export const ValueWrapper = styled.div` - display: flex; - justify-content: space-between; - button { - margin: 0 10px; - } -`; - -export const Value = styled.span` - line-height: 24px; - margin-right: 10px; - text-overflow: ellipsis; - max-width: 400px; - overflow: hidden; - white-space: nowrap; -`; - -export const ButtonsWrapper = styled.div` - display: flex; -`; export const SearchWrapper = styled.div` margin: 10px; width: 21%; `; - -export const Source = styled.div` - display: flex; - align-content: center; - svg { - margin-left: 10px; - vertical-align: middle; - cursor: pointer; - } -`; diff --git a/frontend/src/components/Brokers/Broker/Configs/Configs.tsx b/frontend/src/components/Brokers/Broker/Configs/Configs.tsx index 5b05b7cc5..54b9bd6a9 100644 --- a/frontend/src/components/Brokers/Broker/Configs/Configs.tsx +++ b/frontend/src/components/Brokers/Broker/Configs/Configs.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { CellContext, ColumnDef } from '@tanstack/react-table'; +import React, { type FC, useMemo, useState } from 'react'; import { ClusterBrokerParam } from 'lib/paths'; import useAppParams from 'lib/hooks/useAppParams'; import { @@ -7,91 +6,37 @@ import { useUpdateBrokerConfigByName, } from 'lib/hooks/api/brokers'; import Table from 'components/common/NewTable'; -import { BrokerConfig, ConfigSource } from 'generated-sources'; +import type { BrokerConfig } from 'generated-sources'; import Search from 'components/common/Search/Search'; -import Tooltip from 'components/common/Tooltip/Tooltip'; -import InfoIcon from 'components/common/Icons/InfoIcon'; +import { + getBrokerConfigsTableColumns, + getConfigTableData, +} from 'components/Brokers/Broker/Configs/lib/utils'; -import InputCell from './InputCell'; import * as S from './Configs.styled'; -const tooltipContent = `DYNAMIC_TOPIC_CONFIG = dynamic topic config that is configured for a specific topic -DYNAMIC_BROKER_LOGGER_CONFIG = dynamic broker logger config that is configured for a specific broker -DYNAMIC_BROKER_CONFIG = dynamic broker config that is configured for a specific broker -DYNAMIC_DEFAULT_BROKER_CONFIG = dynamic broker config that is configured as default for all brokers in the cluster -STATIC_BROKER_CONFIG = static broker config provided as broker properties at start up (e.g. server.properties file) -DEFAULT_CONFIG = built-in default configuration for configs that have a default value -UNKNOWN = source unknown e.g. in the ConfigEntry used for alter requests where source is not set`; - -const Configs: React.FC = () => { - const [keyword, setKeyword] = React.useState(''); +const Configs: FC = () => { + const [searchQuery, setSearchQuery] = useState(''); const { clusterName, brokerId } = useAppParams(); - const { data = [] } = useBrokerConfig(clusterName, Number(brokerId)); - const stateMutation = useUpdateBrokerConfigByName( + const { data: configs = [] } = useBrokerConfig(clusterName, Number(brokerId)); + const updateBrokerConfigByName = useUpdateBrokerConfigByName( clusterName, Number(brokerId) ); - const getData = () => { - return data - .filter((item) => { - const nameMatch = item.name - .toLocaleLowerCase() - .includes(keyword.toLocaleLowerCase()); - return nameMatch - ? true - : item.value && - item.value - .toLocaleLowerCase() - .includes(keyword.toLocaleLowerCase()); // try to match the keyword on any of the item.value elements when nameMatch fails but item.value exists - }) - .sort((a, b) => { - if (a.source === b.source) return 0; - return a.source === ConfigSource.DYNAMIC_BROKER_CONFIG ? -1 : 1; - }); - }; - - const dataSource = React.useMemo(() => getData(), [data, keyword]); - - const renderCell = (props: CellContext) => ( - { - stateMutation.mutateAsync({ - name, - brokerConfigItem: { - value, - }, - }); - }} - /> + const tableData = useMemo( + () => getConfigTableData(configs, searchQuery), + [configs, searchQuery] ); - const columns = React.useMemo[]>( - () => [ - { header: 'Key', accessorKey: 'name' }, - { - header: 'Value', - accessorKey: 'value', - cell: renderCell, - }, - { - // eslint-disable-next-line react/no-unstable-nested-components - header: () => { - return ( - - Source - } - content={tooltipContent} - placement="top-end" - /> - - ); - }, - accessorKey: 'source', - }, - ], + const onUpdateInputCell = async ( + name: BrokerConfig['name'], + value: BrokerConfig['value'] + ) => + updateBrokerConfigByName.mutateAsync({ name, brokerConfigItem: { value } }); + + const columns = useMemo( + () => getBrokerConfigsTableColumns(onUpdateInputCell), [] ); @@ -99,12 +44,12 @@ const Configs: React.FC = () => { <> - +
); }; diff --git a/frontend/src/components/Brokers/Broker/Configs/InputCell.tsx b/frontend/src/components/Brokers/Broker/Configs/InputCell.tsx deleted file mode 100644 index bf54c45c6..000000000 --- a/frontend/src/components/Brokers/Broker/Configs/InputCell.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useEffect } from 'react'; -import { CellContext } from '@tanstack/react-table'; -import CheckmarkIcon from 'components/common/Icons/CheckmarkIcon'; -import EditIcon from 'components/common/Icons/EditIcon'; -import CancelIcon from 'components/common/Icons/CancelIcon'; -import { useConfirm } from 'lib/hooks/useConfirm'; -import { Action, BrokerConfig, ResourceType } from 'generated-sources'; -import { Button } from 'components/common/Button/Button'; -import Input from 'components/common/Input/Input'; -import { ActionButton } from 'components/common/ActionComponent'; - -import * as S from './Configs.styled'; - -interface InputCellProps extends CellContext { - onUpdate: (name: string, value?: string) => void; -} - -const InputCell: React.FC = ({ row, getValue, onUpdate }) => { - const initialValue = `${getValue()}`; - const [isEdit, setIsEdit] = React.useState(false); - const [value, setValue] = React.useState(initialValue); - - const confirm = useConfirm(); - - const onSave = () => { - if (value !== initialValue) { - confirm('Are you sure you want to change the value?', async () => { - onUpdate(row?.original?.name, value); - }); - } - setIsEdit(false); - }; - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - return isEdit ? ( - - setValue(target?.value)} - /> - - - - - - ) : ( - - {initialValue} - setIsEdit(true)} - permission={{ - resource: ResourceType.CLUSTERCONFIG, - action: Action.EDIT, - }} - > - Edit - - - ); -}; - -export default InputCell; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.styled.ts b/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.styled.ts new file mode 100644 index 000000000..3856932b1 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.styled.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const Source = styled.div` + display: flex; + align-content: center; + + svg { + margin-left: 10px; + vertical-align: middle; + cursor: pointer; + } +`; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.tsx new file mode 100644 index 000000000..e521218e8 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/ConfigSourceHeader/ConfigSourceHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Tooltip from 'components/common/Tooltip/Tooltip'; +import InfoIcon from 'components/common/Icons/InfoIcon'; +import { CONFIG_SOURCE_NAME_MAP } from 'components/Brokers/Broker/Configs/lib/constants'; + +import * as S from './ConfigSourceHeader.styled'; + +const tooltipContent = `${CONFIG_SOURCE_NAME_MAP.DYNAMIC_TOPIC_CONFIG} = dynamic topic config that is configured for a specific topic +${CONFIG_SOURCE_NAME_MAP.DYNAMIC_BROKER_LOGGER_CONFIG} = dynamic broker logger config that is configured for a specific broker +${CONFIG_SOURCE_NAME_MAP.DYNAMIC_BROKER_CONFIG} = dynamic broker config that is configured for a specific broker +${CONFIG_SOURCE_NAME_MAP.DYNAMIC_DEFAULT_BROKER_CONFIG} = dynamic broker config that is configured as default for all brokers in the cluster +${CONFIG_SOURCE_NAME_MAP.STATIC_BROKER_CONFIG} = static broker config provided as broker properties at start up (e.g. server.properties file) +${CONFIG_SOURCE_NAME_MAP.DEFAULT_CONFIG} = built-in default configuration for configs that have a default value +${CONFIG_SOURCE_NAME_MAP.UNKNOWN} = source unknown e.g. in the ConfigEntry used for alter requests where source is not set`; + +const ConfigSourceHeader = () => ( + + Source + } + content={tooltipContent} + placement="top-end" + /> + +); + +export default ConfigSourceHeader; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellEditMode.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellEditMode.tsx new file mode 100644 index 000000000..2ac00ca63 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellEditMode.tsx @@ -0,0 +1,53 @@ +import React, { type FC, useState } from 'react'; +import Input from 'components/common/Input/Input'; +import { Button } from 'components/common/Button/Button'; +import CheckmarkIcon from 'components/common/Icons/CheckmarkIcon'; +import CancelIcon from 'components/common/Icons/CancelIcon'; + +import * as S from './styled'; + +interface EditModeProps { + initialValue: string; + onSave: (value: string) => void; + onCancel: () => void; +} + +const InputCellEditMode: FC = ({ + initialValue, + onSave, + onCancel, +}) => { + const [value, setValue] = useState(initialValue); + + return ( + + setValue(target.value)} + /> + + + + + + ); +}; + +export default InputCellEditMode; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellViewMode.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellViewMode.tsx new file mode 100644 index 000000000..8e7c19376 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellViewMode.tsx @@ -0,0 +1,64 @@ +import React, { type FC, ReactNode } from 'react'; +import { Button } from 'components/common/Button/Button'; +import EditIcon from 'components/common/Icons/EditIcon'; +import type { ConfigUnit } from 'components/Brokers/Broker/Configs/lib/types'; +import Tooltip from 'components/common/Tooltip/Tooltip'; +import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; + +import * as S from './styled'; + +interface InputCellViewModeProps { + value: string; + unit: ConfigUnit | undefined; + onEdit: () => void; + isDynamic: boolean; + isSensitive: boolean; + isReadOnly: boolean; +} + +const InputCellViewMode: FC = ({ + value, + unit, + onEdit, + isDynamic, + isSensitive, + isReadOnly, +}) => { + let displayValue: ReactNode | string; + let title: string; + + if (isSensitive) { + displayValue = '**********'; + title = 'Sensitive Value'; + } else if (unit === 'bytes' && parseInt(value, 10) > 0) { + displayValue = ; + title = `Bytes: ${value}`; + } else { + displayValue = unit ? `${value} ${unit}` : value; + title = displayValue.toString(); + } + + return ( + + {displayValue} + + Edit + + } + showTooltip={isReadOnly} + content="Property is read-only" + placement="left" + /> + + ); +}; + +export default InputCellViewMode; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCell.spec.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCell.spec.tsx new file mode 100644 index 000000000..421b00f21 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCell.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import InputCell, { + type InputCellProps, +} from 'components/Brokers/Broker/Configs/TableComponents/InputCell/index'; +import { render } from 'lib/testHelpers'; +import { ConfigSource } from 'generated-sources'; +import { useConfirm } from 'lib/hooks/useConfirm'; +import { BrokerConfigsTableRow } from 'components/Brokers/Broker/Configs/lib/types'; +import { Row } from '@tanstack/react-table'; + +jest.mock('lib/hooks/useConfirm', () => ({ + useConfirm: jest.fn(), +})); + +describe('InputCell', () => { + const mockOnUpdate = jest.fn(); + const initialValue = 'initialValue'; + const name = 'testName'; + const original = { + name, + source: ConfigSource.DYNAMIC_BROKER_CONFIG, + value: initialValue, + isSensitive: false, + isReadOnly: false, + }; + + beforeEach(() => { + const setupWrapper = (props?: Partial) => ( + } + onUpdate={mockOnUpdate} + /> + ); + (useConfirm as jest.Mock).mockImplementation( + () => (message: string, callback: () => void) => callback() + ); + render(setupWrapper()); + }); + + it('renders InputCellViewMode by default', () => { + expect(screen.getByText(initialValue)).toBeInTheDocument(); + }); + + it('switches to InputCellEditMode upon triggering an edit action', async () => { + const user = userEvent.setup(); + await user.click(screen.getByLabelText('editAction')); + expect( + screen.getByRole('textbox', { name: /inputValue/i }) + ).toBeInTheDocument(); + }); + + it('calls onUpdate callback with the new value when saved', async () => { + const user = userEvent.setup(); + await user.click(screen.getByLabelText('editAction')); // Enter edit mode + await user.type( + screen.getByRole('textbox', { name: /inputValue/i }), + '123' + ); + await user.click(screen.getByRole('button', { name: /confirmAction/i })); + expect(mockOnUpdate).toHaveBeenCalledWith(name, 'initialValue123'); + }); + + it('returns to InputCellViewMode upon canceling an edit', async () => { + const user = userEvent.setup(); + await user.click(screen.getByLabelText('editAction')); // Enter edit mode + await user.click(screen.getByRole('button', { name: /cancelAction/i })); + expect(screen.getByText(initialValue)).toBeInTheDocument(); // Back to view mode + }); +}); diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellEditMode.spec.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellEditMode.spec.tsx new file mode 100644 index 000000000..91270469e --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellEditMode.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import InputCellEditMode from 'components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellEditMode'; +import { render } from 'lib/testHelpers'; +import userEvent from '@testing-library/user-event'; + +describe('InputCellEditMode', () => { + const mockOnSave = jest.fn(); + const mockOnCancel = jest.fn(); + + beforeEach(() => { + render( + + ); + }); + + it('renders with initial value', () => { + expect(screen.getByRole('textbox', { name: /inputValue/i })).toHaveValue( + 'test' + ); + }); + + it('calls onSave with new value', async () => { + const user = userEvent.setup(); + await user.type( + screen.getByRole('textbox', { name: /inputValue/i }), + '123' + ); + await user.click(screen.getByRole('button', { name: /confirmAction/i })); + expect(mockOnSave).toHaveBeenCalledWith('test123'); + }); + + it('calls onCancel', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /cancelAction/i })); + expect(mockOnCancel).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellViewMode.spec.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellViewMode.spec.tsx new file mode 100644 index 000000000..9499fa857 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/__test__/InputCellViewMode.spec.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'lib/testHelpers'; +import InputCellViewMode from 'components/Brokers/Broker/Configs/TableComponents/InputCell/InputCellViewMode'; + +describe('InputCellViewMode', () => { + const mockOnEdit = jest.fn(); + const value = 'testValue'; + + it('displays the correct value for non-sensitive data', () => { + render( + + ); + expect(screen.getByTitle(value)).toBeInTheDocument(); + }); + + it('masks sensitive data with asterisks', () => { + render( + + ); + expect(screen.getByTitle('Sensitive Value')).toBeInTheDocument(); + expect(screen.getByText('**********')).toBeInTheDocument(); + }); + + it('renders edit button and triggers onEdit callback when clicked', async () => { + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByLabelText('editAction')); + expect(mockOnEdit).toHaveBeenCalled(); + }); + + it('disables edit button for read-only properties', () => { + render( + + ); + expect(screen.getByLabelText('editAction')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/index.tsx b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/index.tsx new file mode 100644 index 000000000..a55d7852c --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/index.tsx @@ -0,0 +1,60 @@ +import React, { type FC, useState } from 'react'; +import { useConfirm } from 'lib/hooks/useConfirm'; +import { type CellContext } from '@tanstack/react-table'; +import { type BrokerConfig } from 'generated-sources'; +import { + BrokerConfigsTableRow, + UpdateBrokerConfigCallback, +} from 'components/Brokers/Broker/Configs/lib/types'; +import { getConfigUnit } from 'components/Brokers/Broker/Configs/lib/utils'; + +import InputCellViewMode from './InputCellViewMode'; +import InputCellEditMode from './InputCellEditMode'; + +export interface InputCellProps + extends CellContext { + onUpdate: UpdateBrokerConfigCallback; +} + +const InputCell: FC = ({ row, onUpdate }) => { + const [isEdit, setIsEdit] = useState(false); + const confirm = useConfirm(); + const { + name, + source, + value: initialValue, + isSensitive, + isReadOnly, + } = row.original; + + const handleSave = (newValue: string) => { + if (newValue !== initialValue) { + confirm('Are you sure you want to change the value?', () => + onUpdate(name, newValue) + ); + } + setIsEdit(false); + }; + + const isDynamic = source === 'DYNAMIC_BROKER_CONFIG'; + const configUnit = getConfigUnit(name); + + return isEdit ? ( + setIsEdit(false)} + /> + ) : ( + setIsEdit(true)} + isDynamic={isDynamic} + isSensitive={isSensitive} + isReadOnly={isReadOnly} + /> + ); +}; + +export default InputCell; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/styled.ts b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/styled.ts new file mode 100644 index 000000000..859b2d5d1 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/InputCell/styled.ts @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +export const ValueWrapper = styled.div<{ $isDynamic?: boolean }>` + display: flex; + justify-content: space-between; + font-weight: ${({ $isDynamic }) => ($isDynamic ? 600 : 400)}; + + button { + margin: 0 10px; + } +`; + +export const Value = styled.span` + line-height: 24px; + margin-right: 10px; + text-overflow: ellipsis; + max-width: 400px; + overflow: hidden; + white-space: nowrap; +`; + +export const ButtonsWrapper = styled.div` + display: flex; +`; diff --git a/frontend/src/components/Brokers/Broker/Configs/TableComponents/index.ts b/frontend/src/components/Brokers/Broker/Configs/TableComponents/index.ts new file mode 100644 index 000000000..308200222 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/TableComponents/index.ts @@ -0,0 +1,2 @@ +export { default as InputCell } from './InputCell'; +export { default as ConfigSourceHeader } from './ConfigSourceHeader/ConfigSourceHeader'; diff --git a/frontend/src/components/Brokers/Broker/Configs/lib/__test__/utils.spec.tsx b/frontend/src/components/Brokers/Broker/Configs/lib/__test__/utils.spec.tsx new file mode 100644 index 000000000..fa9e04be1 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/lib/__test__/utils.spec.tsx @@ -0,0 +1,47 @@ +import { + getConfigTableData, + getConfigUnit, +} from 'components/Brokers/Broker/Configs/lib/utils'; +import { ConfigSource } from 'generated-sources'; + +describe('getConfigTableData', () => { + it('filters configs by search query and sorts by source priority', () => { + const configs = [ + { + name: 'log.retention.ms', + value: '7200000', + source: ConfigSource.DEFAULT_CONFIG, + isSensitive: true, + isReadOnly: false, + }, + { + name: 'log.segment.bytes', + value: '1073741824', + source: ConfigSource.DYNAMIC_BROKER_CONFIG, + isSensitive: false, + isReadOnly: true, + }, + { + name: 'compression.type', + value: 'producer', + source: ConfigSource.DEFAULT_CONFIG, + isSensitive: true, + isReadOnly: false, + }, + ]; + const searchQuery = 'log'; + const result = getConfigTableData(configs, searchQuery); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('log.segment.bytes'); + expect(result[1].name).toBe('log.retention.ms'); + }); +}); + +describe('getConfigUnit', () => { + it('identifies the unit of a configuration name', () => { + expect(getConfigUnit('log.retention.ms')).toBe('ms'); + expect(getConfigUnit('log.segment.bytes')).toBe('bytes'); + expect(getConfigUnit('compression.type')).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/Brokers/Broker/Configs/lib/constants.ts b/frontend/src/components/Brokers/Broker/Configs/lib/constants.ts new file mode 100644 index 000000000..5a829323a --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/lib/constants.ts @@ -0,0 +1,22 @@ +import { ConfigSource } from 'generated-sources'; + +export const CONFIG_SOURCE_NAME_MAP: Record = { + [ConfigSource.DYNAMIC_TOPIC_CONFIG]: 'Dynamic topic config', + [ConfigSource.DYNAMIC_BROKER_LOGGER_CONFIG]: 'Dynamic broker logger config', + [ConfigSource.DYNAMIC_BROKER_CONFIG]: 'Dynamic broker config', + [ConfigSource.DYNAMIC_DEFAULT_BROKER_CONFIG]: 'Dynamic default broker config', + [ConfigSource.STATIC_BROKER_CONFIG]: 'Static broker config', + [ConfigSource.DEFAULT_CONFIG]: 'Default config', + [ConfigSource.UNKNOWN]: 'Unknown', +} as const; + +export const CONFIG_SOURCE_PRIORITY = { + [ConfigSource.DYNAMIC_TOPIC_CONFIG]: 1, + [ConfigSource.DYNAMIC_BROKER_LOGGER_CONFIG]: 1, + [ConfigSource.DYNAMIC_BROKER_CONFIG]: 1, + [ConfigSource.DYNAMIC_DEFAULT_BROKER_CONFIG]: 1, + [ConfigSource.STATIC_BROKER_CONFIG]: 2, + [ConfigSource.DEFAULT_CONFIG]: 3, + [ConfigSource.UNKNOWN]: 4, + UNHANDLED: 5, +} as const; diff --git a/frontend/src/components/Brokers/Broker/Configs/lib/types.ts b/frontend/src/components/Brokers/Broker/Configs/lib/types.ts new file mode 100644 index 000000000..1a726cad3 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/lib/types.ts @@ -0,0 +1,13 @@ +import { type BrokerConfig } from 'generated-sources'; + +export type BrokerConfigsTableRow = Pick< + BrokerConfig, + 'name' | 'value' | 'source' | 'isReadOnly' | 'isSensitive' +>; + +export type UpdateBrokerConfigCallback = ( + name: BrokerConfig['name'], + value: BrokerConfig['value'] +) => Promise; + +export type ConfigUnit = 'ms' | 'bytes'; diff --git a/frontend/src/components/Brokers/Broker/Configs/lib/utils.tsx b/frontend/src/components/Brokers/Broker/Configs/lib/utils.tsx new file mode 100644 index 000000000..25b989fa2 --- /dev/null +++ b/frontend/src/components/Brokers/Broker/Configs/lib/utils.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { type BrokerConfig, ConfigSource } from 'generated-sources'; +import { createColumnHelper } from '@tanstack/react-table'; +import * as BrokerConfigTableComponents from 'components/Brokers/Broker/Configs/TableComponents/index'; + +import type { + BrokerConfigsTableRow, + ConfigUnit, + UpdateBrokerConfigCallback, +} from './types'; +import { CONFIG_SOURCE_NAME_MAP, CONFIG_SOURCE_PRIORITY } from './constants'; + +const getConfigFieldMatch = (field: string, query: string) => + field.toLocaleLowerCase().includes(query.toLocaleLowerCase()); + +const filterConfigsBySearchQuery = + (searchQuery: string) => (config: BrokerConfig) => { + const nameMatch = getConfigFieldMatch(config.name, searchQuery); + const valueMatch = + config.value && getConfigFieldMatch(config.value, searchQuery); + + return nameMatch ? true : valueMatch; + }; + +const getConfigSourcePriority = (source: ConfigSource): number => + CONFIG_SOURCE_PRIORITY[source]; + +const sortBrokersBySource = (a: BrokerConfig, b: BrokerConfig) => { + const priorityA = getConfigSourcePriority(a.source); + const priorityB = getConfigSourcePriority(b.source); + + return priorityA - priorityB; +}; + +export const getConfigTableData = ( + configs: BrokerConfig[], + searchQuery: string +) => + configs + .filter(filterConfigsBySearchQuery(searchQuery)) + .sort(sortBrokersBySource); + +export const getBrokerConfigsTableColumns = ( + onUpdateInputCell: UpdateBrokerConfigCallback +) => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor('name', { header: 'Key' }), + columnHelper.accessor('value', { + header: 'Value', + cell: (props) => ( + + ), + }), + columnHelper.accessor('source', { + header: BrokerConfigTableComponents.ConfigSourceHeader, + cell: ({ getValue }) => CONFIG_SOURCE_NAME_MAP[getValue()], + }), + ]; +}; + +const unitPatterns = { + ms: /\.ms$/, + bytes: /\.bytes$/, +}; + +export const getConfigUnit = (configName: string): ConfigUnit | undefined => { + const found = Object.entries(unitPatterns).find(([, pattern]) => + pattern.test(configName) + ); + + return found ? (found[0] as ConfigUnit) : undefined; +}; diff --git a/frontend/src/components/common/ConfirmationModal/ConfirmationModal.tsx b/frontend/src/components/common/ConfirmationModal/ConfirmationModal.tsx index 1b882c946..98a9a51eb 100644 --- a/frontend/src/components/common/ConfirmationModal/ConfirmationModal.tsx +++ b/frontend/src/components/common/ConfirmationModal/ConfirmationModal.tsx @@ -30,6 +30,7 @@ const ConfirmationModal: React.FC = () => { buttonSize="M" onClick={context.confirm} type="button" + inProgress={context?.isConfirming} > Confirm diff --git a/frontend/src/components/common/Tooltip/Tooltip.tsx b/frontend/src/components/common/Tooltip/Tooltip.tsx index 4d31585c4..6b74391ca 100644 --- a/frontend/src/components/common/Tooltip/Tooltip.tsx +++ b/frontend/src/components/common/Tooltip/Tooltip.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; import { + Placement, useFloating, useHover, useInteractions, - Placement, } from '@floating-ui/react'; import * as S from './Tooltip.styled'; diff --git a/frontend/src/components/contexts/ConfirmContext.tsx b/frontend/src/components/contexts/ConfirmContext.tsx index d68eda254..b4807bd1d 100644 --- a/frontend/src/components/contexts/ConfirmContext.tsx +++ b/frontend/src/components/contexts/ConfirmContext.tsx @@ -1,25 +1,32 @@ -import React, { useState } from 'react'; +import React, { + createContext, + type Dispatch, + type FC, + type PropsWithChildren, + type ReactNode, + type SetStateAction, + useState, +} from 'react'; interface ConfirmContextType { - content: React.ReactNode; + content: ReactNode; confirm?: () => void; - setContent: React.Dispatch>; - setConfirm: React.Dispatch void) | undefined>>; + setContent: Dispatch>; + setConfirm: Dispatch void) | undefined>>; cancel: () => void; dangerButton: boolean; - setDangerButton: React.Dispatch>; + setDangerButton: Dispatch>; + isConfirming: boolean; + setIsConfirming: Dispatch>; } -export const ConfirmContext = React.createContext( - null -); +export const ConfirmContext = createContext(null); -export const ConfirmContextProvider: React.FC< - React.PropsWithChildren -> = ({ children }) => { - const [content, setContent] = useState(null); +export const ConfirmContextProvider: FC = ({ children }) => { + const [content, setContent] = useState(null); const [confirm, setConfirm] = useState<(() => void) | undefined>(undefined); const [dangerButton, setDangerButton] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); const cancel = () => { setContent(null); @@ -36,6 +43,8 @@ export const ConfirmContextProvider: React.FC< cancel, dangerButton, setDangerButton, + isConfirming, + setIsConfirming, }} > {children} diff --git a/frontend/src/lib/hooks/useConfirm.ts b/frontend/src/lib/hooks/useConfirm.ts index baac856c5..117db2401 100644 --- a/frontend/src/lib/hooks/useConfirm.ts +++ b/frontend/src/lib/hooks/useConfirm.ts @@ -1,17 +1,22 @@ import { ConfirmContext } from 'components/contexts/ConfirmContext'; -import React, { useContext } from 'react'; +import { type ReactNode, useContext } from 'react'; export const useConfirm = (danger = false) => { const context = useContext(ConfirmContext); - return ( - message: React.ReactNode, - callback: () => void | Promise - ) => { + + return (message: ReactNode, callback: () => void | Promise) => { context?.setDangerButton(danger); context?.setContent(message); + context?.setIsConfirming(false); context?.setConfirm(() => async () => { - await callback(); - context?.cancel(); + context?.setIsConfirming(true); + + try { + await callback(); + } finally { + context?.setIsConfirming(false); + context?.cancel(); + } }); }; };