From 135055cb4515ffb7c05ee51134136e817a924fbd Mon Sep 17 00:00:00 2001 From: Renat Kalimulin <103274228+Nilumilak@users.noreply.github.com> Date: Thu, 7 Mar 2024 12:41:16 +0300 Subject: [PATCH 1/9] FE: Create connector button throws 404 if KC cluster is inaccessible (#136) Co-authored-by: Roman Zabaluev --- .../src/components/Connect/List/ListPage.tsx | 40 ++++++++++++------- .../Connect/List/__tests__/ListPage.spec.tsx | 27 ++++++++++++- .../src/components/common/Tooltip/Tooltip.tsx | 10 ++++- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Connect/List/ListPage.tsx b/frontend/src/components/Connect/List/ListPage.tsx index 94ec8354c..5d61ff3c4 100644 --- a/frontend/src/components/Connect/List/ListPage.tsx +++ b/frontend/src/components/Connect/List/ListPage.tsx @@ -1,21 +1,23 @@ import React, { Suspense } from 'react'; import useAppParams from 'lib/hooks/useAppParams'; -import { clusterConnectorNewRelativePath, ClusterNameRoute } from 'lib/paths'; +import { ClusterNameRoute, clusterConnectorNewRelativePath } from 'lib/paths'; import ClusterContext from 'components/contexts/ClusterContext'; import Search from 'components/common/Search/Search'; import * as Metrics from 'components/common/Metrics'; import PageHeading from 'components/common/PageHeading/PageHeading'; -import { ActionButton } from 'components/common/ActionComponent'; +import Tooltip from 'components/common/Tooltip/Tooltip'; import { ControlPanelWrapper } from 'components/common/ControlPanel/ControlPanel.styled'; import PageLoader from 'components/common/PageLoader/PageLoader'; -import { Action, ConnectorState, ResourceType } from 'generated-sources'; -import { useConnectors } from 'lib/hooks/api/kafkaConnect'; +import { ConnectorState, Action, ResourceType } from 'generated-sources'; +import { useConnectors, useConnects } from 'lib/hooks/api/kafkaConnect'; +import { ActionButton } from 'components/common/ActionComponent'; import List from './List'; const ListPage: React.FC = () => { const { isReadOnly } = React.useContext(ClusterContext); const { clusterName } = useAppParams(); + const { data: connects = [] } = useConnects(clusterName); // Fetches all connectors from the API, without search criteria. Used to display general metrics. const { data: connectorsMetrics, isLoading } = useConnectors(clusterName); @@ -33,17 +35,25 @@ const ListPage: React.FC = () => { <> {!isReadOnly && ( - - Create Connector - + + Create Connector + + } + showTooltip={!connects.length} + content="No Connects available" + placement="left" + /> )} diff --git a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx index 7da8e47e8..0cbe4dd78 100644 --- a/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx +++ b/frontend/src/components/Connect/List/__tests__/ListPage.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { connectors } from 'lib/fixtures/kafkaConnect'; +import { connectors, connects } from 'lib/fixtures/kafkaConnect'; import ClusterContext, { ContextProps, initialValue, @@ -8,7 +8,7 @@ import ListPage from 'components/Connect/List/ListPage'; import { screen, within } from '@testing-library/react'; import { render, WithRoute } from 'lib/testHelpers'; import { clusterConnectorsPath } from 'lib/paths'; -import { useConnectors } from 'lib/hooks/api/kafkaConnect'; +import { useConnectors, useConnects } from 'lib/hooks/api/kafkaConnect'; jest.mock('components/Connect/List/List', () => () => (
Connectors List
@@ -16,6 +16,7 @@ jest.mock('components/Connect/List/List', () => () => ( jest.mock('lib/hooks/api/kafkaConnect', () => ({ useConnectors: jest.fn(), + useConnects: jest.fn(), })); jest.mock('components/common/Icons/SpinnerIcon', () => () => 'progressbar'); @@ -28,6 +29,10 @@ describe('Connectors List Page', () => { isLoading: false, data: [], })); + + (useConnects as jest.Mock).mockImplementation(() => ({ + data: connects, + })); }); const renderComponent = async (contextValue: ContextProps = initialValue) => @@ -178,4 +183,22 @@ describe('Connectors List Page', () => { expect(failedTasksIndicator).toHaveTextContent('Failed Tasks 1'); }); }); + + describe('Create new connector', () => { + it('Create new connector button is enabled when connects list is not empty', async () => { + await renderComponent(); + + expect(screen.getByText('Create Connector')).toBeEnabled(); + }); + + it('Create new connector button is disabled when connects list is empty', async () => { + (useConnects as jest.Mock).mockImplementation(() => ({ + data: [], + })); + + await renderComponent(); + + expect(screen.getByText('Create Connector')).toBeDisabled(); + }); + }); }); diff --git a/frontend/src/components/common/Tooltip/Tooltip.tsx b/frontend/src/components/common/Tooltip/Tooltip.tsx index 0764320f5..4d31585c4 100644 --- a/frontend/src/components/common/Tooltip/Tooltip.tsx +++ b/frontend/src/components/common/Tooltip/Tooltip.tsx @@ -12,9 +12,15 @@ interface TooltipProps { value: React.ReactNode; content: string; placement?: Placement; + showTooltip?: boolean; } -const Tooltip: React.FC = ({ value, content, placement }) => { +const Tooltip: React.FC = ({ + value, + content, + placement, + showTooltip = true, +}) => { const [open, setOpen] = useState(false); const { x, y, refs, strategy, context } = useFloating({ open, @@ -28,7 +34,7 @@ const Tooltip: React.FC = ({ value, content, placement }) => {
{value}
- {open && ( + {showTooltip && open && ( Date: Thu, 7 Mar 2024 19:35:13 +0700 Subject: [PATCH 2/9] Infra: Update serde deploy flow (#193) --- .github/workflows/release-serde-api.yml | 6 +- serde-api/pom.xml | 241 ++++++++++++------------ settings.xml | 4 +- 3 files changed, 128 insertions(+), 123 deletions(-) diff --git a/.github/workflows/release-serde-api.yml b/.github/workflows/release-serde-api.yml index af5b1db0d..746769152 100644 --- a/.github/workflows/release-serde-api.yml +++ b/.github/workflows/release-serde-api.yml @@ -33,4 +33,8 @@ jobs: - name: Publish to Maven Central run: | - mvn source:jar javadoc:jar package gpg:sign -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} -Dserver.username=${{ secrets.NEXUS_USERNAME }} -Dserver.password=${{ secrets.NEXUS_PASSWORD }} nexus-staging:deploy -pl serde-api -s settings.xml + mvn source:jar javadoc:jar package gpg:sign \ + -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} \ + -Dserver.username=${{ secrets.NEXUS_USERNAME }} \ + -Dserver.password=${{ secrets.NEXUS_PASSWORD }} \ + central-publishing:publish -pl serde-api -s settings.xml diff --git a/serde-api/pom.xml b/serde-api/pom.xml index 9452dd3ac..3c2e3b3a1 100644 --- a/serde-api/pom.xml +++ b/serde-api/pom.xml @@ -1,128 +1,129 @@ + xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> - 4.0.0 - jar + 4.0.0 + jar - - 17 - 17 - + + 17 + 17 + - - - ossrh - https://s01.oss.sonatype.org/content/repositories/snapshots - - - ossrh - https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ - - + + + ossrh + https://s01.oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ + + - kafbat-ui-serde-api - kafbat-ui-serde-api - http://github.com/kafbat/kafka-ui - - - The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - - - - - Kafbat - maintainers@kafbat.io - Kafbat - https://kafbat.io/ - - - - scm:git:git://github.com/kafbat/kafka-ui.git - scm:git:ssh://github.com:kafbat/kafka-ui.git - https://github.com/kafbat/kafka-ui - - io.kafbat.ui - serde-api - 1.0.0 + kafbat-ui-serde-api + kafbat-ui-serde-api + http://github.com/kafbat/kafka-ui + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Kafbat + maintainers@kafbat.io + Kafbat + https://kafbat.io/ + + + + scm:git:git://github.com/kafbat/kafka-ui.git + scm:git:ssh://github.com:kafbat/kafka-ui.git + https://github.com/kafbat/kafka-ui + + io.kafbat.ui + serde-api + 1.0.0 - - - - - org.apache.maven.plugins - maven-install-plugin - 2.5.2 - - - org.apache.maven.plugins - maven-jar-plugin - 3.3.0 - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 - true - - ossrh - https://s01.oss.sonatype.org/ - true - - - - org.apache.maven.plugins - maven-source-plugin - 2.2.1 - - - attach-sources - - jar-no-fork - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - - 8 - - 3.5.0 - - - attach-javadocs - - jar - - - - - - org.apache.maven.plugins - maven-gpg-plugin - - - sign-artifacts - verify - - sign - - - - - - - --pinentry-mode - loopback - - - - - - + + + + + org.apache.maven.plugins + maven-install-plugin + 2.5.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + org.sonatype.central + central-publishing-maven-plugin + 0.4.0 + true + + central + true + true + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + + 3.5.0 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.1.0 + + + sign-artifacts + verify + + sign + + + + + + + --pinentry-mode + loopback + + + + + + diff --git a/settings.xml b/settings.xml index 7935a5f53..e3dd1641d 100644 --- a/settings.xml +++ b/settings.xml @@ -1,7 +1,7 @@ - ossrh + central ${server.username} ${server.password} @@ -14,4 +14,4 @@ - \ No newline at end of file + From b3691ceb146db37ec845f0b18ac2e384e6032dd0 Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Thu, 7 Mar 2024 19:37:54 +0700 Subject: [PATCH 3/9] FE: Build fix, add tsc to build goal (#191) --- .github/workflows/frontend_tests.yml | 12 ++++++++++-- frontend/package.json | 3 ++- .../common/NewTable/__test__/Table.spec.tsx | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frontend_tests.yml b/.github/workflows/frontend_tests.yml index 4be3239d4..10d5fd4cb 100644 --- a/.github/workflows/frontend_tests.yml +++ b/.github/workflows/frontend_tests.yml @@ -13,37 +13,45 @@ jobs: NODE_ENV: dev runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 with: # Disabling shallow clone is recommended for improving relevancy of reporting fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} token: ${{ github.token }} + - uses: pnpm/action-setup@v3.0.0 with: version: 8.6.12 + - name: Install node uses: actions/setup-node@v4.0.1 with: node-version: "18.17.1" cache: "pnpm" cache-dependency-path: "./frontend/pnpm-lock.yaml" + - name: Install Node dependencies run: | cd frontend/ pnpm install --frozen-lockfile - - name: Generate sources + + - name: Compile run: | cd frontend/ - pnpm gen:sources + pnpm compile + - name: Linter run: | cd frontend/ pnpm lint:CI + - name: Tests run: | cd frontend/ pnpm test:CI + - name: SonarCloud Scan if: false # TODO remove when public uses: sonarsource/sonarcloud-github-action@master diff --git a/frontend/package.json b/frontend/package.json index 540c85a88..8a0d936cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,8 +51,9 @@ "start": "vite", "dev": "vite", "clean": "rimraf ./src/generated-sources", + "compile": "pnpm gen:sources && tsc --noEmit", "gen:sources": "pnpm clean && openapi-generator-cli generate", - "build": "pnpm gen:sources && tsc --noEmit && vite build", + "build": "pnpm compile && vite build", "preview": "vite preview", "lint": "eslint --ext .tsx,.ts src/", "lint:fix": "eslint --ext .tsx,.ts src/ --fix", diff --git a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx index b22e7c0ec..e31a88846 100644 --- a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx +++ b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx @@ -94,7 +94,7 @@ const columns: ColumnDef[] = [ const ExpandedRow: React.FC = () =>
I am expanded row
; -interface Props extends TableProps { +interface Props extends TableProps { path?: string; } From 0694303e3069d40078c91bc6a77baf2e43adaace Mon Sep 17 00:00:00 2001 From: Roman Zabaluev Date: Thu, 7 Mar 2024 20:02:16 +0700 Subject: [PATCH 4/9] BE: Fix blocking gh release info call (#185) --- .../main/java/io/kafbat/ui/service/ApplicationInfoService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java index 9c396e149..0a04ce9d6 100644 --- a/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java +++ b/api/src/main/java/io/kafbat/ui/service/ApplicationInfoService.java @@ -17,6 +17,7 @@ import org.springframework.boot.info.GitProperties; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; @Service public class ApplicationInfoService { @@ -70,7 +71,7 @@ private List getEnabledFeatures() { // updating on startup and every hour @Scheduled(fixedRateString = "${github-release-info-update-rate:3600000}") public void updateGithubReleaseInfo() { - githubReleaseInfo.refresh().block(); + githubReleaseInfo.refresh().subscribe(); } } From 14084f4148f779c8d39c0d39c013f4f51e414009 Mon Sep 17 00:00:00 2001 From: Aleksei Koziurov Date: Tue, 12 Mar 2024 15:14:35 +0800 Subject: [PATCH 5/9] 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(); + } }); }; }; From 6e9bbcdc071668e7e103b2944e194c6d405158dc Mon Sep 17 00:00:00 2001 From: Aleksei Koziurov Date: Tue, 12 Mar 2024 20:55:16 +0800 Subject: [PATCH 6/9] FE: Redesign buttons (#200) --- .../KsqlDb/Query/QueryForm/QueryForm.tsx | 6 +- .../NavBar/UserInfo/UserInfo.styled.ts | 16 ++- .../Form/__tests__/TimeToRetainBtn.spec.tsx | 4 +- .../components/common/Button/Button.styled.ts | 45 +++----- .../src/components/common/Button/Button.tsx | 28 ++--- .../common/Button/__tests__/Button.spec.tsx | 8 -- frontend/src/theme/theme.ts | 108 +++++++----------- 7 files changed, 85 insertions(+), 130 deletions(-) diff --git a/frontend/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx b/frontend/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx index 7456b5d2d..900b11e1e 100644 --- a/frontend/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx +++ b/frontend/src/components/KsqlDb/Query/QueryForm/QueryForm.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { FormError } from 'components/common/Input/Input.styled'; import { ErrorMessage } from '@hookform/error-message'; import { - useForm, Controller, - useFieldArray, FormProvider, + useFieldArray, + useForm, } from 'react-hook-form'; import { Button } from 'components/common/Button/Button'; import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; @@ -24,6 +24,7 @@ interface QueryFormProps { resetResults: () => void; submitHandler: (values: FormValues) => void; } + type StreamsPropertiesType = { key: string; value: string; @@ -118,7 +119,6 @@ const QueryForm: React.FC = ({ onClick={() => setValue('ksql', '')} buttonType="primary" buttonSize="S" - isInverted > Clear diff --git a/frontend/src/components/NavBar/UserInfo/UserInfo.styled.ts b/frontend/src/components/NavBar/UserInfo/UserInfo.styled.ts index 85154d767..2f99fb293 100644 --- a/frontend/src/components/NavBar/UserInfo/UserInfo.styled.ts +++ b/frontend/src/components/NavBar/UserInfo/UserInfo.styled.ts @@ -1,19 +1,23 @@ -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; export const Wrapper = styled.div` display: flex; justify-content: center; align-items: center; gap: 5px; + svg { position: relative; } `; -export const Text = styled.div( - ({ theme }) => css` - color: ${theme.button.primary.invertedColors.normal}; - ` -); +export const Text = styled.div` + color: ${({ theme }) => theme.user.color}; + + &:hover { + color: ${({ theme }) => theme.user.hoverColor}; + } +} +`; export const LogoutLink = styled.a``; diff --git a/frontend/src/components/Topics/shared/Form/__tests__/TimeToRetainBtn.spec.tsx b/frontend/src/components/Topics/shared/Form/__tests__/TimeToRetainBtn.spec.tsx index f09eec6c5..8fd700c74 100644 --- a/frontend/src/components/Topics/shared/Form/__tests__/TimeToRetainBtn.spec.tsx +++ b/frontend/src/components/Topics/shared/Form/__tests__/TimeToRetainBtn.spec.tsx @@ -4,7 +4,7 @@ import { screen } from '@testing-library/react'; import TimeToRetainBtn, { Props, } from 'components/Topics/shared/Form/TimeToRetainBtn'; -import { useForm, FormProvider } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; import { theme } from 'theme/theme'; import userEvent from '@testing-library/user-event'; @@ -61,7 +61,7 @@ describe('TimeToRetainBtn', () => { SetUpComponent({ value: 604800000 }); const buttonElement = screen.getByRole('button'); expect(buttonElement).toHaveStyle( - `background-color:${theme.button.secondary.invertedColors.normal}` + `background-color:${theme.chips.backgroundColor.active}` ); expect(buttonElement).toHaveStyle(`border:none`); }); diff --git a/frontend/src/components/common/Button/Button.styled.ts b/frontend/src/components/common/Button/Button.styled.ts index a436d01e7..649719987 100644 --- a/frontend/src/components/common/Button/Button.styled.ts +++ b/frontend/src/components/common/Button/Button.styled.ts @@ -3,7 +3,6 @@ import styled from 'styled-components'; export interface ButtonProps { buttonType: 'primary' | 'secondary' | 'danger'; buttonSize: 'S' | 'M' | 'L'; - isInverted?: boolean; } const StyledButton = styled.button` @@ -11,44 +10,32 @@ const StyledButton = styled.button` flex-direction: row; align-items: center; justify-content: center; - padding: 0 12px; + padding: ${({ buttonSize }) => (buttonSize === 'S' ? '0 8px' : '0 12px')}; border: none; border-radius: 4px; white-space: nowrap; - background: ${({ isInverted, buttonType, theme }) => - isInverted - ? 'transparent' - : theme.button[buttonType].backgroundColor.normal}; - color: ${({ isInverted, buttonType, theme }) => - isInverted - ? theme.button[buttonType].invertedColors.normal - : theme.button[buttonType].color.normal}; + background: ${({ buttonType, theme }) => + theme.button[buttonType].backgroundColor.normal}; + + color: ${({ buttonType, theme }) => theme.button[buttonType].color.normal}; + height: ${({ theme, buttonSize }) => theme.button.height[buttonSize]}; font-size: ${({ theme, buttonSize }) => theme.button.fontSize[buttonSize]}; font-weight: 500; - height: ${({ theme, buttonSize }) => theme.button.height[buttonSize]}; &:hover:enabled { - background: ${({ isInverted, buttonType, theme }) => - isInverted - ? 'transparent' - : theme.button[buttonType].backgroundColor.hover}; - color: ${({ isInverted, buttonType, theme }) => - isInverted - ? theme.button[buttonType].invertedColors.hover - : theme.button[buttonType].color}; + background: ${({ buttonType, theme }) => + theme.button[buttonType].backgroundColor.hover}; + color: ${({ buttonType, theme }) => theme.button[buttonType].color.normal}; cursor: pointer; } + &:active:enabled { - background: ${({ isInverted, buttonType, theme }) => - isInverted - ? 'transparent' - : theme.button[buttonType].backgroundColor.active}; - color: ${({ isInverted, buttonType, theme }) => - isInverted - ? theme.button[buttonType].invertedColors.active - : theme.button[buttonType].color}; + background: ${({ buttonType, theme }) => + theme.button[buttonType].backgroundColor.active}; + color: ${({ buttonType, theme }) => theme.button[buttonType].color.normal}; } + &:disabled { opacity: 0.5; cursor: not-allowed; @@ -59,11 +46,11 @@ const StyledButton = styled.button` } & a { - color: ${({ theme }) => theme.button.primary.color}; + color: ${({ theme }) => theme.button.primary.color.normal}; } & svg { - margin-right: 7px; + margin-right: 4px; fill: ${({ theme, disabled, buttonType }) => disabled ? theme.button[buttonType].color.disabled diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index fe330a5e4..46bf120fe 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -1,10 +1,9 @@ -import StyledButton, { - ButtonProps, -} from 'components/common/Button/Button.styled'; import React from 'react'; import { Link } from 'react-router-dom'; import Spinner from 'components/common/Spinner/Spinner'; +import StyledButton, { ButtonProps } from './Button.styled'; + export interface Props extends React.ButtonHTMLAttributes, ButtonProps { @@ -12,24 +11,27 @@ export interface Props inProgress?: boolean; } -export const Button: React.FC = ({ to, ...props }) => { +export const Button: React.FC = ({ + to, + children, + disabled, + inProgress, + ...props +}) => { if (to) { return ( - - {props.children} + + {children} ); } + return ( - - {props.children}{' '} - {props.inProgress ? ( + + {children}{' '} + {inProgress ? ( ) : null} diff --git a/frontend/src/components/common/Button/__tests__/Button.spec.tsx b/frontend/src/components/common/Button/__tests__/Button.spec.tsx index 21919eb0d..ced6d0af4 100644 --- a/frontend/src/components/common/Button/__tests__/Button.spec.tsx +++ b/frontend/src/components/common/Button/__tests__/Button.spec.tsx @@ -50,14 +50,6 @@ describe('Button', () => { ); }); - it('renders inverted color Button', () => { - render(