From fcb007c7ee45637bf8240f7a7874999b8e134165 Mon Sep 17 00:00:00 2001 From: Aleksei Koziurov Date: Tue, 30 Apr 2024 17:04:33 +0500 Subject: [PATCH] FE: Redesign menu (#227) Co-authored-by: Leshe4ka Co-authored-by: Roman Zabaluev Co-authored-by: VladSenyuta --- .../kafbat/ui/screens/panels/NaviSideBar.java | 4 +- .../ui/screens/schemas/SchemaCreateForm.java | 8 +- .../screens/topics/TopicCreateEditForm.java | 17 +- .../java/io/kafbat/ui/utilities/WebUtil.java | 10 +- .../ui/smokesuite/schemas/SchemasTest.java | 10 +- frontend/src/components/App.styled.ts | 2 - frontend/src/components/Nav/ClusterMenu.tsx | 71 --------- .../Nav/ClusterMenu/ClusterMenu.tsx | 105 +++++++++++++ .../__tests__/ClusterMenu.spec.tsx | 9 +- .../src/components/Nav/ClusterMenuItem.tsx | 30 ---- .../Nav/ClusterTab/ClusterTab.styled.ts | 87 ---------- .../components/Nav/ClusterTab/ClusterTab.tsx | 34 ---- frontend/src/components/Nav/Menu/MenuItem.tsx | 26 +++ frontend/src/components/Nav/Menu/MenuTab.tsx | 36 +++++ .../Nav/Menu/__tests__/MenuItem.spec.tsx | 37 +++++ .../__tests__/MenuTab.spec.tsx} | 10 +- .../__tests__/MenuTab.styled.spec.tsx} | 24 +-- frontend/src/components/Nav/Menu/styled.ts | 98 ++++++++++++ frontend/src/components/Nav/Nav.styled.ts | 40 +---- frontend/src/components/Nav/Nav.tsx | 14 +- .../Nav/__tests__/ClusterMenuItem.spec.tsx | 56 ------- .../src/components/NavBar/NavBar.styled.ts | 54 +------ frontend/src/components/NavBar/NavBar.tsx | 16 +- .../NavBar/__tests__/NavBar.spec.tsx | 6 - .../PageContainer/PageContainer.styled.ts | 37 +++-- .../PageContainer/PageContainer.tsx | 21 ++- .../__tests__/PageContainer.spec.tsx | 20 +-- .../components/common/Button/Button.styled.ts | 10 +- .../src/components/common/Button/Button.tsx | 6 +- .../src/components/common/Icons/MenuIcon.tsx | 28 ++++ .../lib/hooks/__tests__/useBoolean.spec.ts | 8 +- frontend/src/lib/hooks/useBoolean.ts | 6 +- frontend/src/lib/hooks/useScreenSize.ts | 27 ++++ .../src/theme/__tests__/hexToRgba.spec.ts | 25 +++ frontend/src/theme/hexToRgba.ts | 20 +++ frontend/src/theme/theme.ts | 148 +++++++++++++----- 36 files changed, 653 insertions(+), 507 deletions(-) delete mode 100644 frontend/src/components/Nav/ClusterMenu.tsx create mode 100644 frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx rename frontend/src/components/Nav/{ => ClusterMenu}/__tests__/ClusterMenu.spec.tsx (93%) delete mode 100644 frontend/src/components/Nav/ClusterMenuItem.tsx delete mode 100644 frontend/src/components/Nav/ClusterTab/ClusterTab.styled.ts delete mode 100644 frontend/src/components/Nav/ClusterTab/ClusterTab.tsx create mode 100644 frontend/src/components/Nav/Menu/MenuItem.tsx create mode 100644 frontend/src/components/Nav/Menu/MenuTab.tsx create mode 100644 frontend/src/components/Nav/Menu/__tests__/MenuItem.spec.tsx rename frontend/src/components/Nav/{ClusterTab/__tests__/ClusterTab.spec.tsx => Menu/__tests__/MenuTab.spec.tsx} (85%) rename frontend/src/components/Nav/{ClusterTab/__tests__/ClusterTab.styled.spec.tsx => Menu/__tests__/MenuTab.styled.spec.tsx} (69%) create mode 100644 frontend/src/components/Nav/Menu/styled.ts delete mode 100644 frontend/src/components/Nav/__tests__/ClusterMenuItem.spec.tsx create mode 100644 frontend/src/components/common/Icons/MenuIcon.tsx create mode 100644 frontend/src/lib/hooks/useScreenSize.ts create mode 100644 frontend/src/theme/__tests__/hexToRgba.spec.ts create mode 100644 frontend/src/theme/hexToRgba.ts diff --git a/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/NaviSideBar.java b/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/NaviSideBar.java index 068485748..6972f379d 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/NaviSideBar.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/screens/panels/NaviSideBar.java @@ -17,12 +17,12 @@ public class NaviSideBar extends BasePage { protected SelenideElement dashboardMenuItem = $x("//a[@title='Dashboard']"); - protected String sideMenuOptionElementLocator = ".//ul/li[contains(.,'%s')]"; + protected String sideMenuOptionElementLocator = ".//ul/a[@title='%s']"; protected String clusterElementLocator = "//aside/ul/li[contains(.,'%s')]"; private SelenideElement expandCluster(String clusterName) { SelenideElement clusterElement = $x(String.format(clusterElementLocator, clusterName)).shouldBe(Condition.visible); - if (clusterElement.parent().$$x(".//ul").size() == 0) { + if (clusterElement.parent().$$x(".//ul").isEmpty()) { WebUtil.clickByActions(clusterElement); } return clusterElement; diff --git a/e2e-tests/src/main/java/io/kafbat/ui/screens/schemas/SchemaCreateForm.java b/e2e-tests/src/main/java/io/kafbat/ui/screens/schemas/SchemaCreateForm.java index 309f8fc68..548bf15a2 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/screens/schemas/SchemaCreateForm.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/screens/schemas/SchemaCreateForm.java @@ -3,7 +3,6 @@ import static com.codeborne.selenide.Selenide.$; import static com.codeborne.selenide.Selenide.$$x; import static com.codeborne.selenide.Selenide.$x; -import static com.codeborne.selenide.Selenide.sleep; import static org.openqa.selenium.By.id; import com.codeborne.selenide.Condition; @@ -36,6 +35,7 @@ public class SchemaCreateForm extends BasePage { protected ElementsCollection visibleMarkers = $$x("//div[@class='ace_scroller']//div[contains(@class,'codeMarker')]"); protected ElementsCollection elementsCompareVersionDdl = $$x("//ul[@role='listbox']/ul/li"); + protected String versionDdlElementLocator = "//ul/li[text()='Version %s']"; protected String ddlElementLocator = "//li[@value='%s']"; @Step @@ -96,14 +96,14 @@ public int getVersionsNumberFromList() { @Step public SchemaCreateForm selectVersionFromDropDown(int versionNumberDd) { - $x(String.format(ddlElementLocator, versionNumberDd)).shouldBe(Condition.visible).click(); - sleep(1000); + $x(String.format(versionDdlElementLocator, versionNumberDd)).shouldBe(Condition.visible).click(); + waitUntilSpinnerDisappear(1); return this; } @Step public int getMarkedLinesNumber() { - return visibleMarkers.size(); + return Math.toIntExact(visibleMarkers.asDynamicIterable().stream().count()); } @Step diff --git a/e2e-tests/src/main/java/io/kafbat/ui/screens/topics/TopicCreateEditForm.java b/e2e-tests/src/main/java/io/kafbat/ui/screens/topics/TopicCreateEditForm.java index aedc3e1c5..45aa9c41a 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/screens/topics/TopicCreateEditForm.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/screens/topics/TopicCreateEditForm.java @@ -1,5 +1,6 @@ package io.kafbat.ui.screens.topics; +import static com.codeborne.selenide.Condition.enabled; import static com.codeborne.selenide.Selenide.$; import static com.codeborne.selenide.Selenide.$$; import static com.codeborne.selenide.Selenide.$x; @@ -17,6 +18,7 @@ import io.kafbat.ui.screens.topics.enums.TimeToRetain; import io.kafbat.ui.utilities.WebUtil; import io.qameta.allure.Step; +import org.openqa.selenium.Keys; public class TopicCreateEditForm extends BasePage { @@ -32,7 +34,7 @@ public class TopicCreateEditForm extends BasePage { protected SelenideElement customParameterDdl = $x("//input[contains(@name, 'customParams')][@role='listitem']"); protected SelenideElement deleteCustomParameterBtn = $x("//span[contains(@title, 'Delete customParam')]"); protected SelenideElement addCustomParameterTypeBtn = $x("//button[contains(text(), 'Add Custom Parameter')]"); - protected SelenideElement customParameterValueField = $x("//input[@placeholder='Value']"); + protected SelenideElement customParameterValueField = $x("//label[text()='Value *']/..//input"); protected SelenideElement validationCustomParameterValueMsg = $x("//p[contains(text(), 'Value is required')]"); protected String ddlElementLocator = "//li[@value='%s']"; protected String btnTimeToRetainLocator = "//button[@class][text()='%s']"; @@ -116,20 +118,27 @@ public TopicCreateEditForm setCustomParameterType(CustomParameterType customPara @Step public TopicCreateEditForm clearCustomParameterValue() { - WebUtil.clearByKeyboard(customParameterValueField); + customParameterValueField.shouldBe(enabled).sendKeys(Keys.END); + String value = customParameterValueField.getValue(); + int valueLength = value != null + ? value.length() + : 0; + for (int i = 0; i < valueLength; i++) { + customParameterValueField.sendKeys(Keys.BACK_SPACE); + } return this; } @Step public TopicCreateEditForm setNumberOfPartitions(int partitions) { - partitionsField.shouldBe(Condition.enabled).clear(); + partitionsField.shouldBe(enabled).clear(); partitionsField.sendKeys(String.valueOf(partitions)); return this; } @Step public TopicCreateEditForm setTimeToRetainDataByButtons(TimeToRetain timeToRetain) { - $x(String.format(btnTimeToRetainLocator, timeToRetain.getButton())).shouldBe(Condition.enabled).click(); + $x(String.format(btnTimeToRetainLocator, timeToRetain.getButton())).shouldBe(enabled).click(); return this; } diff --git a/e2e-tests/src/main/java/io/kafbat/ui/utilities/WebUtil.java b/e2e-tests/src/main/java/io/kafbat/ui/utilities/WebUtil.java index 5c7ab20d6..55921f40d 100644 --- a/e2e-tests/src/main/java/io/kafbat/ui/utilities/WebUtil.java +++ b/e2e-tests/src/main/java/io/kafbat/ui/utilities/WebUtil.java @@ -1,5 +1,6 @@ package io.kafbat.ui.utilities; +import static com.codeborne.selenide.Condition.enabled; import static io.kafbat.ui.variables.Common.LOG_RESULT; import com.codeborne.selenide.Condition; @@ -51,8 +52,13 @@ public static void clickByJavaScript(SelenideElement element) { public static void clearByKeyboard(SelenideElement field) { log.debug("\nclearByKeyboard: {{}}", field.getSearchCriteria()); - field.shouldBe(Condition.enabled).sendKeys(Keys.END); - field.sendKeys(Keys.chord(Keys.CONTROL + "a"), Keys.DELETE); + field.shouldBe(enabled).sendKeys(Keys.PAGE_DOWN); + Selenide.actions() + .keyDown(Keys.SHIFT) + .sendKeys(Keys.PAGE_UP) + .keyUp(Keys.SHIFT) + .sendKeys(Keys.DELETE) + .perform(); } public static boolean isVisible(SelenideElement element, int... timeoutInSeconds) { diff --git a/e2e-tests/src/test/java/io/kafbat/ui/smokesuite/schemas/SchemasTest.java b/e2e-tests/src/test/java/io/kafbat/ui/smokesuite/schemas/SchemasTest.java index a8089aab1..2a1ab87fa 100644 --- a/e2e-tests/src/test/java/io/kafbat/ui/smokesuite/schemas/SchemasTest.java +++ b/e2e-tests/src/test/java/io/kafbat/ui/smokesuite/schemas/SchemasTest.java @@ -57,6 +57,8 @@ public void updateSchemaAvroCheck() { AVRO_SCHEMA.setValuePath( System.getProperty("user.dir") + "/src/main/resources/testdata/schemas/schema_avro_update.json"); navigateToSchemaRegistryAndOpenDetails(AVRO_SCHEMA.getName()); + int latestVersion = schemaDetails + .getLatestVersion(); schemaDetails .openEditSchema(); schemaCreateForm @@ -72,8 +74,10 @@ public void updateSchemaAvroCheck() { .clickSubmitButton(); schemaDetails .waitUntilScreenReady(); - Assert.assertEquals(schemaDetails.getCompatibility(), CompatibilityLevel.CompatibilityEnum.NONE.toString(), + softly.assertEquals(schemaDetails.getLatestVersion(), latestVersion + 1, "getLatestVersion()"); + softly.assertEquals(schemaDetails.getCompatibility(), CompatibilityLevel.CompatibilityEnum.NONE.toString(), "getCompatibility()"); + softly.assertAll(); } @Test(priority = 3) @@ -90,7 +94,9 @@ public void compareVersionsCheck() { .getVersionsNumberFromList(); Assert.assertEquals(versionsNumberFromDdl, latestVersion, "Versions number is not matched"); schemaCreateForm - .selectVersionFromDropDown(latestVersion); + .selectVersionFromDropDown(latestVersion) + .openRightVersionDdl() + .selectVersionFromDropDown(latestVersion - 1); Assert.assertEquals(schemaCreateForm.getMarkedLinesNumber(), 42, "getMarkedLinesNumber()"); } diff --git a/frontend/src/components/App.styled.ts b/frontend/src/components/App.styled.ts index 418e5d8a1..e2f1614a1 100644 --- a/frontend/src/components/App.styled.ts +++ b/frontend/src/components/App.styled.ts @@ -1,8 +1,6 @@ import styled from 'styled-components'; export const Layout = styled.div` - min-width: 1200px; - @media screen and (max-width: 1023px) { min-width: initial; } diff --git a/frontend/src/components/Nav/ClusterMenu.tsx b/frontend/src/components/Nav/ClusterMenu.tsx deleted file mode 100644 index 54f4a8f5f..000000000 --- a/frontend/src/components/Nav/ClusterMenu.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { Cluster, ClusterFeaturesEnum } from 'generated-sources'; -import { - clusterBrokersPath, - clusterTopicsPath, - clusterConsumerGroupsPath, - clusterSchemasPath, - clusterConnectorsPath, - clusterKsqlDbPath, - clusterACLPath, -} from 'lib/paths'; - -import ClusterMenuItem from './ClusterMenuItem'; -import ClusterTab from './ClusterTab/ClusterTab'; -import * as S from './Nav.styled'; - -interface Props { - cluster: Cluster; - singleMode?: boolean; -} - -const ClusterMenu: React.FC = ({ - cluster: { name, status, features }, - singleMode, -}) => { - const hasFeatureConfigured = (key: ClusterFeaturesEnum) => - features?.includes(key); - const [isOpen, setIsOpen] = React.useState(!!singleMode); - return ( - -
- setIsOpen((prev) => !prev)} - /> - {isOpen && ( - - - - - {hasFeatureConfigured(ClusterFeaturesEnum.SCHEMA_REGISTRY) && ( - - )} - {hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_CONNECT) && ( - - )} - {hasFeatureConfigured(ClusterFeaturesEnum.KSQL_DB) && ( - - )} - {(hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_VIEW) || - hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_EDIT)) && ( - - )} - - )} -
- ); -}; - -export default ClusterMenu; diff --git a/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx b/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx new file mode 100644 index 000000000..6618dd5b1 --- /dev/null +++ b/frontend/src/components/Nav/ClusterMenu/ClusterMenu.tsx @@ -0,0 +1,105 @@ +import React, { type FC, useState } from 'react'; +import { Cluster, ClusterFeaturesEnum } from 'generated-sources'; +import * as S from 'components/Nav/Nav.styled'; +import MenuTab from 'components/Nav/Menu/MenuTab'; +import MenuItem from 'components/Nav/Menu/MenuItem'; +import { + clusterACLPath, + clusterAclRelativePath, + clusterBrokerRelativePath, + clusterBrokersPath, + clusterConnectorsPath, + clusterConnectorsRelativePath, + clusterConsumerGroupsPath, + clusterConsumerGroupsRelativePath, + clusterKsqlDbPath, + clusterKsqlDbRelativePath, + clusterSchemasPath, + clusterSchemasRelativePath, + clusterTopicsPath, + clusterTopicsRelativePath, +} from 'lib/paths'; +import { useLocation } from 'react-router-dom'; + +interface ClusterMenuProps { + name: Cluster['name']; + status: Cluster['status']; + features: Cluster['features']; + singleMode?: boolean; +} + +const ClusterMenu: FC = ({ + name, + status, + features, + singleMode, +}) => { + const hasFeatureConfigured = (key: ClusterFeaturesEnum) => + features?.includes(key); + const [isOpen, setIsOpen] = useState(!!singleMode); + const location = useLocation(); + + const getIsMenuItemActive = (path: string) => + location.pathname.includes(path); + + return ( +
    + setIsOpen((prev) => !prev)} + /> + {isOpen && ( + + + + + {hasFeatureConfigured(ClusterFeaturesEnum.SCHEMA_REGISTRY) && ( + + )} + {hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_CONNECT) && ( + + )} + {hasFeatureConfigured(ClusterFeaturesEnum.KSQL_DB) && ( + + )} + {(hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_VIEW) || + hasFeatureConfigured(ClusterFeaturesEnum.KAFKA_ACL_EDIT)) && ( + + )} + + )} +
+ ); +}; + +export default ClusterMenu; diff --git a/frontend/src/components/Nav/__tests__/ClusterMenu.spec.tsx b/frontend/src/components/Nav/ClusterMenu/__tests__/ClusterMenu.spec.tsx similarity index 93% rename from frontend/src/components/Nav/__tests__/ClusterMenu.spec.tsx rename to frontend/src/components/Nav/ClusterMenu/__tests__/ClusterMenu.spec.tsx index 22bc1eabf..ce0bb9731 100644 --- a/frontend/src/components/Nav/__tests__/ClusterMenu.spec.tsx +++ b/frontend/src/components/Nav/ClusterMenu/__tests__/ClusterMenu.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { Cluster, ClusterFeaturesEnum } from 'generated-sources'; -import ClusterMenu from 'components/Nav/ClusterMenu'; +import ClusterMenu from 'components/Nav/ClusterMenu/ClusterMenu'; import userEvent from '@testing-library/user-event'; import { clusterConnectorsPath } from 'lib/paths'; import { render } from 'lib/testHelpers'; @@ -9,7 +9,12 @@ import { onlineClusterPayload } from 'lib/fixtures/clusters'; describe('ClusterMenu', () => { const setupComponent = (cluster: Cluster, singleMode?: boolean) => ( - + ); const getMenuItems = () => screen.getAllByRole('menuitem'); const getMenuItem = () => screen.getByRole('menuitem'); diff --git a/frontend/src/components/Nav/ClusterMenuItem.tsx b/frontend/src/components/Nav/ClusterMenuItem.tsx deleted file mode 100644 index cf254d63c..000000000 --- a/frontend/src/components/Nav/ClusterMenuItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import * as S from './Nav.styled'; - -export interface ClusterMenuItemProps { - to: string; - title?: string; - isTopLevel?: boolean; -} - -const ClusterMenuItem: React.FC> = ( - props -) => { - const { to, title, children, isTopLevel } = props; - - if (to) { - return ( - - - {title} - - {children} - - ); - } - - return ; -}; - -export default ClusterMenuItem; diff --git a/frontend/src/components/Nav/ClusterTab/ClusterTab.styled.ts b/frontend/src/components/Nav/ClusterTab/ClusterTab.styled.ts deleted file mode 100644 index 5ef246788..000000000 --- a/frontend/src/components/Nav/ClusterTab/ClusterTab.styled.ts +++ /dev/null @@ -1,87 +0,0 @@ -import styled, { css } from 'styled-components'; -import { ServerStatus } from 'generated-sources'; - -export const Wrapper = styled.li.attrs({ role: 'menuitem' })<{ - isOpen: boolean; -}>( - ({ theme, isOpen }) => css` - font-size: 14px; - font-weight: 500; - user-select: none; - - display: grid; - grid-template-columns: min-content min-content auto min-content; - grid-template-areas: 'title status . chevron'; - gap: 0 5px; - - padding: 0.5em 0.75em; - cursor: pointer; - text-decoration: none; - margin: 0; - line-height: 20px; - align-items: center; - color: ${isOpen ? theme.menu.color.isOpen : theme.menu.color.normal}; - background-color: ${theme.menu.backgroundColor.normal}; - - &:hover { - background-color: ${theme.menu.backgroundColor.hover}; - color: ${theme.menu.color.hover}; - } - ` -); - -export const Title = styled.div` - grid-area: title; - white-space: nowrap; - max-width: 110px; - overflow: hidden; - text-overflow: ellipsis; - color: ${({ theme }) => theme.menu.titleColor}; -`; - -export const StatusIconWrapper = styled.svg.attrs({ - viewBox: '0 0 4 4', - xmlns: 'http://www.w3.org/2000/svg', -})` - grid-area: status; - fill: none; - width: 4px; - height: 4px; -`; - -export const StatusIcon = styled.circle.attrs({ - cx: 2, - cy: 2, - r: 2, - role: 'status-circle', -})<{ status: ServerStatus }>(({ theme, status }) => { - const statusColor: { - [k in ServerStatus]: string; - } = { - [ServerStatus.ONLINE]: theme.menu.statusIconColor.online, - [ServerStatus.OFFLINE]: theme.menu.statusIconColor.offline, - [ServerStatus.INITIALIZING]: theme.menu.statusIconColor.initializing, - }; - - return css` - fill: ${statusColor[status]}; - `; -}); - -export const ChevronWrapper = styled.svg.attrs({ - viewBox: '0 0 10 6', - xmlns: 'http://www.w3.org/2000/svg', -})` - grid-area: chevron; - width: 10px; - height: 6px; - fill: none; -`; - -type ChevronIconProps = { $open: boolean }; - -export const ChevronIcon = styled.path.attrs(({ $open }) => ({ - d: $open ? 'M8.99988 5L4.99988 1L0.999878 5' : 'M1 1L5 5L9 1', -}))` - stroke: ${({ theme }) => theme.menu.chevronIconColor}; -`; diff --git a/frontend/src/components/Nav/ClusterTab/ClusterTab.tsx b/frontend/src/components/Nav/ClusterTab/ClusterTab.tsx deleted file mode 100644 index 2e6309d4e..000000000 --- a/frontend/src/components/Nav/ClusterTab/ClusterTab.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { ServerStatus } from 'generated-sources'; - -import * as S from './ClusterTab.styled'; - -export interface ClusterTabProps { - title?: string; - status: ServerStatus; - isOpen: boolean; - toggleClusterMenu: () => void; -} - -const ClusterTab: React.FC = ({ - status, - title, - isOpen, - toggleClusterMenu, -}) => ( - - {title} - - - - {status} - - - - - - - -); - -export default ClusterTab; diff --git a/frontend/src/components/Nav/Menu/MenuItem.tsx b/frontend/src/components/Nav/Menu/MenuItem.tsx new file mode 100644 index 000000000..87c70c78c --- /dev/null +++ b/frontend/src/components/Nav/Menu/MenuItem.tsx @@ -0,0 +1,26 @@ +import React, { type FC } from 'react'; +import { NavLink } from 'react-router-dom'; + +import * as S from './styled'; + +export interface MenuItemProps { + to: string; + title: string; + variant?: 'primary' | 'secondary'; + isActive?: boolean; +} + +const MenuItem: FC = ({ + title, + to, + isActive, + variant = 'secondary', +}) => ( + + + {title} + + +); + +export default MenuItem; diff --git a/frontend/src/components/Nav/Menu/MenuTab.tsx b/frontend/src/components/Nav/Menu/MenuTab.tsx new file mode 100644 index 000000000..9214c3b00 --- /dev/null +++ b/frontend/src/components/Nav/Menu/MenuTab.tsx @@ -0,0 +1,36 @@ +import React, { type FC } from 'react'; +import { ServerStatus } from 'generated-sources'; + +import * as S from './styled'; + +export interface MenuTabProps { + title: string; + status: ServerStatus; + isOpen: boolean; + toggleClusterMenu: () => void; +} + +const MenuTab: FC = ({ + title, + toggleClusterMenu, + status, + isOpen, +}) => ( + + + + + {status} + + + + {title} + + + + + + +); + +export default MenuTab; diff --git a/frontend/src/components/Nav/Menu/__tests__/MenuItem.spec.tsx b/frontend/src/components/Nav/Menu/__tests__/MenuItem.spec.tsx new file mode 100644 index 000000000..7a23db7b7 --- /dev/null +++ b/frontend/src/components/Nav/Menu/__tests__/MenuItem.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import MenuItem, { MenuItemProps } from 'components/Nav/Menu/MenuItem'; +import { screen } from '@testing-library/react'; +import { render } from 'lib/testHelpers'; + +describe('MenuItem', () => { + const setupComponent = (props: Partial = {}) => ( +
    + +
+ ); + + const getMenuItem = () => screen.getByRole('menuitem'); + const getLink = () => screen.queryByRole('link'); + + it('renders component with correct title', () => { + const testTitle = 'My Test Title'; + render(setupComponent({ title: testTitle })); + expect(screen.getByText(testTitle)).toBeInTheDocument(); + }); + + it('renders primary variant component with correct styles', () => { + render(setupComponent({ variant: 'primary' })); + expect(getMenuItem()).toHaveStyle({ fontWeight: '500' }); + }); + + it('renders secondary variant component with correct styles', () => { + render(setupComponent({ variant: 'secondary' })); + expect(getMenuItem()).toHaveStyle({ fontWeight: '400' }); + }); + + it('renders list item with link inside', () => { + render(setupComponent({ to: '/my-cluster' })); + expect(getMenuItem()).toBeInTheDocument(); + expect(getLink()).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Nav/ClusterTab/__tests__/ClusterTab.spec.tsx b/frontend/src/components/Nav/Menu/__tests__/MenuTab.spec.tsx similarity index 85% rename from frontend/src/components/Nav/ClusterTab/__tests__/ClusterTab.spec.tsx rename to frontend/src/components/Nav/Menu/__tests__/MenuTab.spec.tsx index 5ff3f137a..50b4ba88e 100644 --- a/frontend/src/components/Nav/ClusterTab/__tests__/ClusterTab.spec.tsx +++ b/frontend/src/components/Nav/Menu/__tests__/MenuTab.spec.tsx @@ -1,8 +1,6 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import ClusterTab, { - ClusterTabProps, -} from 'components/Nav/ClusterTab/ClusterTab'; +import MenuTab, { MenuTabProps } from 'components/Nav/Menu/MenuTab'; import { ServerStatus } from 'generated-sources'; import React from 'react'; import { render } from 'lib/testHelpers'; @@ -10,9 +8,9 @@ import { render } from 'lib/testHelpers'; const testClusterName = 'My-Huge-Cluster'; const toggleClusterMenuMock = jest.fn(); -describe('ClusterTab component', () => { - const setupWrapper = (props?: Partial) => ( - { + const setupWrapper = (props?: Partial) => ( + { const getMenuItem = () => screen.getByRole('menuitem'); - describe('Wrapper Component', () => { + describe('MenuItem Component', () => { it('should check the rendering and correct Styling when it is open', () => { - render(); - expect(getMenuItem()).toHaveStyle(`color:${theme.menu.color.isOpen}`); + render(); + expect(getMenuItem()).toHaveStyle( + `color:${theme.menu.primary.color.active}` + ); }); it('should check the rendering and correct Styling when it is Not open', () => { - render(); - expect(getMenuItem()).toHaveStyle(`color:${theme.menu.color.normal}`); + render(); + expect(getMenuItem()).toHaveStyle( + `color:${theme.menu.primary.color.normal}` + ); }); }); @@ -28,7 +32,7 @@ describe('Cluster Styled Components', () => { ); expect(getStatusCircle()).toHaveStyle( - `fill:${theme.menu.statusIconColor.online}` + `fill:${theme.menu.primary.statusIconColor.online}` ); }); @@ -39,7 +43,7 @@ describe('Cluster Styled Components', () => { ); expect(getStatusCircle()).toHaveStyle( - `fill:${theme.menu.statusIconColor.offline}` + `fill:${theme.menu.primary.statusIconColor.offline}` ); }); @@ -50,7 +54,7 @@ describe('Cluster Styled Components', () => { ); expect(getStatusCircle()).toHaveStyle( - `fill:${theme.menu.statusIconColor.initializing}` + `fill:${theme.menu.primary.statusIconColor.initializing}` ); }); }); diff --git a/frontend/src/components/Nav/Menu/styled.ts b/frontend/src/components/Nav/Menu/styled.ts new file mode 100644 index 000000000..930d93a85 --- /dev/null +++ b/frontend/src/components/Nav/Menu/styled.ts @@ -0,0 +1,98 @@ +import styled, { css } from 'styled-components'; +import { ServerStatus } from 'generated-sources'; + +export const MenuItem = styled('li').attrs({ role: 'menuitem' })<{ + $variant: 'primary' | 'secondary'; + $isActive?: boolean; +}>( + ({ theme, $variant, $isActive }) => css` + font-size: 14px; + font-weight: ${theme.menu[$isActive ? 'primary' : $variant].fontWeight}; + height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; + width: 100%; + padding: 6px 8px; + cursor: pointer; + text-decoration: none; + border-radius: 8px; + background-color: ${$isActive + ? theme.menu[$variant].backgroundColor.active + : theme.menu[$variant].backgroundColor.normal}; + color: ${$isActive + ? theme.menu[$variant].color.active + : theme.menu[$variant].color.normal}; + + &:hover { + background-color: ${theme.menu[$variant].backgroundColor.hover}; + color: ${theme.menu[$variant].color.hover}; + } + + &:active { + background-color: ${theme.menu[$variant].backgroundColor.active}; + color: ${theme.menu[$variant].color.active}; + } + ` +); + +export const ContentWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 4px; +`; + +export const Title = styled.div` + white-space: nowrap; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const StatusIconWrapper = styled.svg.attrs({ + viewBox: '0 0 6 6', + xmlns: 'http://www.w3.org/2000/svg', +})` + fill: none; + width: 6px; + height: 6px; +`; + +export const StatusIcon = styled.circle.attrs({ + cx: 3, + cy: 3, + r: 3, + role: 'status-circle', +})<{ status: ServerStatus }>(({ theme, status }) => { + const statusColor: { + [k in ServerStatus]: string; + } = { + [ServerStatus.ONLINE]: theme.menu.primary.statusIconColor.online, + [ServerStatus.OFFLINE]: theme.menu.primary.statusIconColor.offline, + [ServerStatus.INITIALIZING]: + theme.menu.primary.statusIconColor.initializing, + }; + + return css` + fill: ${statusColor[status]}; + `; +}); + +export const ChevronWrapper = styled.svg.attrs({ + viewBox: '0 0 10 6', + xmlns: 'http://www.w3.org/2000/svg', +})` + width: 10px; + height: 6px; + fill: none; +`; + +type ChevronIconProps = { $isOpen: boolean }; +export const ChevronIcon = styled.path.attrs( + ({ $isOpen }) => ({ + d: $isOpen ? 'M8.99988 5L4.99988 1L0.999878 5' : 'M1 1L5 5L9 1', + }) +)` + stroke: ${({ theme }) => theme.menu.primary.chevronIconColor}; +`; diff --git a/frontend/src/components/Nav/Nav.styled.ts b/frontend/src/components/Nav/Nav.styled.ts index 36e9f6454..4f6bde408 100644 --- a/frontend/src/components/Nav/Nav.styled.ts +++ b/frontend/src/components/Nav/Nav.styled.ts @@ -1,41 +1,13 @@ -import { NavLink } from 'react-router-dom'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; export const List = styled.ul.attrs({ role: 'menu' })` - padding-bottom: 4px; + padding: 2px 4px 6px 4px; & > & { - padding-left: 8px; + padding: 0 0 0 8px; } -`; - -export const Link = styled(NavLink)( - ({ theme }) => css` - width: 100%; - padding: 0.5em 0.75em; - cursor: pointer; - text-decoration: none; - margin: 0 0; - background-color: ${theme.menu.backgroundColor.normal}; - color: ${theme.menu.color.normal}; - &:hover { - background-color: ${theme.menu.backgroundColor.hover}; - color: ${theme.menu.color.hover}; - } - &.active { - background-color: ${theme.menu.backgroundColor.active}; - color: ${theme.menu.color.active}; - } - ` -); - -export const ListItem = styled('li').attrs({ role: 'menuitem' })<{ - $isTopLevel?: boolean; -}>` - font-size: 14px; - font-weight: ${({ $isTopLevel }) => ($isTopLevel ? 500 : 'normal')}; - height: 32px; - display: flex; - user-select: none; + & * { + margin-bottom: 2px; + } `; diff --git a/frontend/src/components/Nav/Nav.tsx b/frontend/src/components/Nav/Nav.tsx index 45c755d32..2741b4351 100644 --- a/frontend/src/components/Nav/Nav.tsx +++ b/frontend/src/components/Nav/Nav.tsx @@ -1,23 +1,25 @@ import { useClusters } from 'lib/hooks/api/clusters'; -import React from 'react'; +import React, { type FC } from 'react'; -import ClusterMenu from './ClusterMenu'; -import ClusterMenuItem from './ClusterMenuItem'; import * as S from './Nav.styled'; +import MenuItem from './Menu/MenuItem'; +import ClusterMenu from './ClusterMenu/ClusterMenu'; -const Nav: React.FC = () => { +const Nav: FC = () => { const clusters = useClusters(); return (