From 1b5f827fed6aa3b3c5d99cda11c959c23bcf88b1 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 19 Jun 2026 12:34:33 +0530 Subject: [PATCH 01/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../sidebar-icons/icon-business-metadata.svg | 3 + .../sidebar-icons/icon-classifications.svg | 3 + .../img/sidebar-icons/icon-custom-filters.svg | 3 + .../img/sidebar-icons/icon-entities.svg | 3 + .../img/sidebar-icons/icon-glossary.svg | 3 + .../public/img/sidebar-icons/icon-search.svg | 3 + .../src/components/EntityDisplayImage.tsx | 77 +- .../components/GlobalSearch/QuickSearch.tsx | 19 +- dashboard/src/components/TreeNodeIcons.tsx | 5 +- .../src/components/TreeSkeletonLoader.tsx | 46 + .../__tests__/EntityDisplayImage.test.tsx | 339 +-- dashboard/src/models/treeStructureType.ts | 1 + dashboard/src/styles/sidebar.scss | 36 +- dashboard/src/views/DashBoard.tsx | 7 +- .../DashboardOverview/DashboardOverview.tsx | 7 +- dashboard/src/views/Layout/Layout.tsx | 1 - dashboard/src/views/SideBar/SideBarBody.tsx | 621 ++++-- .../SideBarTree/BusinessMetadataTree.tsx | 1 + .../SideBarTree/ClassificationTree.tsx | 1 + .../SideBar/SideBarTree/CustomFiltersTree.tsx | 3 +- .../SideBar/SideBarTree/EntitiesTree.tsx | 3 +- .../SideBar/SideBarTree/GlossaryTree.tsx | 3 +- .../SideBar/SideBarTree/RelationShipsTree.tsx | 1 + .../views/SideBar/SideBarTree/SideBarTree.tsx | 1962 +++++++++-------- .../__tests__/SideBarTree.test.tsx | 135 +- .../SideBar/__tests__/SideBarBody.test.tsx | 11 +- 26 files changed, 1749 insertions(+), 1548 deletions(-) create mode 100644 dashboard/public/img/sidebar-icons/icon-business-metadata.svg create mode 100644 dashboard/public/img/sidebar-icons/icon-classifications.svg create mode 100644 dashboard/public/img/sidebar-icons/icon-custom-filters.svg create mode 100644 dashboard/public/img/sidebar-icons/icon-entities.svg create mode 100644 dashboard/public/img/sidebar-icons/icon-glossary.svg create mode 100644 dashboard/public/img/sidebar-icons/icon-search.svg create mode 100644 dashboard/src/components/TreeSkeletonLoader.tsx diff --git a/dashboard/public/img/sidebar-icons/icon-business-metadata.svg b/dashboard/public/img/sidebar-icons/icon-business-metadata.svg new file mode 100644 index 00000000000..7eb1bad564a --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-business-metadata.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/img/sidebar-icons/icon-classifications.svg b/dashboard/public/img/sidebar-icons/icon-classifications.svg new file mode 100644 index 00000000000..f193f878ef6 --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-classifications.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/img/sidebar-icons/icon-custom-filters.svg b/dashboard/public/img/sidebar-icons/icon-custom-filters.svg new file mode 100644 index 00000000000..a34dae1ce7b --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-custom-filters.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/img/sidebar-icons/icon-entities.svg b/dashboard/public/img/sidebar-icons/icon-entities.svg new file mode 100644 index 00000000000..86f5adce5c4 --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-entities.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/img/sidebar-icons/icon-glossary.svg b/dashboard/public/img/sidebar-icons/icon-glossary.svg new file mode 100644 index 00000000000..701ee70b964 --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-glossary.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/img/sidebar-icons/icon-search.svg b/dashboard/public/img/sidebar-icons/icon-search.svg new file mode 100644 index 00000000000..5904b7e6a31 --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-search.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index a4d67c7e52e..91e83398e54 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -15,10 +15,8 @@ * limitations under the License. */ -import { useEffect, useState } from "react"; -import { Avatar, Skeleton } from "@mui/material"; +import { Avatar } from "@mui/material"; import { getEntityIconPath } from "../utils/Utils"; -import axios from "axios"; const DisplayImage = ({ entity, @@ -27,77 +25,40 @@ const DisplayImage = ({ avatarDisplay, isProcess }: any) => { - const [imageUrl, setImageUrl] = useState(null); - const [checkEntityImage, setCheckEntityImage] = useState({ - [entity.guid]: false - }); + const entityData = { ...entity, isProcess: isProcess }; + + const primaryUrl = getEntityIconPath({ entityData }) || ""; + const fallbackUrl = getEntityIconPath({ entityData, errorUrl: primaryUrl }) || ""; - useEffect(() => { - const fetchImagePath = async () => { - let entityData = { ...entity, ...{ isProcess: isProcess } }; - let imagePath: any = getEntityIconPath({ entityData: entityData }); - try { - const response = await axios.get(imagePath, { - responseType: "blob" - }); - const contentType: any = response.headers["content-type"]; + const handleError = (e: React.SyntheticEvent) => { + const target = e.currentTarget; + if (target.src !== fallbackUrl) { + target.onerror = null; + target.src = fallbackUrl; + } + }; - if (contentType && contentType.startsWith("image/")) { - let cache = { [entityData.guid]: imagePath }; - setCheckEntityImage(cache); - setImageUrl(getEntityIconPath({ entityData: entityData })); - } else { - setImageUrl( - getEntityIconPath({ entityData: entityData, errorUrl: imagePath }) - ); - } - } catch (_error) { - setImageUrl( - getEntityIconPath({ entityData: entityData, errorUrl: imagePath }) - ); - } - }; - - fetchImagePath(); - }, []); - - return imageUrl != undefined ? ( + return (
- {checkEntityImage[entity.guid] !== false ? ( - avatarDisplay == undefined ? ( - Entity Icon - ) : ( - - ) - ) : avatarDisplay == undefined ? ( + {avatarDisplay == undefined ? ( Entity Icon ) : ( )}
- ) : ( -
{}
); }; diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx index f3f860f7881..97d6253228b 100644 --- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx +++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx @@ -400,6 +400,7 @@ const QuickSearch = () => { onChange={handleScopeChange} aria-label="Search scope" displayEmpty + sx={{ height: "32px", boxSizing: "border-box" }} renderValue={(v) => SCOPE_LABELS[v as QuickSearchScope]} > Select All @@ -645,11 +646,13 @@ const QuickSearch = () => { }} className="text-black-default" InputProps={{ - style: { - padding: "1px 10px", + sx: { + height: "32px", + padding: "0 10px !important", borderRadius: "4px", color: "#1a1a1a", - backgroundColor: "white" + backgroundColor: "white", + boxSizing: "border-box" }, ...params.InputProps, type: "search", @@ -686,7 +689,11 @@ const QuickSearch = () => { backgroundColor: "#4a90e2 !important", color: "#fff !important", textTransform: "none", - fontWeight: 600 + fontWeight: 600, + height: "32px !important", + minHeight: "32px !important", + maxHeight: "32px !important", + boxSizing: "border-box" }} onClick={handleSubmitSearch} aria-label="Run search" @@ -701,6 +708,10 @@ const QuickSearch = () => { backgroundColor: "white !important", color: "#4a90e2 !important", borderColor: "#dddddd !important", + height: "32px !important", + minHeight: "32px !important", + maxHeight: "32px !important", + boxSizing: "border-box", "&:hover": { backgroundColor: "rgba(74, 144, 226, 0.08) !important", color: "#4a90e2 !important" diff --git a/dashboard/src/components/TreeNodeIcons.tsx b/dashboard/src/components/TreeNodeIcons.tsx index 5403a2a9d63..3a613d3b2dd 100644 --- a/dashboard/src/components/TreeNodeIcons.tsx +++ b/dashboard/src/components/TreeNodeIcons.tsx @@ -55,8 +55,9 @@ const TreeNodeIcons = (props: { treeName: string; updatedData: any; isEmptyServicetype: boolean | undefined; + isHovered?: boolean; }) => { - const { node, treeName, updatedData, isEmptyServicetype } = props; + const { node, treeName, updatedData, isEmptyServicetype, isHovered } = props; const navigate = useNavigate(); const toastId: any = useRef(null); const [expandNode, setExpandNode] = useState(null); @@ -189,6 +190,7 @@ const TreeNodeIcons = (props: { size="small" className="tree-item-more-label" data-cy="dropdownMenuButton" + style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > @@ -209,6 +211,7 @@ const TreeNodeIcons = (props: { className="tree-item-more-label" size="small" data-cy="dropdownMenuButton" + style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > diff --git a/dashboard/src/components/TreeSkeletonLoader.tsx b/dashboard/src/components/TreeSkeletonLoader.tsx new file mode 100644 index 00000000000..4b206b5e71b --- /dev/null +++ b/dashboard/src/components/TreeSkeletonLoader.tsx @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Stack } from "@mui/material"; +import SkeletonLoader from "./SkeletonLoader"; + +const TreeSkeletonLoader = ({ count = 7 }: { count?: number }) => { + const treeItemSkeleton = (indentLevel: number, textWidth: string, key: number) => ( + + + + + ); + + const allRows = [ + treeItemSkeleton(0, "80%", 0), + treeItemSkeleton(1, "65%", 1), + treeItemSkeleton(1, "75%", 2), + treeItemSkeleton(2, "50%", 3), + treeItemSkeleton(2, "60%", 4), + treeItemSkeleton(0, "85%", 5), + treeItemSkeleton(1, "70%", 6) + ]; + + return ( + + {allRows.slice(0, count)} + + ); +}; + +export default TreeSkeletonLoader; diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 04dc1c5c6de..71cb7cb8c76 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -15,269 +15,102 @@ * limitations under the License. */ - -/** - * Unit tests for EntityDisplayImage component - * - * Coverage Target: 100% - * - Statements: 100% - * - Branches: 100% - * - Functions: 100% - * - Lines: 100% - */ - import React from 'react' -import { render, waitFor, act } from '@testing-library/react' +import { render, fireEvent } from '@testing-library/react' import DisplayImage from '../EntityDisplayImage' -import axios from 'axios' -// Import Utils to spy on it import * as Utils from '../../utils/Utils' const mockGetEntityIconPath = jest.fn() -// Mock the Utils module jest.mock('../../utils/Utils', () => ({ - getEntityIconPath: jest.fn() + getEntityIconPath: jest.fn() })) -const mockFetch = (contentType: string | null, shouldReject?: boolean) => { - if (shouldReject) { - jest.spyOn(axios, 'get').mockRejectedValue(new Error('fetch failed')) - return - } - jest.spyOn(axios, 'get').mockResolvedValue({ - headers: { - "content-type": contentType || '' - } - }) -} describe('EntityDisplayImage', () => { - const entity = { guid: 'entity-1' } - - beforeEach(() => { - jest.clearAllMocks() - - // Set up the mock implementation for getEntityIconPath - ;(Utils.getEntityIconPath as jest.Mock).mockImplementation(({ entityData, errorUrl }: { entityData: any, errorUrl?: string }) => { - const result = errorUrl ? `${errorUrl}-fallback` : `/icons/${entityData.guid}.png` - mockGetEntityIconPath({ entityData, errorUrl }) - return result - }) - }) - - it('renders cached image when content-type is image', async () => { - mockFetch('image/png') - - const { container } = render( - - ) - - // Wait for Skeleton to disappear and image to appear - await waitFor(() => { - const skeleton = container.querySelector('.MuiSkeleton-root') - expect(skeleton).not.toBeInTheDocument() - }, { timeout: 10000, interval: 100 }) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toBe('/icons/entity-1.png') - expect(img?.getAttribute('alt')).toBe('Entity Icon') - expect(img?.getAttribute('id')).toBe('entity-1') - expect(img?.getAttribute('data-cy')).toBe('entity-1') - }, { timeout: 10000 }) - }, 20000) - - it('renders fallback image when content-type is not image', async () => { - mockFetch('text/plain') - - const { container } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') - }, { timeout: 10000 }) - }, 20000) - - it('renders fallback image when content-type is null', async () => { - mockFetch(null) - - const { container} = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') - }, { timeout: 10000 }) - }, 20000) - - it('renders fallback image when fetch throws', async () => { - mockFetch('image/png', true) - - const { container } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') - }, { timeout: 10000 }) - }, 20000) - - it('renders Avatar when avatarDisplay is provided and image is cached', async () => { - mockFetch('image/png') - - const { container } = render( - - ) - - await waitFor(() => { - const avatar = container.querySelector('img[alt="entityImg"]') - expect(avatar).toBeTruthy() - expect(avatar?.getAttribute('src')).toBe('/icons/entity-1.png') - }, { timeout: 10000 }) - }, 20000) - - it('renders Avatar when avatarDisplay is provided and image is not cached', async () => { - mockFetch('text/plain') - - const { container } = render( - - ) - - await waitFor(() => { - const avatar = container.querySelector('img[alt="entityImg"]') - expect(avatar).toBeTruthy() - expect(avatar?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') - }, { timeout: 10000 }) - }, 20000) - - it('renders Skeleton when imageUrl is undefined', () => { - mockGetEntityIconPath.mockReturnValue(undefined) - - const { container } = render( - - ) - - const skeleton = container.querySelector('div') - expect(skeleton).toBeTruthy() - }) - - it('handles isProcess prop', async () => { - mockFetch('image/png') - - const entityWithProcess = { guid: 'entity-2', isProcess: true } - render( - - ) - - await waitFor(() => { - expect(mockGetEntityIconPath).toHaveBeenCalledWith( - expect.objectContaining({ - entityData: expect.objectContaining({ isProcess: true }) - }) - ) - }) - }, 20000) - - it('handles entity without isProcess prop but with isProcess passed', async () => { - mockFetch('image/png') - - render( - - ) - - await waitFor(() => { - expect(mockGetEntityIconPath).toHaveBeenCalledWith( - expect.objectContaining({ - entityData: expect.objectContaining({ isProcess: false }) - }) - ) - }) - }, 20000) - - it('sets checkEntityImage cache when image is valid', async () => { - mockFetch('image/jpeg') - - const { container } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toBe('/icons/entity-1.png') - }, { timeout: 10000 }) - }, 20000) - - it('handles different image content types', async () => { - const contentTypes = ['image/gif', 'image/webp', 'image/svg+xml'] - - for (const contentType of contentTypes) { - mockFetch(contentType) - const { container, unmount } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeTruthy() - }, { timeout: 10000 }) - unmount() - } - }, 30000) - - it('handles errorUrl in getEntityIconPath when fetch fails', async () => { - mockFetch('image/png', true) - ;(Utils.getEntityIconPath as jest.Mock).mockImplementation(({ entityData, errorUrl }: { entityData: any, errorUrl?: string }) => { - if (errorUrl) return `${errorUrl}-error` - return `/icons/${entityData.guid}.png` - }) - - const { container } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toContain('-error') - }, { timeout: 10000 }) - }, 20000) - - it('handles errorUrl in getEntityIconPath when content-type is not image', async () => { - mockFetch('application/json') - ;(Utils.getEntityIconPath as jest.Mock).mockImplementation(({ entityData, errorUrl }: { entityData: any, errorUrl?: string }) => { - if (errorUrl) return `${errorUrl}-error` - return `/icons/${entityData.guid}.png` - }) - - const { container } = render( - - ) - - await waitFor(() => { - const img = container.querySelector('img') - expect(img).toBeInTheDocument() - expect(img?.getAttribute('src')).toContain('-error') - }, { timeout: 10000 }) - }, 20000) + const entity = { guid: 'entity-1' } + + beforeEach(() => { + jest.clearAllMocks() + + ;(Utils.getEntityIconPath as jest.Mock).mockImplementation(({ entityData, errorUrl }: { entityData: any, errorUrl?: string }) => { + const result = errorUrl ? `${errorUrl}-fallback` : `/icons/${entityData.guid}.png` + mockGetEntityIconPath({ entityData, errorUrl }) + return result + }) + }) + + it('renders primary image instantly', () => { + const { container } = render( + + ) + + const img = container.querySelector('img') + expect(img).toBeInTheDocument() + expect(img?.getAttribute('src')).toBe('/icons/entity-1.png') + expect(img?.getAttribute('alt')).toBe('Entity Icon') + expect(img?.getAttribute('id')).toBe('entity-1') + expect(img?.getAttribute('data-cy')).toBe('entity-1') + }) + + it('switches to fallback image when native onError is triggered', () => { + const { container } = render( + + ) + + const img = container.querySelector('img') + expect(img).toBeInTheDocument() + expect(img?.getAttribute('src')).toBe('/icons/entity-1.png') + + // Trigger error natively + fireEvent.error(img!) + + expect(img?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') + }) + + it('renders Avatar when avatarDisplay is provided and handles fallback', () => { + const { container } = render( + + ) + + const avatar = container.querySelector('img[alt="entityImg"]') + expect(avatar).toBeTruthy() + expect(avatar?.getAttribute('src')).toBe('/icons/entity-1.png') + + // Trigger error natively + fireEvent.error(avatar!) + + expect(avatar?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') + }) + + it('handles isProcess prop', () => { + const entityWithProcess = { guid: 'entity-2', isProcess: true } + render( + + ) + + expect(mockGetEntityIconPath).toHaveBeenCalledWith( + expect.objectContaining({ + entityData: expect.objectContaining({ isProcess: true }) + }) + ) + }) + + it('handles entity without isProcess prop but with isProcess passed', () => { + render( + + ) + + expect(mockGetEntityIconPath).toHaveBeenCalledWith( + expect.objectContaining({ + entityData: expect.objectContaining({ isProcess: false }) + }) + ) + }) }) diff --git a/dashboard/src/models/treeStructureType.ts b/dashboard/src/models/treeStructureType.ts index 2980cb9cfcb..02d36fa7f4f 100644 --- a/dashboard/src/models/treeStructureType.ts +++ b/dashboard/src/models/treeStructureType.ts @@ -18,6 +18,7 @@ export interface Props { sideBarOpen: boolean; loading?: boolean; searchTerm: string; + isPopover?: boolean; } export interface TypeHeaderState { diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 4f6fbb583fd..811f246f719 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -49,10 +49,10 @@ flex-grow: 1; // position: fixed; top: 128px; - overflow-y: auto; // height: calc(100vh - 128px); width: inherit; } + .loader-box { min-height: 180px; flex-grow: 1; @@ -63,6 +63,7 @@ height: calc(100vh - 128px); width: 100%; } + .sidebar-treeview { position: relative; } @@ -88,13 +89,26 @@ color: v.$text-green; margin-bottom: 2px; } + .menuitem-label { color: v.$text-grey; } + .custom-treeitem-label { display: flex; align-items: center; gap: 0.5rem; + + .action-icon { + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease; + } + + &:hover .action-icon { + opacity: 1; + visibility: visible; + } } .custom-treeitem-icon { @@ -125,6 +139,7 @@ align-items: center; padding: 8px 16px 8px 8px !important; } + .modal-close-icon { position: absolute !important; right: 12px; @@ -154,7 +169,8 @@ } .tree-item-label { - width: calc(100% - 50px); + flex: 1; + min-width: 0; text-overflow: ellipsis; overflow: hidden; font-size: 14px; @@ -196,6 +212,17 @@ border-bottom: "1px solid rgba(25,255,255,0.1)"; } +.light-popover { + * { + color: #333333 !important; + } + + img, + svg { + filter: invert(1) brightness(0.2) !important; + } +} + .sidebar-searchbar { background: #f1f1f1 !important; display: flex; @@ -213,12 +240,11 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m font-size: 1.25rem !important; } -button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-more-label - svg.MuiSvgIcon-root { +button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-more-label svg.MuiSvgIcon-root { font-size: 1.25rem !important; color: white !important; } .sidebar-menu-item { padding: 4px 10px !important; -} +} \ No newline at end of file diff --git a/dashboard/src/views/DashBoard.tsx b/dashboard/src/views/DashBoard.tsx index c0b5a201e80..c7e7e239ee5 100644 --- a/dashboard/src/views/DashBoard.tsx +++ b/dashboard/src/views/DashBoard.tsx @@ -32,15 +32,16 @@ const DashBoard = () => { position="relative" height="100%" flex="1" - padding={0} - spacing={2} + paddingTop={1} + paddingBottom={0} + spacing={0} sx={{ boxSizing: "border-box", overflow: "hidden" }} > diff --git a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx index c7bfbd55115..756e9cfaa2a 100644 --- a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx +++ b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx @@ -97,11 +97,12 @@ const DashboardOverview = () => { maxWidth: "100%", boxSizing: "border-box", backgroundColor: "#f5f7f9", - padding: 3, - borderRadius: 2 + borderRadius: 2, + pb: 3, + pr: 3 }} > - + {isLoading ? : } diff --git a/dashboard/src/views/Layout/Layout.tsx b/dashboard/src/views/Layout/Layout.tsx index cd34d16e3bc..cd79874f46f 100644 --- a/dashboard/src/views/Layout/Layout.tsx +++ b/dashboard/src/views/Layout/Layout.tsx @@ -123,7 +123,6 @@ const Layout: React.FC = () => {
diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 91b5bda096f..8018d7850f4 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -25,7 +25,9 @@ import { KeyboardEvent, lazy, useRef, + useMemo, } from "react"; +import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; import atlasLogo from "/img/atlas_logo.svg"; import apacheAtlasLogo from "/img/apache-atlas-logo.svg"; import { @@ -39,10 +41,10 @@ import { import Drawer from "@mui/material/Drawer"; import CssBaseline from "@mui/material/CssBaseline"; import { IconButton } from "@components/muiComponents"; -import { useSelector } from "react-redux"; -import SearchIcon from "@mui/icons-material/Search"; -import { InputBase, Paper, Stack } from "@mui/material"; -import { TypeHeaderState } from "@models/treeStructureType.js"; + +import ClearIcon from "@mui/icons-material/Clear"; +import { getVersion } from "@api/apiMethods/headerApiMethods"; +import { InputBase, Paper, Stack, Box, Popover, Typography, Tooltip, CircularProgress } from "@mui/material"; import { globalSessionData, PathAssociateWithModule } from "@utils/Enum"; import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft"; import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; @@ -57,7 +59,7 @@ import ErrorPage from "@views/ErrorPage"; import AppRoutes from "@views/AppRoutes"; import ErrorBoundaryWithNavigate from "../../ErrorBoundary"; import useHistory from "@utils/history.js"; -import SkeletonLoader from "@components/SkeletonLoader"; +import AccountTreeIcon from "@mui/icons-material/AccountTree"; const Header = lazy(() => import("@views/Layout/Header")); @@ -102,7 +104,6 @@ const DrawerHeader = styled("div")(({ theme }) => ({ })); const SideBarBody = (props: { - loading: boolean; handleOpenModal: any; handleOpenAboutModal: any; }) => { @@ -110,17 +111,70 @@ const SideBarBody = (props: { const routes = useRoutes(AppRoutes as RouteObject[]); const history = useHistory(); const dispatch = useAppDispatch(); - const { loading: loader, handleOpenModal, handleOpenAboutModal } = props; + const { handleOpenModal, handleOpenAboutModal } = props; const navigate = useNavigate(); - const { loading } = useSelector((state: TypeHeaderState) => state.typeHeader); const { relationshipSearch = {} } = globalSessionData || {}; const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); + const [versionData, setVersionData] = useState({}); + const searchParams = new URLSearchParams(location.search); + + const isCustomFilterActive = searchParams.get("isCF") === "true"; + const isGlossaryActive = !isCustomFilterActive && (location.pathname.includes("/glossary") || !!searchParams.get("gtype") || !!searchParams.get("term") || !!searchParams.get("category")); + const isBusinessMetadataActive = !isCustomFilterActive && location.pathname.includes("/administrator/businessMetadata"); + const isClassificationActive = !isCustomFilterActive && (!!searchParams.get("tag") || location.pathname.includes("/tag/tagAttribute")); + const isRelationshipActive = !isCustomFilterActive && (!!searchParams.get("relationshipName") || location.pathname.includes("/relationshipDetailPage")); + + const isEntitiesActive = !isCustomFilterActive && (!!searchParams.get("type") || location.pathname.includes("/detailPage")); const handleDrawerOpen = () => { setOpen(!open); }; + const [popoverAnchor, setPopoverAnchor] = useState(null); + const [activePopover, setActivePopover] = useState(null); + + const handlePopoverOpen = (event: React.MouseEvent, id: string) => { + setPopoverAnchor(event.currentTarget); + setActivePopover(id); + }; + + const handlePopoverClose = () => { + setPopoverAnchor(null); + setActivePopover(null); + }; + + + + const renderPopoverSearch = () => ( +
+ + ) => setSearchTerm(e.target.value)} + endAdornment={ + + {searchTerm.length > 0 && ( + setSearchTerm("")} + edge="end" + sx={{ padding: "4px" }} + > + + + )} + Search + + } + /> + +
+ ); + const [position, setPosition] = useState(defaultDrawerWidth); const draggerRef = useRef(null); const headerRef = useRef(null); @@ -156,6 +210,17 @@ const SideBarBody = (props: { dispatch(fetchRootClassification()); dispatch(fetchEnumData()); dispatch(fetchMetricEntity()); + + // Fetch version data for footer + const fetchVersion = async () => { + try { + const resp = await getVersion(); + if (resp?.data) setVersionData(resp.data); + } catch (e) { + console.error(e); + } + }; + fetchVersion(); }, [dispatch]); const handleAtlasLogoClick = useCallback(() => { @@ -199,6 +264,73 @@ const SideBarBody = (props: { }); const matched = matchRoutes(routeConfig, location.pathname); + const isMatched = !!matched; + + const rightSideContent = useMemo(() => ( + +
+ +
+ +
+
+ {isMatched || location.pathname.includes("!") ? ( + + +
+ } + > + + {" "} + + + ) : ( + + )} +
+ + ), [isMatched, location.pathname, history, handleOpenModal, handleOpenAboutModal]); return ( - {/* Collapsed sidebar logo */} + {/* Collapsed sidebar logo and module icons */} {!open && ( -
- Apache Atlas logo -
+ role="button" + tabIndex={0} + aria-label="Atlas home — refresh dashboard" + onClick={handleAtlasLogoClick} + onKeyDown={handleAtlasLogoKeyDown} + data-cy="apache-atlas-logo-collapsed" + > + Apache Atlas logo +
+ + {/* Module Icons for Mini Drawer */} + + {/* Search */} + + + setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + search + + + + + {/* Entities */} + + + handlePopoverOpen(e, "entities")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + entities + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+ + {/* Classifications */} + + + handlePopoverOpen(e, "classification")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + classifications + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+ + {/* Glossary */} + + + handlePopoverOpen(e, "glossary")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + glossary + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+ + {/* Business Metadata */} + + + handlePopoverOpen(e, "businessMetadata")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + business metadata + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+ + {/* Relationships */} + {relationshipSearch && ( + <> + + + handlePopoverOpen(e, "relationships")} sx={{ color: isRelationshipActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+ + )} + + {/* Custom Filters */} + + + handlePopoverOpen(e, "customFilters")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + custom filters + + + + + {renderPopoverSearch()} +
+ }> +
+ +
+
+ +
+
+
+ )} {open && ( @@ -311,24 +634,36 @@ const SideBarBody = (props: { ) => { setSearchTerm(e.target.value); }} data-cy="searchNode" + endAdornment={ + + {searchTerm.length > 0 && ( + setSearchTerm("")} + edge="end" + sx={{ padding: "4px" }} + > + + + )} + Search + + } /> - - - - @@ -337,10 +672,12 @@ const SideBarBody = (props: { className="sidebar-wrapper" sx={{ flex: 1, - overflow: "hidden auto", - paddingBottom: "0px", // Account for bottom toggle button + overflowX: "hidden", + overflowY: "auto", + paddingBottom: "48px", // Added space so it doesn't touch the bottom toggle button ...(open == false && { overflow: "hidden", + display: "none", }), }} > @@ -349,20 +686,10 @@ const SideBarBody = (props: { data-cy="r_entityTreeRender" > - // - // - } + fallback={} > @@ -373,20 +700,10 @@ const SideBarBody = (props: { data-cy="r_classificationTreeRender" > - // - // - } + fallback={} > @@ -394,44 +711,26 @@ const SideBarBody = (props: {
- // - // - } + fallback={} > - +
- // - // - } + fallback={} > - +
{relationshipSearch && ( @@ -440,16 +739,7 @@ const SideBarBody = (props: { data-cy="r_relationshipTreeRender" > - // - // - } + fallback={} > - // - // - } + fallback={} > @@ -482,15 +763,27 @@ const SideBarBody = (props: {
+ {open && ( + + + V {versionData?.Version || '3.12.1.0'} + + + )} + handleDrawerOpen()}> {open ? ( - -
- -
- -
-
- {matched || location.pathname.includes("!") ? ( - - - {/* */} -
- } - > - - {" "} - - - ) : ( - - )} -
- + {rightSideContent} ); diff --git a/dashboard/src/views/SideBar/SideBarTree/BusinessMetadataTree.tsx b/dashboard/src/views/SideBar/SideBarTree/BusinessMetadataTree.tsx index c7fdb84bfe0..f737289b8e8 100644 --- a/dashboard/src/views/SideBar/SideBarTree/BusinessMetadataTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/BusinessMetadataTree.tsx @@ -66,6 +66,7 @@ const BusinessMetadataTree = (props: Props) => { sideBarOpen={sideBarOpen} loader={loading} searchTerm={searchTerm} + isPopover={props.isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/ClassificationTree.tsx b/dashboard/src/views/SideBar/SideBarTree/ClassificationTree.tsx index db8e66b087b..c01798ef8cf 100644 --- a/dashboard/src/views/SideBar/SideBarTree/ClassificationTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/ClassificationTree.tsx @@ -254,6 +254,7 @@ const ClassificationTree = (props: Props) => { sideBarOpen={sideBarOpen} loader={loadingClassification} searchTerm={searchTerm} + isPopover={props.isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx b/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx index 72c6d3a0eff..f50a73c1aa8 100644 --- a/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx @@ -38,7 +38,7 @@ import { import { fetchSavedSearchData } from "@redux/slice/savedSearchSlice.ts"; import { globalSessionData } from "@utils/Enum.ts"; -const CustomFiltersTree = ({ sideBarOpen, searchTerm }: Props) => { +const CustomFiltersTree = ({ sideBarOpen, searchTerm, isPopover }: Props) => { const dispatch = useAppDispatch(); const { savedSearchData }: any = useAppSelector( (state: any) => state.savedSearch @@ -174,6 +174,7 @@ const CustomFiltersTree = ({ sideBarOpen, searchTerm }: Props) => { sideBarOpen={sideBarOpen} loader={customFilterLoader} searchTerm={searchTerm} + isPopover={isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx b/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx index 74ffd544804..a18f09817b3 100644 --- a/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/EntitiesTree.tsx @@ -34,7 +34,7 @@ import { fetchEntityData } from "@redux/slice/typeDefSlices/typedefEntitySlice.t import { fetchTypeHeaderData } from "@redux/slice/typeDefSlices/typeDefHeaderSlice.ts"; import { fetchMetricEntity } from "@redux/slice/metricsSlice.ts"; -const EntitiesTree = ({ sideBarOpen, searchTerm }: Props) => { +const EntitiesTree = ({ sideBarOpen, searchTerm, isPopover }: Props) => { const dispatch = useAppDispatch(); const { typeHeaderData, loading }: TypedefHeaderDataType = useAppSelector( (state: any) => state.typeHeader @@ -267,6 +267,7 @@ const EntitiesTree = ({ sideBarOpen, searchTerm }: Props) => { sideBarOpen={sideBarOpen} loader={loading} searchTerm={searchTerm} + isPopover={isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/GlossaryTree.tsx b/dashboard/src/views/SideBar/SideBarTree/GlossaryTree.tsx index bfa7fac66cd..543d39384ae 100644 --- a/dashboard/src/views/SideBar/SideBarTree/GlossaryTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/GlossaryTree.tsx @@ -37,7 +37,7 @@ import { } from "@models/glossaryTreeType.ts"; import { fetchGlossaryData } from "@redux/slice/glossarySlice.ts"; -const GlossaryTree = ({ sideBarOpen, searchTerm }: Props) => { +const GlossaryTree = ({ sideBarOpen, searchTerm, isPopover }: Props) => { const dispatch = useAppDispatch(); const { glossaryData, loading }: any = useAppSelector( (state: any) => state.glossary @@ -209,6 +209,7 @@ const GlossaryTree = ({ sideBarOpen, searchTerm }: Props) => { sideBarOpen={sideBarOpen} loader={loading} searchTerm={searchTerm} + isPopover={isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/RelationShipsTree.tsx b/dashboard/src/views/SideBar/SideBarTree/RelationShipsTree.tsx index b1036d46a7b..de63259ec04 100644 --- a/dashboard/src/views/SideBar/SideBarTree/RelationShipsTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/RelationShipsTree.tsx @@ -63,6 +63,7 @@ const RelationshipsTree = (props: Props) => { sideBarOpen={sideBarOpen} loader={loading} searchTerm={searchTerm} + isPopover={props.isPopover} /> ); }; diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index c031e1e587b..ebc8e21574e 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -69,7 +69,7 @@ import { toast } from "react-toastify"; import { EnumTypeDefData, TreeNode } from "@models/treeStructureType"; import ImportDialog from "@components/ImportDialog"; import TreeIcons from "@components/Treeicons"; -import { useAppDispatch, useAppSelector } from "@hooks/reducerHook"; +import { useAppSelector, useAppDispatch } from "@hooks/reducerHook"; import { fetchGlossaryData } from "@redux/slice/glossarySlice"; import TreeNodeIcons from "@components/TreeNodeIcons"; import ClassificationForm from "@views/Classification/ClassificationForm"; @@ -77,24 +77,40 @@ import AddUpdateGlossaryForm from "@views/Glossary/AddUpdateGlossaryForm"; import RefreshIcon from "@mui/icons-material/Refresh"; import { AntSwitch } from "@utils/Muiutils"; import { IconButton } from "@components/muiComponents"; -import SkeletonLoader from "@components/SkeletonLoader"; + +import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; type CustomContentRootProps = HTMLAttributes & { selectedNodeType?: any; selectedNodeTag?: any; selectedNodeRelationship?: any; selectedNodeBM?: any; + selectedNodeTerm?: any; + selectedNodeCustomFilter?: any; node?: any; selectedNode?: any; }; +const HoverableTreeItemContainer = ({ children, ...props }: any) => { + const [isHovered, setIsHovered] = useState(false); + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + {...props} + > + {typeof children === "function" ? children(isHovered) : children} +
+ ); +}; + const CustomContentRoot = styled("div")( ({ theme, ...props }) => ({ WebkitTapHighlightColor: "#0E8173", "&&:hover, &&.Mui-disabled, &&.Mui-focused, &&.Mui-selected, &&.Mui-selected.Mui-focused, &&.Mui-selected:hover": - { - backgroundColor: "transparent", - }, + { + backgroundColor: "transparent", + }, ".MuiTreeItem-contentBar": { position: "absolute", width: "100%", @@ -120,7 +136,9 @@ const CustomContentRoot = styled("div")( ...((props.selectedNodeType === props.node || props.selectedNodeTag === props.node || props.selectedNodeRelationship === props.node || - props.selectedNodeBM === props.node) && { + props.selectedNodeBM === props.node || + props.selectedNodeTerm === props.node || + props.selectedNodeCustomFilter === props.node) && { "&.Mui-selected .MuiTreeItem-contentBar": { backgroundColor: "rgba(255,255,255,0.08)", borderLeft: "4px solid #2ccebb", @@ -197,8 +215,8 @@ const CustomContent = forwardRef(function CustomContent( }; const labelProps = isValidElement(props.label) - ? (props.label.props as CustomContentRootProps) - : undefined; + ? (props.label.props as CustomContentRootProps) + : undefined; return ( { handleClick(e); @@ -276,6 +298,7 @@ const BarTreeView: FC<{ searchTerm: string; sideBarOpen: boolean; loader?: boolean; + isPopover?: boolean; }> = ({ treeData, treeName, @@ -287,1027 +310,1082 @@ const BarTreeView: FC<{ sideBarOpen, searchTerm, loader, + isPopover, }) => { - const dispatch = useAppDispatch(); - const { savedSearchData }: any = useAppSelector( - (state: any) => state.savedSearch - ); - const { bmguid } = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - const searchParams = new URLSearchParams(location.search); - const [expand, setExpand] = useState(null); - const [selectedNode, setSelectedNode] = useState<{ - type: string | null; - tag: string | null; - relationship: string | null; - businessMetadata: string | null; - }>({ - type: null, - tag: null, - relationship: null, - businessMetadata: null, - }); - - const [openModal, setOpenModal] = useState(false); - const toastId: any = useRef(null); - const open = Boolean(expand); - const [expandedItems, setExpandedItems] = useState([]); - const [tagModal, setTagModal] = useState(false); - const [glossaryModal, setGlossaryModal] = useState(false); - const { businessMetaData }: any = useAppSelector( - (state: any) => state.businessMetaData - ); - - const filteredData = useMemo(() => { - return treeData.filter((node) => { - return ( - node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || - (node.children && - node.children.some((child) => - child.label?.toLowerCase().includes(searchTerm.toLowerCase()) - )) - ); + const { savedSearchData }: any = useAppSelector( + (state: any) => state.savedSearch + ); + const { bmguid } = useParams(); + const dispatch = useAppDispatch(); + const location = useLocation(); + const navigate = useNavigate(); + const searchParams = new URLSearchParams(location.search); + const [expand, setExpand] = useState(null); + const [selectedNode, setSelectedNode] = useState<{ + type: string | null; + tag: string | null; + relationship: string | null; + businessMetadata: string | null; + term: string | null; + customFilter: string | null; + }>({ + type: null, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, }); - }, [treeData, searchTerm]); - const displayTreeName = useMemo(() => { - return treeName === "CustomFilters" ? "Custom Filters" : treeName - }, [treeName]); + const [openModal, setOpenModal] = useState(false); + const toastId: any = useRef(null); + const open = Boolean(expand); + const [expandedItems, setExpandedItems] = useState([]); + const [tagModal, setTagModal] = useState(false); + const [glossaryModal, setGlossaryModal] = useState(false); + const { businessMetaData }: any = useAppSelector( + (state: any) => state.businessMetaData + ); - const highlightText = useMemo(() => { - return (text: string) => { - if (!searchTerm) return text; + const filteredData = useMemo(() => { + return treeData.filter((node) => { + return ( + node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + (node.children && + node.children.some((child) => + child.label?.toLowerCase().includes(searchTerm.toLowerCase()) + )) + ); + }); + }, [treeData, searchTerm]); + + const displayTreeName = useMemo(() => { + return treeName === "CustomFilters" ? "Custom Filters" : treeName + }, [treeName]); + + const highlightText = useMemo(() => { + return (text: string) => { + if (!searchTerm) return text; + + const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === searchTerm.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ); + }; + }, [searchTerm]); + + const expandedItemsMemo = useMemo(() => { + const allNodeIds = filteredData.flatMap((node) => { + return [ + node.id, + ...(node.children ? node.children.map((child) => child.id) : []), + ]; + }); + return [...allNodeIds, ...[treeName]]; + }, [filteredData, treeName]); - const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); - return parts.map((part, index) => - part.toLowerCase() === searchTerm.toLowerCase() ? ( - - {part} - - ) : ( - part - ) - ); + useEffect(() => { + setExpandedItems(expandedItemsMemo); + }, [expandedItemsMemo]); + + const getNodeId = (node: TreeNode) => { + if (treeName == "Classifications" && node.types == "parent") { + return node.label; + } else if (treeName == "Classifications" && node.types == "child") { + return `${node.id}@${node.label}`; + } + return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; }; - }, [searchTerm]); - - const expandedItemsMemo = useMemo(() => { - const allNodeIds = filteredData.flatMap((node) => { - return [ - node.id, - ...(node.children ? node.children.map((child) => child.id) : []), - ]; - }); - return [...allNodeIds, ...[treeName]]; - }, [filteredData, treeName]); - useEffect(() => { - setExpandedItems(expandedItemsMemo); - }, [expandedItemsMemo]); - - useEffect(() => { - const searchParams = new URLSearchParams(location.search); - const nodeIdFromParamsType = searchParams.get("type"); - const nodeIdFromParamsTag = searchParams.get("tag"); - const nodeIdFromParamsRelationshipName = - searchParams.get("relationshipName"); - const nodeIdFromBMName = location.pathname.includes( - "/administrator/businessMetadata" - ); + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const nodeIdFromParamsType = searchParams.get("type"); + const nodeIdFromParamsTag = searchParams.get("tag"); + const nodeIdFromParamsRelationshipName = + searchParams.get("relationshipName"); + const nodeIdFromBMName = location.pathname.includes( + "/administrator/businessMetadata" + ); + const nodeIdFromParamsTerm = searchParams.get("term") || searchParams.get("category") || searchParams.get("gtype") || location.pathname.split("/glossary/")[1]; + const nodeIdFromCustomFilter = searchParams.get("customFilter"); - const bmObj = !isEmpty(businessMetaData?.businessMetadataDefs) - ? businessMetaData?.businessMetadataDefs?.find((obj: EnumTypeDefData) => { + const bmObj = !isEmpty(businessMetaData?.businessMetadataDefs) + ? businessMetaData?.businessMetadataDefs?.find((obj: EnumTypeDefData) => { if (bmguid == obj.guid) { return obj; } }) - : {}; - const { name = "" } = bmObj || {}; - - setSelectedNode({ - type: nodeIdFromParamsType, - tag: nodeIdFromParamsTag, - relationship: nodeIdFromParamsRelationshipName, - businessMetadata: nodeIdFromBMName ? name : null, - }); + : {}; + const { name = "" } = bmObj || {}; - if ( - !nodeIdFromParamsType && - !nodeIdFromParamsTag && - !nodeIdFromParamsRelationshipName && - !nodeIdFromBMName - ) { setSelectedNode({ - type: null, - tag: null, - relationship: null, - businessMetadata: null, + type: nodeIdFromParamsType, + tag: nodeIdFromParamsTag, + relationship: nodeIdFromParamsRelationshipName, + businessMetadata: nodeIdFromBMName ? name : null, + term: nodeIdFromParamsTerm || null, + customFilter: nodeIdFromCustomFilter || null, }); - } - }, [location.search]); - const getEmptyTypesTitle = () => { - switch (treeName) { - case "Entities": - return `${isEmptyServicetype ? "Hide" : "Show"} empty service types`; + if (nodeIdFromParamsTerm && typeof nodeIdFromParamsTerm === "string" && nodeIdFromParamsTerm.includes("@") && treeName === "Glossary") { + const glossaryName = nodeIdFromParamsTerm.split("@")[1]; + if (glossaryName) { + const parentNode = treeData.find((n) => n.label === glossaryName); + if (parentNode) { + const nodeIdToExpand = getNodeId(parentNode); + setExpandedItems((prev) => { + if (!prev.includes(nodeIdToExpand)) { + return [...prev, nodeIdToExpand]; + } + return prev; + }); + } + } + } - case "Classifications": - return `${isEmptyServicetype ? "Show" : "Hide"} unused classifications`; + if ( + !nodeIdFromParamsType && + !nodeIdFromParamsTag && + !nodeIdFromParamsRelationshipName && + !nodeIdFromBMName && + !nodeIdFromParamsTerm && + !nodeIdFromCustomFilter + ) { + setSelectedNode({ + type: null, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + }, [location.search, treeData, treeName, businessMetaData, bmguid]); - case "Glossary": - return `Show ${isEmptyServicetype ? "Category" : "Term"}`; + const getEmptyTypesTitle = () => { + switch (treeName) { + case "Entities": + return `${isEmptyServicetype ? "Hide" : "Show"} empty service types`; - case "CustomFilters": - return `Show ${isEmptyServicetype ? "all" : "Type"}`; + case "Classifications": + return `${isEmptyServicetype ? "Show" : "Hide"} unused classifications`; - default: - return ""; - } - }; + case "Glossary": + return `Show ${isEmptyServicetype ? "Category" : "Term"}`; - const handleExpandedItemsChange = ( - _event: SyntheticEvent, - newExpandedItems: string[] - ) => { - setExpandedItems(newExpandedItems); - }; + case "CustomFilters": + return `Show ${isEmptyServicetype ? "all" : "Type"}`; - const handleOpenModal = () => { - setOpenModal(true); - }; - const handleCloseModal = () => { - setOpenModal(false); - }; + default: + return ""; + } + }; - const handleClickMenu = (event: MouseEvent) => { - event.stopPropagation(); - setExpand(event.currentTarget); - }; + const handleExpandedItemsChange = ( + _event: SyntheticEvent, + newExpandedItems: string[] + ) => { + setExpandedItems(newExpandedItems); + }; - const handleClose = () => { - setExpand(null); - }; + const handleOpenModal = () => { + setOpenModal(true); + }; + const handleCloseModal = () => { + setOpenModal(false); + }; - const handleCloseTagModal = () => { - setTagModal(false); - }; - const handleCloseGlossaryModal = () => { - setGlossaryModal(false); - }; + const handleClickMenu = (event: MouseEvent) => { + event.stopPropagation(); + setExpand(event.currentTarget); + }; - const handleClickNode = (nodeId: string) => { - const searchParams = new URLSearchParams(location.search); - const isTypeMatch = searchParams.get("type") === nodeId; - const isTagMatch = searchParams.get("tag") === nodeId; - const isRelationshipMatch = searchParams.get("relationshipName") === nodeId; - const isBusinessMetadataMatch = location.pathname.includes( - "/administrator/businessMetadata" - ); + const handleClose = () => { + setExpand(null); + }; - if (isTypeMatch) { - setSelectedNode({ - type: nodeId, - tag: null, - relationship: null, - businessMetadata: null, - }); - } - if (isTagMatch) { - setSelectedNode({ - type: null, - tag: nodeId, - relationship: null, - businessMetadata: null, - }); - } - if (isRelationshipMatch) { - setSelectedNode({ - type: null, - tag: null, - relationship: nodeId, - businessMetadata: null, - }); - } - if (isBusinessMetadataMatch) { - setSelectedNode({ - type: null, - tag: null, - relationship: null, - businessMetadata: nodeId, - }); - } - }; + const handleCloseTagModal = () => { + setTagModal(false); + }; + const handleCloseGlossaryModal = () => { + setGlossaryModal(false); + }; - const getNodeId = (node: TreeNode) => { - if (treeName == "Classifications" && node.types == "parent") { - return node.label; - } else if (treeName == "Classifications" && node.types == "child") { - return `${node.id}@${node.label}`; - } - return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; - }; + const handleClickNode = (nodeId: string) => { + const searchParams = new URLSearchParams(location.search); + const isTypeMatch = searchParams.get("type") === nodeId; + const isTagMatch = searchParams.get("tag") === nodeId; + const isRelationshipMatch = searchParams.get("relationshipName") === nodeId; + const isBusinessMetadataMatch = location.pathname.includes( + "/administrator/businessMetadata" + ); - const handleNodeClick = ( - node: TreeNode, - treeName: string, - searchParams: URLSearchParams, - navigate: NavigateFunction, - isEmptyServicetype: boolean | undefined, - savedSearchData: any, - toastId: any - ) => { - globalSearchFilterInitialQuery.setQuery({}); - searchParams.delete("tabActive"); - - if (treeName === "Classifications") { - handleClickNode(node.id); - } else { - handleClickNode(node.id); - } - - if (node.id === "No Records Found") { - if (typeof event !== "undefined" && event.stopPropagation) { - event.stopPropagation(); + if (isTypeMatch) { + setSelectedNode({ + type: nodeId, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isTagMatch) { + setSelectedNode({ + type: null, + tag: nodeId, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isRelationshipMatch) { + setSelectedNode({ + type: null, + tag: null, + relationship: nodeId, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isBusinessMetadataMatch) { + setSelectedNode({ + type: null, + tag: null, + relationship: null, + businessMetadata: nodeId, + term: null, + customFilter: null, + }); } - return; - } - - if (shouldSetSearchParams(node, treeName)) { - setSearchParams( - node, - treeName, - searchParams, - isEmptyServicetype, - savedSearchData + }; + + const handleNodeClick = ( + node: TreeNode, + treeName: string, + searchParams: URLSearchParams, + navigate: NavigateFunction, + isEmptyServicetype: boolean | undefined, + savedSearchData: any, + toastId: any + ) => { + globalSearchFilterInitialQuery.setQuery({}); + searchParams.delete("tabActive"); + + if (treeName === "Classifications") { + handleClickNode(node.id); + } else { + handleClickNode(node.id); + } + + if (node.id === "No Records Found") { + if (typeof event !== "undefined" && event.stopPropagation) { + event.stopPropagation(); + } + return; + } + + if (shouldSetSearchParams(node, treeName)) { + setSearchParams( + node, + treeName, + searchParams, + isEmptyServicetype, + savedSearchData + ); + navigateToPath( + node, + treeName, + searchParams, + navigate, + isEmptyServicetype, + toastId + ); + } + }; + + const shouldSetSearchParams = (node: TreeNode, treeName: string) => { + if (treeName === "CustomFilters" && node.types === "parent") { + return false; + } + return ( + node.children === undefined || + isEmpty(node.children) || + treeName === "Classifications" || + (treeName === "Glossary" && node.types === "child") ); - navigateToPath( - node, - treeName, - searchParams, - navigate, - isEmptyServicetype, - toastId + }; + + const setSearchParams = ( + node: TreeNode, + treeName: string, + searchParams: URLSearchParams, + isEmptyServicetype: boolean | undefined, + savedSearchData: any + ) => { + searchParams.set( + "searchType", + node.parent === "ADVANCED" ? "dsl" : "basic" ); - } - }; - const shouldSetSearchParams = (node: TreeNode, treeName: string) => { - if (treeName === "CustomFilters" && node.types === "parent") { - return false; - } - return ( - node.children === undefined || - isEmpty(node.children) || - treeName === "Classifications" || - (treeName === "Glossary" && node.types === "child") - ); - }; + switch (treeName) { + case "Entities": + searchParams.delete("relationshipName"); + searchParams.set("type", node.id); + break; + case "Classifications": { + searchParams.delete("relationshipName"); + const id = node.label.split(" (")[0]; + searchParams.set("tag", id); + break; + } + case "Glossary": + setGlossarySearchParams(node, searchParams, isEmptyServicetype); + break; + case "Relationships": + case "CustomFilters": + setCustomFiltersSearchParams(node, searchParams, savedSearchData); + break; + default: + break; + } - const setSearchParams = ( - node: TreeNode, - treeName: string, - searchParams: URLSearchParams, - isEmptyServicetype: boolean | undefined, - savedSearchData: any - ) => { - searchParams.set( - "searchType", - node.parent === "ADVANCED" ? "dsl" : "basic" - ); + if (treeName !== "CustomFilters") { + searchParams.delete("attributes"); + searchParams.delete("entityFilters"); + searchParams.delete("tagFilters"); + searchParams.delete("relationshipFilters"); + searchParams.set("pageLimit", "25"); + searchParams.set("pageOffset", "0"); + } + }; - switch (treeName) { - case "Entities": - searchParams.delete("relationshipName"); - searchParams.set("type", node.id); - break; - case "Classifications": { - searchParams.delete("relationshipName"); - const id = node.label.split(" (")[0]; - searchParams.set("tag", id); - break; + const setGlossarySearchParams = ( + node: TreeNode, + searchParams: URLSearchParams, + isEmptyServicetype: boolean | undefined + ) => { + const guid = + !isEmptyServicetype && node.cGuid !== undefined + ? node.cGuid + : node.guid || ""; + searchParams.delete("relationshipName"); + + if (isEmptyServicetype) { + searchParams.set("term", `${node.id}@${node.parent}`); + } else { + searchParams.delete("type"); + searchParams.delete("tag"); + searchParams.set("gid", node.guid as string); } - case "Glossary": - setGlossarySearchParams(node, searchParams, isEmptyServicetype); - break; - case "Relationships": - case "CustomFilters": - setCustomFiltersSearchParams(node, searchParams, savedSearchData); - break; - default: - break; - } - - if (treeName !== "CustomFilters") { - searchParams.delete("attributes"); - searchParams.delete("entityFilters"); - searchParams.delete("tagFilters"); - searchParams.delete("relationshipFilters"); - searchParams.set("pageLimit", "25"); - searchParams.set("pageOffset", "0"); - } - }; - const setGlossarySearchParams = ( - node: TreeNode, - searchParams: URLSearchParams, - isEmptyServicetype: boolean | undefined - ) => { - const guid = - !isEmptyServicetype && node.cGuid !== undefined - ? node.cGuid - : node.guid || ""; - searchParams.delete("relationshipName"); - - if (isEmptyServicetype) { - searchParams.set("term", `${node.id}@${node.parent}`); - } else { - searchParams.delete("type"); - searchParams.delete("tag"); - searchParams.set("gid", node.guid as string); - } - - searchParams.set("gtype", `${isEmptyServicetype ? "term" : "category"}`); - searchParams.set("viewType", `${isEmptyServicetype ? "term" : "category"}`); - searchParams.set("guid", guid); - }; + searchParams.set("gtype", `${isEmptyServicetype ? "term" : "category"}`); + searchParams.set("viewType", `${isEmptyServicetype ? "term" : "category"}`); + searchParams.set("guid", guid); + }; - const setCustomFiltersSearchParams = ( - node: TreeNode, - searchParams: URLSearchParams, - savedSearchData: any[] - ) => { - // Clear all existing params except searchType - const keys = Array.from(searchParams.keys()); - for (let i = 0; i < keys.length; i++) { - if (keys[i] !== "searchType") { - searchParams.delete(keys[i]); - } - } - - // Clear globalSearchFilterInitialQuery when applying new saved search - globalSearchFilterInitialQuery.setQuery({}); - - if (treeName === "CustomFilters") { - const params = savedSearchData.find((obj) => obj.name === node.id); - if (params) { - const searchParamsObj = params?.searchParameters || {}; - - // Step 1: Set searchType based on saved search type - if (params.searchType) { - const searchTypeValue = params.searchType === "ADVANCED" ? "dsl" : "basic"; - searchParams.set("searchType", searchTypeValue); + const setCustomFiltersSearchParams = ( + node: TreeNode, + searchParams: URLSearchParams, + savedSearchData: any[] + ) => { + // Clear all existing params except searchType + const keys = Array.from(searchParams.keys()); + for (let i = 0; i < keys.length; i++) { + if (keys[i] !== "searchType") { + searchParams.delete(keys[i]); } - - // Step 2: Apply basic search parameters (excluding filters which are handled separately) - for (const key in searchParamsObj) { - if (shouldSetCustomFilterParam(node, key) && + } + + // Clear globalSearchFilterInitialQuery when applying new saved search + globalSearchFilterInitialQuery.setQuery({}); + + if (treeName === "CustomFilters") { + const params = savedSearchData.find((obj) => obj.name === node.id); + if (params) { + const searchParamsObj = params?.searchParameters || {}; + + // Step 1: Set searchType based on saved search type + if (params.searchType) { + const searchTypeValue = params.searchType === "ADVANCED" ? "dsl" : "basic"; + searchParams.set("searchType", searchTypeValue); + } + + // Step 2: Apply basic search parameters (excluding filters which are handled separately) + for (const key in searchParamsObj) { + if (shouldSetCustomFilterParam(node, key) && !["entityFilters", "tagFilters", "relationshipFilters"].includes(key)) { - setCustomFilterParam(searchParams, key, searchParamsObj[key]); + setCustomFilterParam(searchParams, key, searchParamsObj[key]); + } } - } - - // Step 3: Convert and apply entityFilters from API format to URL string format - if (searchParamsObj.entityFilters && !isEmpty(searchParamsObj.entityFilters)) { - const clonedFilter = cloneDeep(searchParamsObj.entityFilters); - const ruleUrl = attributeFilter.generateUrl({ - value: clonedFilter, - formatedDateToLong: true - }); - - if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { - searchParams.set("entityFilters", ruleUrl); - - // Convert API format to query builder format for Filters component UI - const qbFilter = convertApiToQueryBuilder(searchParamsObj.entityFilters); - if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { - globalSearchFilterInitialQuery.setQuery({ - entityFilters: qbFilter - }); + + // Step 3: Convert and apply entityFilters from API format to URL string format + if (searchParamsObj.entityFilters && !isEmpty(searchParamsObj.entityFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.entityFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("entityFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.entityFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + entityFilters: qbFilter + }); + } } } - } - - // Step 4: Convert and apply tagFilters from API format to URL string format - if (searchParamsObj.tagFilters && !isEmpty(searchParamsObj.tagFilters)) { - const clonedFilter = cloneDeep(searchParamsObj.tagFilters); - const ruleUrl = attributeFilter.generateUrl({ - value: clonedFilter, - formatedDateToLong: true - }); - - if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { - searchParams.set("tagFilters", ruleUrl); - - // Convert API format to query builder format for Filters component UI - const qbFilter = convertApiToQueryBuilder(searchParamsObj.tagFilters); - if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { - globalSearchFilterInitialQuery.setQuery({ - tagFilters: qbFilter - }); + + // Step 4: Convert and apply tagFilters from API format to URL string format + if (searchParamsObj.tagFilters && !isEmpty(searchParamsObj.tagFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.tagFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("tagFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.tagFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + tagFilters: qbFilter + }); + } } } - } - - // Step 5: Convert and apply relationshipFilters from API format to URL string format - if (searchParamsObj.relationshipFilters && !isEmpty(searchParamsObj.relationshipFilters)) { - const clonedFilter = cloneDeep(searchParamsObj.relationshipFilters); - const ruleUrl = attributeFilter.generateUrl({ - value: clonedFilter, - formatedDateToLong: true - }); - - if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { - searchParams.set("relationshipFilters", ruleUrl); - - // Convert API format to query builder format for Filters component UI - const qbFilter = convertApiToQueryBuilder(searchParamsObj.relationshipFilters); - if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { - globalSearchFilterInitialQuery.setQuery({ - relationshipFilters: qbFilter - }); + + // Step 5: Convert and apply relationshipFilters from API format to URL string format + if (searchParamsObj.relationshipFilters && !isEmpty(searchParamsObj.relationshipFilters)) { + const clonedFilter = cloneDeep(searchParamsObj.relationshipFilters); + const ruleUrl = attributeFilter.generateUrl({ + value: clonedFilter, + formatedDateToLong: true + }); + + if (ruleUrl && !isEmpty(ruleUrl) && typeof ruleUrl === "string") { + searchParams.set("relationshipFilters", ruleUrl); + + // Convert API format to query builder format for Filters component UI + const qbFilter = convertApiToQueryBuilder(searchParamsObj.relationshipFilters); + if (qbFilter && (qbFilter.rules || qbFilter.combinator)) { + globalSearchFilterInitialQuery.setQuery({ + relationshipFilters: qbFilter + }); + } } } + + searchParams.set("isCF", "true"); + searchParams.set("customFilter", node.id); } - - searchParams.set("isCF", "true"); + } else { + searchParams.set("relationshipName", node.id); } - } else { - searchParams.set("relationshipName", node.id); - } - }; - - // Helper function to convert API format filter (criterion/condition) to query builder format (rules/combinator) - const convertApiToQueryBuilder = (apiFilter: any): any => { - if (!apiFilter || typeof apiFilter !== "object") { - return null; - } - - const result: any = {}; - - // Convert condition to combinator - if (apiFilter.condition) { - result.combinator = apiFilter.condition.toLowerCase(); - } else { - result.combinator = "and"; // default - } - - // Convert criterion to rules - if (apiFilter.criterion && Array.isArray(apiFilter.criterion)) { - result.rules = apiFilter.criterion.map((rule: any) => { - // If nested condition, recurse - if (rule.condition || rule.criterion) { - return convertApiToQueryBuilder(rule); - } - // Convert API rule format to query builder format - return { - field: rule.attributeName || rule.id, - operator: rule.operator, - value: rule.attributeValue || rule.value, - type: rule.type || rule.attributeType - }; - }); - } else if (apiFilter.rules && Array.isArray(apiFilter.rules)) { - // Already in query builder format - result.rules = apiFilter.rules.map((rule: any) => - rule.condition || rule.criterion ? convertApiToQueryBuilder(rule) : rule - ); - } - - return Object.keys(result).length > 0 ? result : null; - }; + }; - const shouldSetCustomFilterParam = (node: TreeNode, key: string) => { - return ( - node.parent === "BASIC" || - node.parent === "ADVANCED" || - (node.parent === "BASIC_RELATIONSHIP" && - (key === "relationshipName" || key === "limit" || key === "offset")) - ); - }; + // Helper function to convert API format filter (criterion/condition) to query builder format (rules/combinator) + const convertApiToQueryBuilder = (apiFilter: any): any => { + if (!apiFilter || typeof apiFilter !== "object") { + return null; + } - const setCustomFilterParam = ( - searchParams: URLSearchParams, - key: string, - value: any - ) => { - if (key === "limit") { - searchParams.set("pageLimit", value || 25); - } else if (key === "offset") { - searchParams.set("pageOffset", value); - } else if (key === "typeName") { - searchParams.set("type", value); - } else if (key === "classification") { - // Map classification to tag parameter for URL (matching classic UI) - searchParams.set("tag", value); - } else if (key === "termName") { - // Map termName (API format) to term parameter for URL (matching classic UI) - searchParams.set("term", value); - } else if (value !== null && value !== undefined && value !== "") { - // Only set parameter if value is not null, undefined, or empty string - searchParams.set(key, value); - } - }; + const result: any = {}; + + // Convert condition to combinator + if (apiFilter.condition) { + result.combinator = apiFilter.condition.toLowerCase(); + } else { + result.combinator = "and"; // default + } - const navigateToPath = ( - node: TreeNode, - treeName: string, - searchParams: URLSearchParams, - navigate: NavigateFunction, - isEmptyServicetype: boolean | undefined, - toastId: any - ) => { - switch (treeName) { - case "Business MetaData": - searchParams.delete("relationshipName"); - navigate( - { pathname: `administrator/businessMetadata/${node.guid}` }, - { replace: true } + // Convert criterion to rules + if (apiFilter.criterion && Array.isArray(apiFilter.criterion)) { + result.rules = apiFilter.criterion.map((rule: any) => { + // If nested condition, recurse + if (rule.condition || rule.criterion) { + return convertApiToQueryBuilder(rule); + } + // Convert API rule format to query builder format + return { + field: rule.attributeName || rule.id, + operator: rule.operator, + value: rule.attributeValue || rule.value, + type: rule.type || rule.attributeType + }; + }); + } else if (apiFilter.rules && Array.isArray(apiFilter.rules)) { + // Already in query builder format + result.rules = apiFilter.rules.map((rule: any) => + rule.condition || rule.criterion ? convertApiToQueryBuilder(rule) : rule ); - break; - case "Glossary": - if (!isEmptyServicetype) { - searchParams.delete("relationshipName"); - navigate( - { - pathname: `glossary/${ - node.cGuid !== undefined ? node.cGuid : node.guid - }`, - search: searchParams.toString(), - }, - { replace: true } - ); - } else if (node.types === "parent") { - toast.dismiss(toastId.current); - toastId.current = toast.warning("Create a Term or Category"); - } else { + } + + return Object.keys(result).length > 0 ? result : null; + }; + + const shouldSetCustomFilterParam = (node: TreeNode, key: string) => { + return ( + node.parent === "BASIC" || + node.parent === "ADVANCED" || + (node.parent === "BASIC_RELATIONSHIP" && + (key === "relationshipName" || key === "limit" || key === "offset")) + ); + }; + + const setCustomFilterParam = ( + searchParams: URLSearchParams, + key: string, + value: any + ) => { + if (key === "limit") { + searchParams.set("pageLimit", value || 25); + } else if (key === "offset") { + searchParams.set("pageOffset", value); + } else if (key === "typeName") { + searchParams.set("type", value); + } else if (key === "classification") { + // Map classification to tag parameter for URL (matching classic UI) + searchParams.set("tag", value); + } else if (key === "termName") { + // Map termName (API format) to term parameter for URL (matching classic UI) + searchParams.set("term", value); + } else if (value !== null && value !== undefined && value !== "") { + // Only set parameter if value is not null, undefined, or empty string + searchParams.set(key, value); + } + }; + + const navigateToPath = ( + node: TreeNode, + treeName: string, + searchParams: URLSearchParams, + navigate: NavigateFunction, + isEmptyServicetype: boolean | undefined, + toastId: any + ) => { + switch (treeName) { + case "Business MetaData": searchParams.delete("relationshipName"); navigate( - { - pathname: "/search/searchResult", - search: searchParams.toString(), - }, + { pathname: `administrator/businessMetadata/${node.guid}` }, { replace: true } ); - } - break; - case "Relationships": - case "CustomFilters": - if ( - treeName == "Relationships" || - (treeName == "CustomFilters" && node.parent == "BASIC_RELATIONSHIP") - ) { - navigate( - { - pathname: `relationship/relationshipSearchresult`, - search: searchParams.toString(), - }, - { replace: true } - ); - } else { + break; + case "Glossary": + if (!isEmptyServicetype) { + searchParams.delete("relationshipName"); + navigate( + { + pathname: `glossary/${node.cGuid !== undefined ? node.cGuid : node.guid + }`, + search: searchParams.toString(), + }, + { replace: true } + ); + } else if (node.types === "parent") { + toast.dismiss(toastId.current); + toastId.current = toast.warning("Create a Term or Category"); + } else { + searchParams.delete("relationshipName"); + navigate( + { + pathname: "/search/searchResult", + search: searchParams.toString(), + }, + { replace: true } + ); + } + break; + case "Relationships": + case "CustomFilters": + if ( + treeName == "Relationships" || + (treeName == "CustomFilters" && node.parent == "BASIC_RELATIONSHIP") + ) { + navigate( + { + pathname: `relationship/relationshipSearchresult`, + search: searchParams.toString(), + }, + { replace: true } + ); + } else { + searchParams.delete("relationshipName"); + navigate( + { + pathname: "/search/searchResult", + search: searchParams.toString(), + }, + { replace: true } + ); + } + break; + default: searchParams.delete("relationshipName"); navigate( - { - pathname: "/search/searchResult", - search: searchParams.toString(), - }, + { pathname: "/search/searchResult", search: searchParams.toString() }, { replace: true } ); - } - break; - default: - searchParams.delete("relationshipName"); - navigate( - { pathname: "/search/searchResult", search: searchParams.toString() }, - { replace: true } - ); - break; - } - }; - - const TreeLabelWithTooltip: React.FC<{ label: string }> = ({ label }) => { - const labelRef = useRef(null); - const [isOverflown, setIsOverflown] = useState(false); - - useEffect(() => { - const el = labelRef.current; - if (el) { - setIsOverflown(el.scrollWidth > el.clientWidth); + break; } - }, [label, searchTerm]); + }; - return ( - - - {highlightText(label)} - - - ); - }; + const TreeLabelWithTooltip: React.FC<{ label: string }> = ({ label }) => { + const labelRef = useRef(null); + const [isOverflown, setIsOverflown] = useState(false); - const renderTreeItem = (node: TreeNode) => - node?.id && ( - ) => { - handleNodeClick( - node, - treeName, - searchParams, - navigate, - isEmptyServicetype, - savedSearchData, - toastId - ); - }, - className: "custom-treeitem-label", - } as any)} + useEffect(() => { + const el = labelRef.current; + if (el) { + setIsOverflown(el.scrollWidth > el.clientWidth); + } + }, [label, searchTerm]); + + return ( + + - {node.id != "No Records Found" && ( - - )} - - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "CustomFilters" || - treeName == "Glossary") && - node.id != "No Records Found" && ( - + {highlightText(label)} + + + ); + }; + + const renderTreeItem = (node: TreeNode) => + node?.id && ( + ) => { + handleNodeClick( + node, + treeName, + searchParams, + navigate, + isEmptyServicetype, + savedSearchData, + toastId + ); + }, + className: "custom-treeitem-label", + } as any)} + > + {(isHovered: boolean) => ( + <> + {node.id != "No Records Found" && ( + + )} + + {(treeName == "Entities" || + treeName == "Classifications" || + treeName == "CustomFilters" || + treeName == "Glossary") && + node.id != "No Records Found" && ( + + )} + )} - - } - > - {node.children && node.children.map((child) => renderTreeItem(child))} - - ); + + } + > + {node.children && node.children.map((child) => renderTreeItem(child))} + + ); - const downloadFile = async () => { - try { - if (treeName == "Glossary") { - await downloadGlossaryImportTemplate(); - return; - } - const apiResp: any = await getBusinessMetadataImportTmpl({}); - const text: string = apiResp ? apiResp.data : ""; - const blob = new Blob([text], { type: "text/plain" }); + const downloadFile = async () => { + try { + if (treeName == "Glossary") { + await downloadGlossaryImportTemplate(); + return; + } + const apiResp: any = await getBusinessMetadataImportTmpl({}); + const text: string = apiResp ? apiResp.data : ""; + const blob = new Blob([text], { type: "text/plain" }); - const url = window.URL.createObjectURL(blob); + const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.setAttribute("download", "template_business_metadata"); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "template_business_metadata"); - document.body.appendChild(link); + document.body.appendChild(link); - link.click(); + link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch { - /* ignore download error */ - } - }; + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch { + /* ignore download error */ + } + }; - const label = { inputProps: { "aria-label": "Switch demo" } }; - return ( - <> - - + - - - {displayTreeName} - - - - { - e.stopPropagation(); - refreshData(); - }} - disabled={loader} - > - - - - + + + + {treeName === "Entities" && } + {treeName === "Classifications" && } + {treeName === "Business MetaData" && } + {treeName === "Glossary" && } + {treeName === "CustomFilters" && } + {displayTreeName} + + + + { + e.stopPropagation(); + refreshData(); + }} + disabled={loader} + > + + + - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "Glossary") && ( - <> - { - - void }) => { - e.stopPropagation(); - if (setisEmptyServicetype) { - setisEmptyServicetype(!isEmptyServicetype); - } - }} - data-cy="showEmptyServiceType" - inputProps={{ "aria-label": "ant design" }} - /> - - } - - )} - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "Glossary") && ( - { - e.stopPropagation(); - handleClickMenu(e); - }} - data-cy="dropdownMenuButton" - fontSize="small" - /> - )} + {(treeName == "Entities" || + treeName == "Classifications" || + treeName == "Glossary") && ( + <> + { + + void }) => { + e.stopPropagation(); + if (setisEmptyServicetype) { + setisEmptyServicetype(!isEmptyServicetype); + } + }} + data-cy="showEmptyServiceType" + inputProps={{ "aria-label": "ant design" }} + /> + + } + + )} + + {(treeName == "Entities" || + treeName == "Classifications" || + treeName == "Glossary") && ( + { + e.stopPropagation(); + handleClickMenu(e); + }} + data-cy="dropdownMenuButton" + fontSize="small" + /> + )} - {treeName == "Business MetaData" && ( - - + { + e.stopPropagation(); + const newSearchParams = new URLSearchParams(); + + newSearchParams.set("tabActive", "businessMetadata"); + navigate( + { + pathname: `administrator`, + search: newSearchParams.toString(), + }, + { replace: true } + ); + }} + data-cy="createBusinessMetadata" + /> + + )} + + { + e.stopPropagation(); + }} + anchorEl={expand} + id="account-menu" + open={open} + onClose={handleClose} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + sx={{ + "& .MuiPaper-root": { + transition: "none !important", + }, + }} + disableScrollLock={true} + > + {(treeName == "Entities" || + treeName == "Classifications") && ( + { + e.stopPropagation(); + if (setisGroupView) { + setisGroupView(!isGroupView); + } + handleClose(); + }} + data-cy="groupOrFlatTreeView" + className="sidebar-menu-item" + > + + {isGroupView ? ( + + ) : ( + + )} + + + Show {isGroupView ? "flat" : "group"} tree + + + )} + {(treeName == "Classifications" || + treeName == "Glossary") && ( + { + e.stopPropagation(); + if (treeName == "Classifications") { + setTagModal(true); + } else if (treeName == "Glossary") { + setGlossaryModal(true); + } + handleClose(); + }} + data-cy="createClassification" + className="sidebar-menu-item" + > + + + + + Create{" "} + {treeName == "Classifications" + ? "Classifications" + : "Glossary"} + + + )} + {(treeName == "Entities" || treeName == "Glossary") && ( + { e.stopPropagation(); - const newSearchParams = new URLSearchParams(); - - newSearchParams.set("tabActive", "businessMetadata"); - navigate( - { - pathname: `administrator`, - search: newSearchParams.toString(), - }, - { replace: true } - ); + downloadFile(); + handleClose(); }} - data-cy="createBusinessMetadata" - /> - - )} - - { - e.stopPropagation(); - }} - anchorEl={expand} - id="account-menu" - open={open} - onClose={handleClose} - transformOrigin={{ horizontal: "right", vertical: "top" }} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - sx={{ - "& .MuiPaper-root": { - transition: "none !important", - }, - }} - disableScrollLock={true} - > - {(treeName == "Entities" || - treeName == "Classifications") && ( - { - e.stopPropagation(); - if (setisGroupView) { - setisGroupView(!isGroupView); + data-cy="downloadBusinessMetadata" + disabled={ + treeName == "Glossary" && !isEmptyServicetype + ? true + : false } - handleClose(); - }} - data-cy="groupOrFlatTreeView" - className="sidebar-menu-item" - > - - {isGroupView ? ( - + - ) : ( - + + Download Import template + + + )} + {(treeName == "Entities" || treeName == "Glossary") && ( + { + e.stopPropagation(); + handleOpenModal(); + handleClose(); + }} + data-cy="importBusinessMetadata" + disabled={ + treeName == "Glossary" && !isEmptyServicetype + ? true + : false + } + className="sidebar-menu-item" + > + + - )} - - - Show {isGroupView ? "flat" : "group"} tree - - - )} - {(treeName == "Classifications" || - treeName == "Glossary") && ( - { - e.stopPropagation(); - if (treeName == "Classifications") { - setTagModal(true); - } else if (treeName == "Glossary") { - setGlossaryModal(true); - } - handleClose(); - }} - data-cy="createClassification" - className="sidebar-menu-item" - > - + + + {treeName == "Entities" + ? "Import Business Metadata" + : "Import Glossary Term"} + + + )} + {treeName == "Glossary" && ( + { + e.stopPropagation(); + navigate("/glossary/terms-list"); + handleClose(); + }} + data-cy="glossaryTermsListNavigate" + className="sidebar-menu-item" > - - - - Create{" "} - {treeName == "Classifications" - ? "Classifications" - : "Glossary"} - - - )} - {(treeName == "Entities" || treeName == "Glossary") && ( - { - e.stopPropagation(); - downloadFile(); - handleClose(); - }} - data-cy="downloadBusinessMetadata" - className="sidebar-menu-item" - > - - - - - Download Import template - - - )} - {(treeName == "Entities" || treeName == "Glossary") && ( - { - e.stopPropagation(); - handleOpenModal(); - handleClose(); - }} - data-cy="importBusinessMetadata" - className="sidebar-menu-item" - > - - - - - - {treeName == "Entities" - ? "Import Business Metadata" - : "Import Glossary Term"} - - - )} - {treeName == "Glossary" && ( - { - e.stopPropagation(); - navigate("/glossary/terms-list"); - handleClose(); - }} - data-cy="glossaryTermsListNavigate" - className="sidebar-menu-item" - > - - - - - Export Glossary - - - )} - - - } - sx={{ - "& .MuiTreeItem-label": { - fontWeight: "600 !important", - fontSize: "14px !important", - lineHeight: "26px !important", - color: "white", - }, - "& .MuiTreeItem-content svg": { - color: "white", - fontSize: "20px !important", - }, - }} - > - {loader ? ( - - ) : ( - filteredData.map((node: TreeNode) => renderTreeItem(node)) - )} - - - { - void dispatch(fetchGlossaryData()); - } - : undefined - } + + + + + Export Glossary + + + )} + + + } + sx={{ + "& .MuiTreeItem-label": { + fontWeight: "600 !important", + fontSize: "14px !important", + lineHeight: "26px !important", + color: "white", + }, + "& .MuiTreeItem-content svg": { + color: "white", + fontSize: "20px !important", + }, + }} + > + {loader ? ( + + ) : ( + filteredData.map((node: TreeNode) => renderTreeItem(node)) + )} + + + { + void dispatch(fetchGlossaryData()); + } + : undefined + } + /> + + + + + {tagModal && ( + - - - - {tagModal && ( - - )} - {glossaryModal && ( - - )} - - ); -}; + )} + {glossaryModal && ( + + )} + + ); + }; export default BarTreeView; diff --git a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx index 88b046432ac..874bcaafb56 100644 --- a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx @@ -27,6 +27,7 @@ */ import React from 'react' +import '@testing-library/jest-dom' import { render, screen, waitFor, fireEvent, act, cleanup } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' @@ -154,8 +155,8 @@ jest.mock('@mui/x-tree-view/TreeItem', () => { ) } - return { - TreeItem, + return { + TreeItem, useTreeItemState, TreeItemProps: {}, TreeItemContentProps: {} @@ -283,7 +284,7 @@ describe('SideBarTree', () => { mockFetchGlossaryData.mockReturnValue({ type: 'glossary/fetchGlossaryData' } as any) global.URL.createObjectURL = jest.fn(() => 'blob:url') global.URL.revokeObjectURL = jest.fn() - + // Restore original createElement for React Testing Library document.createElement = originalCreateElement document.body.appendChild = originalAppendChild @@ -331,7 +332,7 @@ describe('SideBarTree', () => { renderComponent({ loader: true }) await waitFor(() => { - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0) }) }) @@ -593,7 +594,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { fireEvent.click(downloadItem) } @@ -643,7 +644,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { fireEvent.click(downloadItem) } @@ -672,7 +673,7 @@ describe('SideBarTree', () => { await waitFor(() => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { expect(downloadItem).not.toHaveAttribute('data-disabled', 'true') } @@ -700,15 +701,15 @@ describe('SideBarTree', () => { // Find menu item by text content const importText = screen.getByText('Import Business Metadata') expect(importText).toBeInTheDocument() - + const importMenuItem = importText.closest('[data-testid="menu-item"]') expect(importMenuItem).toBeInTheDocument() - + if (importMenuItem) { await act(async () => { fireEvent.click(importMenuItem) }) - + // Wait for state update and dialog to appear await waitFor(() => { expect(screen.getByTestId('import-dialog')).toBeInTheDocument() @@ -735,7 +736,7 @@ describe('SideBarTree', () => { // Find menu item by text content const importText = screen.getByText('Import Business Metadata') const importMenuItem = importText.closest('[data-testid="menu-item"]') - + if (importMenuItem) { await act(async () => { fireEvent.click(importMenuItem) @@ -768,7 +769,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const createItem = menuItems.find(item => item.textContent?.includes('Create')) - + if (createItem) { fireEvent.click(createItem) } @@ -796,7 +797,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const createItem = menuItems.find(item => item.textContent?.includes('Create')) - + if (createItem) { fireEvent.click(createItem) } @@ -1424,10 +1425,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'ADVANCED', - searchParameters: { query: 'test' } + savedSearchData: [{ + name: 'filter1', + searchType: 'ADVANCED', + searchParameters: { query: 'test' } }] } }, ['/search/searchResult']) @@ -1454,10 +1455,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: mockEntityFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: mockEntityFilters } }] } }, ['/search/searchResult']) @@ -1484,10 +1485,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { tagFilters: mockTagFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { tagFilters: mockTagFilters } }] } }, ['/search/searchResult']) @@ -1514,10 +1515,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC_RELATIONSHIP', - searchParameters: { relationshipFilters: mockRelationshipFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC_RELATIONSHIP', + searchParameters: { relationshipFilters: mockRelationshipFilters } }] } }, ['/relationship/relationshipSearchresult']) @@ -1537,10 +1538,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC_RELATIONSHIP', - searchParameters: { limit: 50, offset: 10 } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC_RELATIONSHIP', + searchParameters: { limit: 50, offset: 10 } }] } }, ['/relationship/relationshipSearchresult']) @@ -1560,10 +1561,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { typeName: 'EntityType' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { typeName: 'EntityType' } }] } }, ['/search/searchResult']) @@ -1583,10 +1584,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { classification: 'Tag1' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { classification: 'Tag1' } }] } }, ['/search/searchResult']) @@ -1606,14 +1607,14 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { - nullValue: null, - undefinedValue: undefined, - emptyValue: '' - } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { + nullValue: null, + undefinedValue: undefined, + emptyValue: '' + } }] } }, ['/search/searchResult']) @@ -1718,7 +1719,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { await act(async () => { fireEvent.click(downloadItem) @@ -1764,7 +1765,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { await act(async () => { fireEvent.click(downloadItem) @@ -1802,10 +1803,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: nestedFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: nestedFilters } }] } }, ['/search/searchResult']) @@ -1832,10 +1833,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: qbFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: qbFilters } }] } }, ['/search/searchResult']) @@ -1855,10 +1856,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: 'invalid' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: 'invalid' } }] } }, ['/search/searchResult']) @@ -2022,7 +2023,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { expect(downloadItem).not.toHaveAttribute('data-disabled', 'true') } @@ -2047,7 +2048,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const importItem = menuItems.find(item => item.textContent?.includes('Import')) - + if (importItem) { expect(importItem).not.toHaveAttribute('data-disabled', 'true') } @@ -2072,7 +2073,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const importItem = menuItems.find(item => item.textContent?.includes('Import')) - + if (importItem) { expect(importItem).not.toHaveAttribute('data-disabled', 'true') } @@ -2155,7 +2156,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const toggleItem = menuItems.find(item => item.textContent?.includes('flat')) - + if (toggleItem) { await act(async () => { fireEvent.click(toggleItem) diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 5b76d74752b..ca6d310eab6 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ +import '@testing-library/jest-dom'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; @@ -252,7 +253,7 @@ describe('SideBarBody', () => { it('should render search bar when drawer is open', () => { renderWithProviders(); - const searchInput = screen.getByPlaceholderText('Entities, Classifications, Glossaries'); + const searchInput = screen.getByPlaceholderText('Search'); expect(searchInput).toBeInTheDocument(); // The data-cy attribute is on the parent InputBase, not the input itself expect(searchInput.closest('[data-cy="searchNode"]')).toBeInTheDocument(); @@ -348,7 +349,7 @@ describe('SideBarBody', () => { it('should update search term when typing in search bar', async () => { renderWithProviders(); - const searchInput = screen.getByPlaceholderText('Entities, Classifications, Glossaries'); + const searchInput = screen.getByPlaceholderText('Search'); fireEvent.change(searchInput, { target: { value: 'test search' } }); @@ -360,7 +361,7 @@ describe('SideBarBody', () => { it('should pass search term to tree components', async () => { renderWithProviders(); - const searchInput = screen.getByPlaceholderText('Entities, Classifications, Glossaries'); + const searchInput = screen.getByPlaceholderText('Search'); fireEvent.change(searchInput, { target: { value: 'entity' } }); @@ -595,7 +596,7 @@ describe('SideBarBody', () => { it('should handle empty search term', () => { renderWithProviders(); - const searchInput = screen.getByPlaceholderText('Entities, Classifications, Glossaries'); + const searchInput = screen.getByPlaceholderText('Search'); fireEvent.change(searchInput, { target: { value: '' } }); @@ -605,7 +606,7 @@ describe('SideBarBody', () => { it('should handle special characters in search', async () => { renderWithProviders(); - const searchInput = screen.getByPlaceholderText('Entities, Classifications, Glossaries'); + const searchInput = screen.getByPlaceholderText('Search'); fireEvent.change(searchInput, { target: { value: '!@#$%^&*()' } }); From ad24a48f27f36b2a7c1f352ad7cb359566343138 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 8 Jul 2026 17:54:00 +0530 Subject: [PATCH 02/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../img/sidebar-icons/icon-relationships.svg | 3 + .../__tests__/TreeSkeletonLoader.test.tsx | 47 +++ .../slice/__tests__/sessionSlice.test.ts | 62 ++- dashboard/src/redux/slice/sessionSlice.ts | 43 ++ dashboard/src/styles/sidebar.scss | 10 - dashboard/src/utils/test-utils.tsx | 1 + .../Administrator/Audits/AdminAuditTable.tsx | 4 + dashboard/src/views/Layout/About.tsx | 26 +- .../src/views/Layout/__tests__/About.test.tsx | 273 +----------- .../Layout/__tests__/DebugMetrics.test.tsx | 1 + dashboard/src/views/SideBar/SideBarBody.tsx | 398 ++++++------------ .../views/SideBar/SideBarTree/SideBarTree.tsx | 1 + .../SideBar/__tests__/SideBarBody.test.tsx | 87 +++- 13 files changed, 401 insertions(+), 555 deletions(-) create mode 100644 dashboard/public/img/sidebar-icons/icon-relationships.svg create mode 100644 dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx diff --git a/dashboard/public/img/sidebar-icons/icon-relationships.svg b/dashboard/public/img/sidebar-icons/icon-relationships.svg new file mode 100644 index 00000000000..bab762dcebf --- /dev/null +++ b/dashboard/public/img/sidebar-icons/icon-relationships.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx b/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx new file mode 100644 index 00000000000..c7388f30cc5 --- /dev/null +++ b/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TreeSkeletonLoader from '../TreeSkeletonLoader'; + +describe('TreeSkeletonLoader', () => { + it('renders default number of skeletons when count is not provided', () => { + const { container } = render(); + + // By default count is 7 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + // Each row has 1 arrow, 1 text = 2 skeletons per row + // 7 rows * 2 = 14 skeletons + expect(skeletons.length).toBe(14); + }); + + it('renders specific number of skeletons based on count prop', () => { + const { container } = render(); + + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + // 2 rows * 2 = 4 skeletons + expect(skeletons.length).toBe(4); + }); + + it('renders correctly with 0 count', () => { + const { container } = render(); + + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(0); + }); +}); diff --git a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts index 82d9c4968a8..65ece1879e1 100644 --- a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts +++ b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts @@ -21,13 +21,17 @@ */ import { configureStore } from '@reduxjs/toolkit'; -import { fetchSessionData, sessionReducer } from '../sessionSlice'; +import { fetchSessionData, fetchVersionData, sessionReducer } from '../sessionSlice'; // Mock API methods jest.mock('../../../api/apiMethods/fetchApi', () => ({ fetchApi: jest.fn() })); +jest.mock('../../../api/apiMethods/headerApiMethods', () => ({ + getVersion: jest.fn() +})); + jest.mock('../../../api/apiUrlLinks/sessionApiUrl', () => ({ getSessionApiUrl: jest.fn(() => '/api/session') })); @@ -150,5 +154,61 @@ describe('sessionSlice', () => { expect(globalSession).toHaveBeenCalledWith(mockData); }); + + describe('fetchVersionData', () => { + it('should handle fetchVersionData.pending', () => { + const action = { type: fetchVersionData.pending.type }; + const state = sessionReducer(undefined, action); + + expect(state.versionData.loading).toBe(true); + expect(state.versionData.data).toBeNull(); + expect(state.versionData.error).toBeNull(); + }); + + it('should handle fetchVersionData.fulfilled', () => { + const mockVersionData = { Version: '3.0.0' }; + + const action = { + type: fetchVersionData.fulfilled.type, + payload: mockVersionData + }; + const state = sessionReducer(undefined, action); + + expect(state.versionData.loading).toBe(false); + expect(state.versionData.data).toEqual(mockVersionData); + expect(state.versionData.error).toBeNull(); + }); + + it('should handle fetchVersionData.rejected', () => { + const error = 'Error fetching version data'; + const action = { + type: fetchVersionData.rejected.type, + payload: error + }; + const state = sessionReducer(undefined, action); + + expect(state.versionData.loading).toBe(false); + expect(state.versionData.data).toBeNull(); + expect(state.versionData.error).toBe(error); + }); + + it('should fetch version data successfully', async () => { + const { getVersion } = require('../../../api/apiMethods/headerApiMethods'); + const mockVersionData = { Version: '3.0.0' }; + getVersion.mockResolvedValue({ data: mockVersionData }); + + const store = configureStore({ + reducer: { + session: sessionReducer + } + }); + + await store.dispatch(fetchVersionData()); + + const state = store.getState().session; + expect(state.versionData.loading).toBe(false); + expect(state.versionData.data).toEqual(mockVersionData); + }); + }); }); diff --git a/dashboard/src/redux/slice/sessionSlice.ts b/dashboard/src/redux/slice/sessionSlice.ts index 82e49757191..f4eee3bad65 100644 --- a/dashboard/src/redux/slice/sessionSlice.ts +++ b/dashboard/src/redux/slice/sessionSlice.ts @@ -17,6 +17,7 @@ import { fetchApi } from "@api/apiMethods/fetchApi"; import { getSessionApiUrl } from "@api/apiUrlLinks/sessionApiUrl"; +import { getVersion } from "@api/apiMethods/headerApiMethods"; import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { globalSession } from "@utils/Global"; @@ -28,6 +29,11 @@ interface SessionState { data: DynamicData | null; error: string | null; }; + versionData: { + loading: boolean; + data: DynamicData | null; + error: string | null; + }; } export const fetchSessionData = createAsyncThunk( @@ -41,11 +47,24 @@ export const fetchSessionData = createAsyncThunk( } ); +export const fetchVersionData = createAsyncThunk( + "session/fetchVersionData", + async () => { + const response = await getVersion(); + return response.data; + } +); + const sessionInitialState: SessionState = { sessionObj: { loading: false, data: null, error: null + }, + versionData: { + loading: false, + data: null, + error: null } }; @@ -77,6 +96,30 @@ const sessionSlice = createSlice({ data: null, error: (action.payload as string) || action.error?.message || 'An error occurred' }; + }), + builder.addCase(fetchVersionData.pending, (state) => { + state.versionData = { + loading: true, + data: null, + error: null + }; + }), + builder.addCase( + fetchVersionData.fulfilled, + (state, action: PayloadAction) => { + state.versionData = { + loading: false, + data: action.payload, + error: null + }; + } + ), + builder.addCase(fetchVersionData.rejected, (state, action) => { + state.versionData = { + loading: false, + data: null, + error: (action.payload as string) || action.error?.message || 'An error occurred' + }; }); } }); diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 811f246f719..ffa05952f07 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -212,16 +212,6 @@ border-bottom: "1px solid rgba(25,255,255,0.1)"; } -.light-popover { - * { - color: #333333 !important; - } - - img, - svg { - filter: invert(1) brightness(0.2) !important; - } -} .sidebar-searchbar { background: #f1f1f1 !important; diff --git a/dashboard/src/utils/test-utils.tsx b/dashboard/src/utils/test-utils.tsx index 3f0608cdba1..f9ebf8831de 100644 --- a/dashboard/src/utils/test-utils.tsx +++ b/dashboard/src/utils/test-utils.tsx @@ -20,6 +20,7 @@ import React, { ReactElement } from 'react'; import { render, RenderOptions } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; +import '@testing-library/jest-dom'; // Custom render function with providers interface AllTheProvidersProps { diff --git a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx index ed3a2cb95ce..92a1af2bf8c 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -242,6 +242,10 @@ const AdminAuditTable = () => { ) } + sx={{ + marginTop: "13px !important", + marginLeft: "13px !important" + }} > Filters diff --git a/dashboard/src/views/Layout/About.tsx b/dashboard/src/views/Layout/About.tsx index 4a77b5aa08c..b73d5af3f8c 100644 --- a/dashboard/src/views/Layout/About.tsx +++ b/dashboard/src/views/Layout/About.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { getVersion } from "@api/apiMethods/headerApiMethods"; +import { useAppSelector } from "@hooks/reducerHook"; import SkeletonLoader from "@components/SkeletonLoader"; import { List, @@ -24,31 +24,9 @@ import { Stack, Typography } from "@mui/material"; -import { serverError } from "@utils/Utils"; -import { useEffect, useRef, useState } from "react"; const About = () => { - const [versionData, setVersionData] = useState({}); - const [loader, setLoader] = useState(false); - const toastId = useRef(null); - - useEffect(() => { - fetchVersionDetails(); - }, []); - - const fetchVersionDetails = async () => { - setLoader(true); - try { - const versionResp = await getVersion(); - const { data = {} } = versionResp || {}; - setVersionData(data); - setLoader(false); - } catch (error) { - setLoader(false); - console.error(`Error occur while fetching version details`, error); - serverError(error, toastId); - } - }; + const { data: versionData, loading: loader } = useAppSelector((state: any) => state.session.versionData); return ( <> diff --git a/dashboard/src/views/Layout/__tests__/About.test.tsx b/dashboard/src/views/Layout/__tests__/About.test.tsx index 2ccb2720e31..6defb435606 100644 --- a/dashboard/src/views/Layout/__tests__/About.test.tsx +++ b/dashboard/src/views/Layout/__tests__/About.test.tsx @@ -23,14 +23,10 @@ */ import React from 'react' -import { render, screen, waitFor } from '@utils/test-utils' +import { render, screen } from '@utils/test-utils' +import '@testing-library/jest-dom' import About from '../About' - -// Mock API methods -const mockGetVersion = jest.fn() -jest.mock('@api/apiMethods/headerApiMethods', () => ({ - getVersion: (...args: any[]) => mockGetVersion(...args) -})) +import * as reducerHook from '@hooks/reducerHook' // Mock SkeletonLoader component jest.mock('@components/SkeletonLoader', () => ({ @@ -95,31 +91,16 @@ jest.mock('@mui/material', () => ({ ) })) -// Mock Utils -const mockServerError = jest.fn() -jest.mock('@utils/Utils', () => ({ - serverError: (...args: any[]) => mockServerError(...args) -})) - -// Mock console.error to avoid noise in test output -const originalConsoleError = console.error -beforeAll(() => { - console.error = jest.fn() -}) - -afterAll(() => { - console.error = originalConsoleError -}) - describe('About', () => { + let useAppSelectorSpy: jest.SpyInstance + beforeEach(() => { jest.clearAllMocks() - mockGetVersion.mockClear() - mockServerError.mockClear() + useAppSelectorSpy = jest.spyOn(reducerHook, 'useAppSelector') }) - it('should render skeleton loader when loading', async () => { - mockGetVersion.mockImplementation(() => new Promise(() => {})) // Never resolves + it('should render skeleton loader when loading', () => { + useAppSelectorSpy.mockReturnValue({ data: {}, loading: true }) render() @@ -132,24 +113,17 @@ describe('About', () => { expect(skeletonLoader).toHaveAttribute('data-width', '100%') }) - it('should render version data when API call succeeds', async () => { + it('should render version data when loading is false and data is available', () => { const mockVersionData = { Version: '3.0.0-SNAPSHOT', Description: 'Metadata Management Platform', Revision: 'abc123' } - mockGetVersion.mockResolvedValue({ - data: mockVersionData - }) + useAppSelectorSpy.mockReturnValue({ data: mockVersionData, loading: false }) render() - // Wait for loading to finish - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - // Check version is displayed expect(screen.getByText(/Version:/i)).toBeInTheDocument() expect(screen.getByText('3.0.0-SNAPSHOT')).toBeInTheDocument() @@ -169,17 +143,11 @@ describe('About', () => { expect(listItem).toHaveAttribute('data-target', '_blank') }) - it('should render empty version when API returns empty data object', async () => { - mockGetVersion.mockResolvedValue({ - data: {} - }) + it('should render empty version when data object is empty', () => { + useAppSelectorSpy.mockReturnValue({ data: {}, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - // Version should be displayed but empty expect(screen.getByText(/Version:/i)).toBeInTheDocument() const versionTypography = screen.getByTestId('typography-body1') @@ -187,235 +155,30 @@ describe('About', () => { expect(versionTypography.textContent).toContain('Version:') }) - it('should render empty version when API returns undefined data', async () => { - mockGetVersion.mockResolvedValue({ - data: undefined - }) + it('should render empty version when data is undefined', () => { + useAppSelectorSpy.mockReturnValue({ data: undefined, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() }) - it('should render empty version when API returns null data', async () => { - mockGetVersion.mockResolvedValue({ - data: null - }) + it('should render empty version when data is null', () => { + useAppSelectorSpy.mockReturnValue({ data: null, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() }) - it('should handle API call error and call serverError', async () => { - const mockError = new Error('Network error') - mockGetVersion.mockRejectedValue(mockError) - - render() - - await waitFor(() => { - expect(mockServerError).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - // Should still show content (not skeleton) after error - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) - - it('should handle API call error with response data', async () => { - const mockError = { - response: { - data: { - errorMessage: 'Server error occurred' - } - } - } - mockGetVersion.mockRejectedValue(mockError) - - render() - - await waitFor(() => { - expect(mockServerError).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should call getVersion on component mount', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - expect(mockGetVersion).toHaveBeenCalledTimes(1) - expect(mockGetVersion).toHaveBeenCalledWith() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should render correct Typography variants and colors', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '2.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - // Check Typography variants - const body1Typography = screen.getByTestId('typography-body1') - expect(body1Typography).toBeInTheDocument() - - const body2Typographies = screen.getAllByTestId('typography-body2') - expect(body2Typographies.length).toBeGreaterThan(0) - - // Check color prop for "Get involved!" text - const getInvolvedTypography = body2Typographies.find( - (el) => el.textContent === 'Get involved!' - ) - expect(getInvolvedTypography).toHaveAttribute('data-color', 'info.main') - }) - - it('should render Stack components with correct props', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - // Check main Stack - const mainStack = screen.getByTestId('stack') - expect(mainStack).toHaveAttribute('data-spacing', '2') - - // Check column Stack - const columnStack = screen.getByTestId('stack-column') - expect(columnStack).toHaveAttribute('data-spacing', '1') - expect(columnStack).toHaveAttribute('data-direction', 'column') - }) - - it('should render List with dense prop', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - const list = screen.getByTestId('list') - expect(list).toHaveAttribute('data-dense', 'true') - }) - - it('should render ListItemText with correct primary text', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) + it('should handle versionData with undefined Version property', () => { + useAppSelectorSpy.mockReturnValue({ data: { Description: 'Some description' }, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - const listItemText = screen.getByTestId('list-item-text') - expect(listItemText).toHaveAttribute( - 'data-primary', - 'Licensed under the Apache License Version 2.0' - ) - }) - - it('should handle versionData with undefined Version property', async () => { - mockGetVersion.mockResolvedValue({ - data: { Description: 'Some description' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() const versionTypography = screen.getByTestId('typography-body1') expect(versionTypography.textContent).toContain('Version:') expect(versionTypography.textContent).not.toContain('undefined') }) - - it('should set loader to false after successful API call', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - // Initially should show loader - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() - - // After API resolves, loader should be hidden - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should set loader to false after failed API call', async () => { - mockGetVersion.mockRejectedValue(new Error('API Error')) - - render() - - // Initially should show loader - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() - - // After API rejects, loader should be hidden - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should handle API response with null response object', async () => { - mockGetVersion.mockResolvedValue(null) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) - - it('should handle API response with undefined response object', async () => { - mockGetVersion.mockResolvedValue(undefined) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) }) diff --git a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx index ffc296dd8ad..78c2ed763c5 100644 --- a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx +++ b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx @@ -24,6 +24,7 @@ import React from 'react' import { render, screen, fireEvent, waitFor } from '@utils/test-utils' +import '@testing-library/jest-dom' import { ThemeProvider, createTheme } from '@mui/material/styles' import DebugMetrics from '../DebugMetrics' diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 8018d7850f4..b41a57e84f6 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -48,18 +48,18 @@ import { InputBase, Paper, Stack, Box, Popover, Typography, Tooltip, CircularPro import { globalSessionData, PathAssociateWithModule } from "@utils/Enum"; import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft"; import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; -import { useAppDispatch } from "@hooks/reducerHook"; +import { useAppDispatch, useAppSelector } from "@hooks/reducerHook"; import { fetchEnumData } from "@redux/slice/enumSlice"; import { fetchRootClassification } from "@redux/slice/rootClassificationSlice"; import { fetchTypeHeaderData } from "@redux/slice/typeDefSlices/typeDefHeaderSlice"; import { fetchRootEntity } from "@redux/slice/allEntityTypesSlice"; import { fetchMetricEntity } from "@redux/slice/metricsSlice"; +import { fetchVersionData } from "@redux/slice/sessionSlice"; import { refreshDashboardHomeData } from "@utils/refreshDashboardHome"; import ErrorPage from "@views/ErrorPage"; import AppRoutes from "@views/AppRoutes"; import ErrorBoundaryWithNavigate from "../../ErrorBoundary"; import useHistory from "@utils/history.js"; -import AccountTreeIcon from "@mui/icons-material/AccountTree"; const Header = lazy(() => import("@views/Layout/Header")); @@ -116,7 +116,7 @@ const SideBarBody = (props: { const { relationshipSearch = {} } = globalSessionData || {}; const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [versionData, setVersionData] = useState({}); + const { data: versionData } = useAppSelector((state: any) => state.session?.versionData || {}); const searchParams = new URLSearchParams(location.search); const isCustomFilterActive = searchParams.get("isCF") === "true"; @@ -127,16 +127,32 @@ const SideBarBody = (props: { const isEntitiesActive = !isCustomFilterActive && (!!searchParams.get("type") || location.pathname.includes("/detailPage")); + const modules = [ + { id: "entities", title: "Entities", isActive: isEntitiesActive, iconUrl: "/img/sidebar-icons/icon-entities.svg", Component: EntitiesTree, isVisible: true }, + { id: "classification", title: "Classifications", isActive: isClassificationActive, iconUrl: "/img/sidebar-icons/icon-classifications.svg", Component: ClassificationTree, isVisible: true }, + { id: "glossary", title: "Glossary", isActive: isGlossaryActive, iconUrl: "/img/sidebar-icons/icon-glossary.svg", Component: GlossaryTree, isVisible: true }, + { id: "businessMetadata", title: "Business Metadata", isActive: isBusinessMetadataActive, iconUrl: "/img/sidebar-icons/icon-business-metadata.svg", Component: BusinessMetadataTree, isVisible: true }, + { id: "relationships", title: "Relationships", isActive: isRelationshipActive, iconUrl: "/img/sidebar-icons/icon-relationships.svg", Component: RelationshipsTree, isVisible: !!relationshipSearch }, + { id: "customFilters", title: "Custom Filters", isActive: isCustomFilterActive, iconUrl: "/img/sidebar-icons/icon-custom-filters.svg", Component: CustomFiltersTree, isVisible: true } + ]; + const handleDrawerOpen = () => { setOpen(!open); }; - const [popoverAnchor, setPopoverAnchor] = useState(null); + const [popoverAnchor, setPopoverAnchor] = useState(null); const [activePopover, setActivePopover] = useState(null); + const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); - const handlePopoverOpen = (event: React.MouseEvent, id: string) => { + const handlePopoverOpen = (event: React.MouseEvent, id: string) => { setPopoverAnchor(event.currentTarget); setActivePopover(id); + + // Calculate remaining screen height from the anchor to the bottom + const rect = event.currentTarget.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.top - 24; // 24px margin from bottom + // Give it a minimum sensible height of 300px just in case, otherwise use available space + setPopoverMaxHeight(`${Math.max(300, spaceBelow)}px`); }; const handlePopoverClose = () => { @@ -210,17 +226,7 @@ const SideBarBody = (props: { dispatch(fetchRootClassification()); dispatch(fetchEnumData()); dispatch(fetchMetricEntity()); - - // Fetch version data for footer - const fetchVersion = async () => { - try { - const resp = await getVersion(); - if (resp?.data) setVersionData(resp.data); - } catch (e) { - console.error(e); - } - }; - fetchVersion(); + dispatch(fetchVersionData()); }, [dispatch]); const handleAtlasLogoClick = useCallback(() => { @@ -384,7 +390,7 @@ const SideBarBody = (props: { {!open && (
- {/* Entities */} - - - handlePopoverOpen(e, "entities")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - entities - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
- - {/* Classifications */} - - - handlePopoverOpen(e, "classification")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - classifications - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
- - {/* Glossary */} - - - handlePopoverOpen(e, "glossary")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - glossary - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
- - {/* Business Metadata */} - - - handlePopoverOpen(e, "businessMetadata")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - business metadata - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
- - {/* Relationships */} - {relationshipSearch && ( - <> - - - handlePopoverOpen(e, "relationships")} sx={{ color: isRelationshipActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> - - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
- - )} - - {/* Custom Filters */} - - - handlePopoverOpen(e, "customFilters")} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - custom filters - - - - - {renderPopoverSearch()} -
- }> -
- -
-
- -
-
+ {modules.filter(m => m.isVisible).map(m => ( + + + handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + {m.title.toLowerCase()} + + + + ))} + + + {renderPopoverSearch()} +
+ }> +
+ {modules.filter(m => m.isVisible && activePopover === m.id).map(m => { + const Component = m.Component; + return ; + })} +
+
+
+
)} @@ -681,84 +547,88 @@ const SideBarBody = (props: { }), }} > -
- } - > - - -
- -
- } - > - - -
- -
- } - > - - -
+ {open && ( + <> +
+ } + > + + +
+ +
+ } + > + + +
+ +
+ } + > + + +
+ +
+ } + > + + +
+ {relationshipSearch && ( +
+ } + > + + +
+ )} -
- } - > - - -
- {relationshipSearch && ( -
- } +
- - -
+ } + > + + +
+ )} - -
- } - > - - -
- V {versionData?.Version || '3.12.1.0'} + {versionData?.Version ? `V ${versionData.Version}` : ''} )} diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index ebc8e21574e..c0081cb7ad3 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -1094,6 +1094,7 @@ const BarTreeView: FC<{ {treeName === "Business MetaData" && } {treeName === "Glossary" && } {treeName === "CustomFilters" && } + {treeName === "Relationships" && } {displayTreeName} diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index ca6d310eab6..988c8ba10eb 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -181,6 +181,10 @@ describe('SideBarBody', () => { entity: () => ({ loading: false, entityData: {} + }), + session: () => ({ + sessionObj: { loading: false, data: null, error: null }, + versionData: { loading: false, data: null, error: null } }) } }); @@ -537,7 +541,7 @@ describe('SideBarBody', () => { fireEvent.click(toggleButton!); await waitFor(() => { - expect(screen.getByTestId('entities-tree')).toHaveTextContent('Open: false'); + expect(screen.queryByTestId('entities-tree')).not.toBeInTheDocument(); }); }); }); @@ -637,4 +641,85 @@ describe('SideBarBody', () => { expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); }); }); + + describe('Collapsed Sidebar Popovers', () => { + beforeEach(() => { + // Start with closed drawer to see popover icons + renderWithProviders(); + const toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + }); + + it('should open correct popover when module icon is clicked', async () => { + // Find the glossary icon and click it + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + // Popover should render the glossary tree + const glossaryTrees = screen.getAllByTestId('glossary-tree'); + expect(glossaryTrees.length).toBeGreaterThan(0); + }); + }); + + it('should share search term between sidebar and popover', async () => { + // Re-open sidebar to access main search input + const toggleOpenButton = screen.getByTestId('KeyboardDoubleArrowRightIcon').closest('button'); + fireEvent.click(toggleOpenButton!); + + // Set search term in the main search bar + const searchInput = screen.getAllByPlaceholderText('Search')[0]; + fireEvent.change(searchInput, { target: { value: 'popover_search' } }); + + // Close sidebar + const toggleCloseButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleCloseButton!); + + // Click entities icon + const entitiesIcon = screen.getByAltText('entities'); + fireEvent.click(entitiesIcon.closest('button')!); + + await waitFor(() => { + // Popover should receive the search term + const entitiesTree = screen.getAllByTestId('entities-tree').find( + el => el.textContent?.includes('Search: popover_search') + ); + expect(entitiesTree).toBeInTheDocument(); + }); + }); + + it('should apply active state markers correctly', async () => { + // Since our mock route is /search, isEntitiesActive should be true if type param exists, etc. + // But we can just test if the style is applied correctly to the container box based on the current state. + // We will look at the border color for the entities box which is active if type param is present. + // For this test, let's verify the tooltips exist and the buttons are rendered. + const entitiesIcon = screen.getByAltText('entities'); + expect(entitiesIcon).toBeInTheDocument(); + + const classificationsIcon = screen.getByAltText('classifications'); + expect(classificationsIcon).toBeInTheDocument(); + }); + + it('should close popover when clicking outside', async () => { + // Open glossary popover + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + expect(screen.getAllByTestId('glossary-tree').length).toBeGreaterThan(0); + }); + + // Press escape to close the popover (MUI Popover default behavior for outside click/escape) + const backdrop = document.querySelector('.MuiBackdrop-root'); + if (backdrop) { + fireEvent.click(backdrop); + } else { + fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' }); + } + + await waitFor(() => { + expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + }); + }); + }); }); From 0a1b9f1539ce78d80d5f4cc9ade2b52a1f622219 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 15 Jul 2026 16:53:25 +0530 Subject: [PATCH 03/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/SidebarSearchInput.tsx | 79 ++++++++++ .../__tests__/SidebarSearchInput.test.tsx | 67 ++++++++ dashboard/src/styles/sidebar.scss | 16 ++ dashboard/src/views/SideBar/SideBarBody.tsx | 146 +++++++++--------- 4 files changed, 235 insertions(+), 73 deletions(-) create mode 100644 dashboard/src/components/SidebarSearchInput.tsx create mode 100644 dashboard/src/components/__tests__/SidebarSearchInput.test.tsx diff --git a/dashboard/src/components/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx new file mode 100644 index 00000000000..dbdbf8811eb --- /dev/null +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ChangeEvent } from "react"; +import { Paper, InputBase, Stack } from "@mui/material"; +import ClearIcon from "@mui/icons-material/Clear"; +import { IconButton } from "@components/muiComponents"; + +interface SidebarSearchInputProps { + searchTerm: string; + onChange: (value: string) => void; + dataCy?: string; +} + +export const SidebarSearchInput: React.FC = ({ + searchTerm, + onChange, + dataCy +}) => ( + + ) => onChange(e.target.value)} + data-cy={dataCy} + endAdornment={ + + {searchTerm.length > 0 && ( + onChange("")} + edge="end" + sx={{ padding: "4px" }} + > + + + )} + Search + + } + /> + +); diff --git a/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx new file mode 100644 index 00000000000..05c8a90d633 --- /dev/null +++ b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. "See the License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { SidebarSearchInput } from "../SidebarSearchInput"; + +describe("SidebarSearchInput Component", () => { + it("should render correctly with empty search term and not show clear icon", () => { + render(); + const input = screen.getByPlaceholderText("Search"); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(""); + + // ClearIcon should not be in the document + expect(screen.queryByTestId("ClearIcon")).not.toBeInTheDocument(); + }); + + it("should render correctly with search term and show clear icon", () => { + render(); + const input = screen.getByPlaceholderText("Search"); + expect(input).toHaveValue("test query"); + + // ClearIcon should be visible + expect(screen.getByTestId("ClearIcon")).toBeInTheDocument(); + }); + + it("should invoke onChange prop when user types", () => { + const mockOnChange = jest.fn(); + render(); + const input = screen.getByPlaceholderText("Search"); + + fireEvent.change(input, { target: { value: "h" } }); + expect(mockOnChange).toHaveBeenCalledWith("h"); + }); + + it("should invoke onChange with empty string when clear button is clicked", () => { + const mockOnChange = jest.fn(); + render(); + + const clearButton = screen.getByRole("button"); + fireEvent.click(clearButton); + expect(mockOnChange).toHaveBeenCalledWith(""); + }); + + it("should support data-cy prop for cypress testing", () => { + render(); + const input = screen.getByPlaceholderText("Search"); + const container = input.closest(".MuiInputBase-root"); + expect(container).toHaveAttribute("data-cy", "my-search-input"); + }); +}); diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index ffa05952f07..16a2e6df60c 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -237,4 +237,20 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m .sidebar-menu-item { padding: 4px 10px !important; +} + +.sidebar-module-icon { + width: 20px !important; + height: 20px !important; +} + +.sidebar-popover-search { + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin-bottom: 4px; +} + +.sidebar-icon-active { + border-left: 4px solid #2ccebb !important; + background: rgba(255, 255, 255, 0.08) !important; } \ No newline at end of file diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index b41a57e84f6..cba2e8c71e3 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -21,13 +21,13 @@ import { useCallback, useEffect, useState, - ChangeEvent, KeyboardEvent, lazy, useRef, useMemo, } from "react"; import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; +import { SidebarSearchInput } from "@components/SidebarSearchInput"; import atlasLogo from "/img/atlas_logo.svg"; import apacheAtlasLogo from "/img/apache-atlas-logo.svg"; import { @@ -42,9 +42,7 @@ import Drawer from "@mui/material/Drawer"; import CssBaseline from "@mui/material/CssBaseline"; import { IconButton } from "@components/muiComponents"; -import ClearIcon from "@mui/icons-material/Clear"; -import { getVersion } from "@api/apiMethods/headerApiMethods"; -import { InputBase, Paper, Stack, Box, Popover, Typography, Tooltip, CircularProgress } from "@mui/material"; +import { Paper, Stack, Box, Popover, Typography, Tooltip, CircularProgress } from "@mui/material"; import { globalSessionData, PathAssociateWithModule } from "@utils/Enum"; import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft"; import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; @@ -103,6 +101,7 @@ const DrawerHeader = styled("div")(({ theme }) => ({ marginBottom: "1rem", })); + const SideBarBody = (props: { handleOpenModal: any; handleOpenAboutModal: any; @@ -143,6 +142,7 @@ const SideBarBody = (props: { const [popoverAnchor, setPopoverAnchor] = useState(null); const [activePopover, setActivePopover] = useState(null); const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); + const [isBottomHalf, setIsBottomHalf] = useState(false); const handlePopoverOpen = (event: React.MouseEvent, id: string) => { setPopoverAnchor(event.currentTarget); @@ -150,9 +150,16 @@ const SideBarBody = (props: { // Calculate remaining screen height from the anchor to the bottom const rect = event.currentTarget.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top - 24; // 24px margin from bottom - // Give it a minimum sensible height of 300px just in case, otherwise use available space - setPopoverMaxHeight(`${Math.max(300, spaceBelow)}px`); + const spaceBelow = window.innerHeight - rect.top - 24; + const isBottom = spaceBelow < 350; + setIsBottomHalf(isBottom); + + if (isBottom) { + const spaceAbove = rect.bottom - 24; + setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`); + } else { + setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`); + } }; const handlePopoverClose = () => { @@ -163,31 +170,8 @@ const SideBarBody = (props: { const renderPopoverSearch = () => ( -
- - ) => setSearchTerm(e.target.value)} - endAdornment={ - - {searchTerm.length > 0 && ( - setSearchTerm("")} - edge="end" - sx={{ padding: "4px" }} - > - - - )} - Search - - } - /> - +
+
); @@ -429,16 +413,26 @@ const SideBarBody = (props: { setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - search + search {modules.filter(m => m.isVisible).map(m => ( - + handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> - {m.title.toLowerCase()} + {m.title.toLowerCase()} @@ -446,13 +440,48 @@ const SideBarBody = (props: { {renderPopoverSearch()}
@@ -497,40 +526,11 @@ const SideBarBody = (props: { data-cy="atlas-logo" /> - - ) => { - setSearchTerm(e.target.value); - }} - data-cy="searchNode" - endAdornment={ - - {searchTerm.length > 0 && ( - setSearchTerm("")} - edge="end" - sx={{ padding: "4px" }} - > - - - )} - Search - - } - /> - + )} From c01569d2bcf4b6788d4997cd7ab3f20d64a51d55 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Mon, 27 Jul 2026 18:09:43 +0530 Subject: [PATCH 04/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- dashboard/src/styles/sidebar.scss | 88 ++++++++++++++++++- dashboard/src/views/SideBar/SideBarBody.tsx | 78 +++------------- .../views/SideBar/SideBarTree/SideBarTree.tsx | 55 ++++++------ 3 files changed, 129 insertions(+), 92 deletions(-) diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 16a2e6df60c..c9bfb613c0e 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -253,4 +253,90 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m .sidebar-icon-active { border-left: 4px solid #2ccebb !important; background: rgba(255, 255, 255, 0.08) !important; -} \ No newline at end of file +} +.layout-header-container { + display: flex; + justify-content: space-between; + background-color: white; + height: 56px; + align-items: center; + padding: 16px; +} + +.layout-content-container { + padding: 16px; + display: flex; + flex: 1; + flex-direction: column; +} + +.layout-loading-container { + left: 0; + top: 0; + width: 100%; + height: calc(100vh - 88px); + position: relative; +} + +.collapsed-logo-container { + width: 100%; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + min-height: 64px; + cursor: pointer; + box-sizing: border-box; + margin-bottom: 1rem; +} + +.collapsed-logo-img { + width: 29px; + height: auto; + max-width: 100%; + display: block; +} + +.sidebar-module-icon-container { + flex: 1; + overflow: auto; +} + +.sidebar-toggle-container { + width: 100%; + padding: 8px; + position: sticky; + bottom: 0px; + z-index: 9; + left: 0; + background: #034858; + display: flex; + align-items: center; +} + +.sidebar-toggle-open { + flex-direction: row; + justify-content: space-between; + gap: 0px; +} + +.sidebar-toggle-closed { + flex-direction: column; + justify-content: center; + gap: 4px; +} + +.sidebar-tree-highlight { + color: #D3D3D3; + font-weight: 600; +} + +.sidebar-tree-icon { + width: 20px; + height: 20px; + opacity: 1; +} + +.sidebar-tree-label-nowrap { + white-space: nowrap; +} diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index cba2e8c71e3..633b5d37c01 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -103,8 +103,8 @@ const DrawerHeader = styled("div")(({ theme }) => ({ const SideBarBody = (props: { - handleOpenModal: any; - handleOpenAboutModal: any; + handleOpenModal: () => void; + handleOpenAboutModal: () => void; }) => { const location = useLocation(); const routes = useRoutes(AppRoutes as RouteObject[]); @@ -115,7 +115,7 @@ const SideBarBody = (props: { const { relationshipSearch = {} } = globalSessionData || {}; const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const { data: versionData } = useAppSelector((state: any) => state.session?.versionData || {}); + const { data: versionData } = useAppSelector((state) => state.session?.versionData || {}); const searchParams = new URLSearchParams(location.search); const isCustomFilterActive = searchParams.get("isCF") === "true"; @@ -258,16 +258,7 @@ const SideBarBody = (props: { const rightSideContent = useMemo(() => ( -
+
-
+
{isMatched || location.pathname.includes("!") ? ( +
@@ -413,7 +374,7 @@ const SideBarBody = (props: { setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> - search + search @@ -432,7 +393,7 @@ const SideBarBody = (props: { > handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> - {m.title.toLowerCase()} + {m.title.toLowerCase()} @@ -484,9 +445,9 @@ const SideBarBody = (props: { }} > {renderPopoverSearch()} -
+
}> -
+
{modules.filter(m => m.isVisible && activePopover === m.id).map(m => { const Component = m.Component; return ; @@ -631,20 +592,7 @@ const SideBarBody = (props: { )}
{open && ( diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index c0081cb7ad3..23454439ebb 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -81,26 +81,32 @@ import { IconButton } from "@components/muiComponents"; import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; type CustomContentRootProps = HTMLAttributes & { - selectedNodeType?: any; - selectedNodeTag?: any; - selectedNodeRelationship?: any; - selectedNodeBM?: any; - selectedNodeTerm?: any; - selectedNodeCustomFilter?: any; - node?: any; - selectedNode?: any; + selectedNodeType?: string | null; + selectedNodeTag?: string | null; + selectedNodeRelationship?: string | null; + selectedNodeBM?: string | null; + selectedNodeTerm?: string | null; + selectedNodeCustomFilter?: string | null; + node?: Record | null; + selectedNode?: Record | null; }; -const HoverableTreeItemContainer = ({ children, ...props }: any) => { +import { Box } from "@mui/material"; + +type HoverableProps = HTMLAttributes & { + children?: React.ReactNode | ((isHovered: boolean) => React.ReactNode); +}; + +const HoverableTreeItemContainer = ({ children, ...props }: HoverableProps) => { const [isHovered, setIsHovered] = useState(false); return ( -
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} {...props} > {typeof children === "function" ? children(isHovered) : children} -
+
); }; @@ -312,8 +318,8 @@ const BarTreeView: FC<{ loader, isPopover, }) => { - const { savedSearchData }: any = useAppSelector( - (state: any) => state.savedSearch + const { savedSearchData } = useAppSelector( + (state) => state.savedSearch ); const { bmguid } = useParams(); const dispatch = useAppDispatch(); @@ -343,8 +349,8 @@ const BarTreeView: FC<{ const [expandedItems, setExpandedItems] = useState([]); const [tagModal, setTagModal] = useState(false); const [glossaryModal, setGlossaryModal] = useState(false); - const { businessMetaData }: any = useAppSelector( - (state: any) => state.businessMetaData + const { businessMetaData } = useAppSelector( + (state) => state.businessMetaData as { businessMetaData?: { businessMetadataDefs?: EnumTypeDefData[] } } ); const filteredData = useMemo(() => { @@ -370,7 +376,7 @@ const BarTreeView: FC<{ const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); return parts.map((part, index) => part.toLowerCase() === searchTerm.toLowerCase() ? ( - + {part} ) : ( @@ -960,10 +966,7 @@ const BarTreeView: FC<{ {highlightText(label)} @@ -1089,12 +1092,12 @@ const BarTreeView: FC<{ className="tree-item-parent-label" > - {treeName === "Entities" && } - {treeName === "Classifications" && } - {treeName === "Business MetaData" && } - {treeName === "Glossary" && } - {treeName === "CustomFilters" && } - {treeName === "Relationships" && } + {treeName === "Entities" && } + {treeName === "Classifications" && } + {treeName === "Business MetaData" && } + {treeName === "Glossary" && } + {treeName === "CustomFilters" && } + {treeName === "Relationships" && } {displayTreeName} From 415237c2b0ad98d7eb7e73d6440ee1b2959f2bdd Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 31 Jul 2026 10:59:36 +0530 Subject: [PATCH 05/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/__tests__/SidebarSearchInput.test.tsx | 1 - dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx index 05c8a90d633..06714b07c31 100644 --- a/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx +++ b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx @@ -4,7 +4,6 @@ * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with - * the License. "See the License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 23454439ebb..deaccbfaf92 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -350,7 +350,7 @@ const BarTreeView: FC<{ const [tagModal, setTagModal] = useState(false); const [glossaryModal, setGlossaryModal] = useState(false); const { businessMetaData } = useAppSelector( - (state) => state.businessMetaData as { businessMetaData?: { businessMetadataDefs?: EnumTypeDefData[] } } + (state) => state.businessMetaData as unknown as { businessMetaData?: { businessMetadataDefs?: EnumTypeDefData[] } } ); const filteredData = useMemo(() => { @@ -428,7 +428,7 @@ const BarTreeView: FC<{ } }) : {}; - const { name = "" } = bmObj || {}; + const { name = "" } = (bmObj || {}) as { name?: string }; setSelectedNode({ type: nodeIdFromParamsType, From b1f53987f82c2aa75368233ba9ee78d484aaff4e Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 5 Aug 2026 16:33:38 +0530 Subject: [PATCH 06/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/EntityDisplayImage.tsx | 10 ++- .../src/components/SidebarSearchInput.tsx | 1 - dashboard/src/styles/sidebar.scss | 2 +- .../src/views/Layout/__tests__/About.test.tsx | 13 +++ dashboard/src/views/SideBar/SideBarBody.tsx | 83 +++++++----------- .../views/SideBar/SideBarTree/SideBarTree.tsx | 38 ++++++--- .../__tests__/SideBarTree.test.tsx | 84 ++++++++++++++++++- .../SideBar/__tests__/SideBarBody.test.tsx | 53 +++++++++--- 8 files changed, 202 insertions(+), 82 deletions(-) diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index 91e83398e54..c95ec307214 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -18,13 +18,21 @@ import { Avatar } from "@mui/material"; import { getEntityIconPath } from "../utils/Utils"; +interface DisplayImageProps { + entity: Record; + width?: string | number; + height?: string | number; + avatarDisplay?: boolean; + isProcess?: boolean; +} + const DisplayImage = ({ entity, width, height, avatarDisplay, isProcess -}: any) => { +}: DisplayImageProps) => { const entityData = { ...entity, isProcess: isProcess }; const primaryUrl = getEntityIconPath({ entityData }) || ""; diff --git a/dashboard/src/components/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx index dbdbf8811eb..77f927855f7 100644 --- a/dashboard/src/components/SidebarSearchInput.tsx +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -67,7 +67,6 @@ export const SidebarSearchInput: React.FC = ({ height: "16px", filter: "brightness(0.4)", opacity: 1, - cursor: "pointer", marginLeft: "4px" }} alt="Search" diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index c9bfb613c0e..459d2accb5d 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -209,7 +209,7 @@ } .tree-item-parent-label { - border-bottom: "1px solid rgba(25,255,255,0.1)"; + border-bottom: 1px solid rgba(25,255,255,0.1); } diff --git a/dashboard/src/views/Layout/__tests__/About.test.tsx b/dashboard/src/views/Layout/__tests__/About.test.tsx index 6defb435606..b82d024b5f6 100644 --- a/dashboard/src/views/Layout/__tests__/About.test.tsx +++ b/dashboard/src/views/Layout/__tests__/About.test.tsx @@ -181,4 +181,17 @@ describe('About', () => { expect(versionTypography.textContent).toContain('Version:') expect(versionTypography.textContent).not.toContain('undefined') }) + + it('should render gracefully on Redux error state', () => { + useAppSelectorSpy.mockReturnValue({ data: null, loading: false, error: 'Network error' }) + + render() + + // Verify no skeleton is stuck + expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() + + // Verify no crash, UI still renders + expect(screen.getByText(/Version:/i)).toBeInTheDocument() + expect(screen.getByText('Get involved!')).toBeInTheDocument() + }) }) diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 633b5d37c01..98ad3913ebf 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -145,20 +145,32 @@ const SideBarBody = (props: { const [isBottomHalf, setIsBottomHalf] = useState(false); const handlePopoverOpen = (event: React.MouseEvent, id: string) => { - setPopoverAnchor(event.currentTarget); - setActivePopover(id); - - // Calculate remaining screen height from the anchor to the bottom - const rect = event.currentTarget.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top - 24; - const isBottom = spaceBelow < 350; - setIsBottomHalf(isBottom); - - if (isBottom) { - const spaceAbove = rect.bottom - 24; - setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`); + const target = event.currentTarget; + + const openNewPopover = () => { + setPopoverAnchor(target); + setActivePopover(id); + + // Calculate remaining screen height from the anchor to the bottom + const rect = target.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.top - 24; + const isBottom = spaceBelow < 350; + setIsBottomHalf(isBottom); + + if (isBottom) { + const spaceAbove = rect.bottom - 24; + setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`); + } else { + setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`); + } + }; + + // If a different popover is already open, close it first to ensure clean unmount + if (activePopover && activePopover !== id) { + handlePopoverClose(); + setTimeout(openNewPopover, 0); } else { - setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`); + openNewPopover(); } }; @@ -175,34 +187,7 @@ const SideBarBody = (props: {
); - const [position, setPosition] = useState(defaultDrawerWidth); - const draggerRef = useRef(null); const headerRef = useRef(null); - const windowWidth = window.innerWidth; - const minPosition = 300; - const maxPosition = windowWidth * 0.6; - - const handleMouseMove = (e: MouseEvent) => { - let newPosition = e.clientX; - - if (newPosition < minPosition) { - newPosition = minPosition; - } else if (newPosition > maxPosition) { - newPosition = maxPosition; - } - - setPosition(newPosition); - }; - - const handleMouseUp = () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - - const handleMouseDown = () => { - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }; useEffect(() => { dispatch(fetchTypeHeaderData()); @@ -234,15 +219,7 @@ const SideBarBody = (props: { [handleAtlasLogoClick] ); - useEffect(() => { - const draggerElement = draggerRef.current; - - draggerElement?.addEventListener("mousedown", handleMouseDown); - return () => { - draggerElement?.removeEventListener("mousedown", handleMouseDown); - }; - }, []); const routeConfig = Object.keys(PathAssociateWithModule).map((key) => { return { @@ -308,7 +285,7 @@ const SideBarBody = (props: { - setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> search @@ -502,7 +479,7 @@ const SideBarBody = (props: { overflowX: "hidden", overflowY: "auto", paddingBottom: "48px", // Added space so it doesn't touch the bottom toggle button - ...(open == false && { + ...(!open && { overflow: "hidden", display: "none", }), diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index deaccbfaf92..f87f06db068 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -354,15 +354,32 @@ const BarTreeView: FC<{ ); const filteredData = useMemo(() => { - return treeData.filter((node) => { - return ( - node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || - (node.children && - node.children.some((child) => - child.label?.toLowerCase().includes(searchTerm.toLowerCase()) - )) - ); - }); + if (!searchTerm) return treeData; + + const lowerTerm = searchTerm.toLowerCase(); + + const filterNodes = (nodes: TreeNode[]): TreeNode[] => { + return nodes.reduce((acc: TreeNode[], node) => { + const isMatch = node.label?.toLowerCase().includes(lowerTerm); + let filteredChildren: TreeNode[] | undefined = undefined; + + if (node.children) { + filteredChildren = filterNodes(node.children); + } + + const hasMatchingChildren = filteredChildren && filteredChildren.length > 0; + + if (isMatch || hasMatchingChildren) { + acc.push({ + ...node, + children: isMatch ? node.children : filteredChildren + }); + } + return acc; + }, []); + }; + + return filterNodes(treeData); }, [treeData, searchTerm]); const displayTreeName = useMemo(() => { @@ -373,7 +390,8 @@ const BarTreeView: FC<{ return (text: string) => { if (!searchTerm) return text; - const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const parts = text.split(new RegExp(`(${escapedSearchTerm})`, "gi")); return parts.map((part, index) => part.toLowerCase() === searchTerm.toLowerCase() ? ( diff --git a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx index 874bcaafb56..1035e5cfef7 100644 --- a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx @@ -120,6 +120,12 @@ jest.mock('@components/SkeletonLoader', () => { } }) +jest.mock('@components/TreeSkeletonLoader', () => { + return function MockTreeSkeletonLoader(props: any) { + return
Loading Tree Skeleton...
+ } +}) + // Mock MUI X Tree components - following pattern from FormTreeView.test.tsx jest.mock('@mui/x-tree-view', () => { const React = require('react') @@ -167,7 +173,7 @@ jest.mock('@components/muiComponents', () => ({ MoreVertIcon: ({ onClick, ...props }: any) => (
More
), - LightTooltip: ({ children, title }: any) =>
{children}
, + LightTooltip: ({ children, title, disableHoverListener }: any) =>
{children}
, FileDownloadIcon: () =>
Download
, FormatListBulletedIcon: () =>
List
, AccountTreeIcon: ({ onClick, ...props }: any) => ( @@ -332,7 +338,7 @@ describe('SideBarTree', () => { renderComponent({ loader: true }) await waitFor(() => { - expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0) + expect(screen.getAllByTestId('tree-skeleton-loader').length).toBeGreaterThan(0) }) }) @@ -340,7 +346,7 @@ describe('SideBarTree', () => { renderComponent({ loader: false }) await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() + expect(screen.queryByTestId('tree-skeleton-loader')).not.toBeInTheDocument() }) }) }) @@ -2182,4 +2188,76 @@ describe('SideBarTree', () => { expect(mockSetIsEmptyServicetype).not.toHaveBeenCalled() }) }) + + describe('Reviewer Requested Tests', () => { + it('should render skeleton loader with 2 rows when isPopover is true', async () => { + renderComponent({ loader: true, isPopover: true }) + + await waitFor(() => { + const loader = screen.getByTestId('tree-skeleton-loader') + expect(loader).toBeInTheDocument() + expect(loader).toHaveAttribute('data-count', '2') + }) + }) + + it('should persist selected state from URL params for custom filters when reopened in popover', async () => { + const treeData = [ + { id: 'customFilter1', label: 'Custom Filter 1', children: [] } + ] + renderComponent({ + treeData, + treeName: 'CustomFilters', + isPopover: true + }, {}, ['/search/searchResult?searchType=BASIC&isCF=true&type=customFilter1']) + + await waitFor(() => { + const treeView = screen.getByTestId('simple-tree-view') + expect(treeView).toBeInTheDocument() + const expandedItems = JSON.parse(treeView.getAttribute('data-expanded-items') || '[]') + expect(expandedItems).toContain('customFilter1') + }) + }) + + it('should enable tooltip when text overflows (scrollWidth > clientWidth)', async () => { + // Mock HTMLElement properties + const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth') + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, value: 200 }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 100 }) + + renderComponent({ treeData: [{ id: 'node1', label: 'Long Text Node', children: [] }] }) + + await waitFor(() => { + const tooltips = screen.getAllByTestId('light-tooltip') + const tooltip = tooltips.find(t => t.getAttribute('title') === 'Long Text Node') + expect(tooltip).toBeDefined() + expect(tooltip).toHaveAttribute('data-disabled', 'false') + }) + + // Restore + if (originalScrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', originalScrollWidth) + if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth) + }) + + it('should disable tooltip when text fits (scrollWidth <= clientWidth)', async () => { + const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth') + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, value: 100 }) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 100 }) + + renderComponent({ treeData: [{ id: 'node1', label: 'Short Text Node', children: [] }] }) + + await waitFor(() => { + const tooltips = screen.getAllByTestId('light-tooltip') + const tooltip = tooltips.find(t => t.getAttribute('title') === 'Short Text Node') + expect(tooltip).toBeDefined() + expect(tooltip).toHaveAttribute('data-disabled', 'true') + }) + + if (originalScrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', originalScrollWidth) + if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth) + }) + }) }) diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 988c8ba10eb..d31b70db7d6 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -26,6 +26,7 @@ import * as rootClassificationSlice from '@redux/slice/rootClassificationSlice'; import * as typeDefHeaderSlice from '@redux/slice/typeDefSlices/typeDefHeaderSlice'; import * as allEntityTypesSlice from '@redux/slice/allEntityTypesSlice'; import * as metricsSlice from '@redux/slice/metricsSlice'; +import * as sessionSlice from '@redux/slice/sessionSlice'; // Mock react-quill-new jest.mock('react-quill-new', () => { @@ -136,6 +137,7 @@ jest.mock('@redux/slice/rootClassificationSlice'); jest.mock('@redux/slice/typeDefSlices/typeDefHeaderSlice'); jest.mock('@redux/slice/allEntityTypesSlice'); jest.mock('@redux/slice/metricsSlice'); +jest.mock('@redux/slice/sessionSlice'); // Mock utils jest.mock('@utils/Enum', () => ({ @@ -154,7 +156,7 @@ const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, - useLocation: () => ({ pathname: '/search' }), + useLocation: () => (global as any).mockLocation || { pathname: '/search', search: '' }, useRoutes: () => null, matchRoutes: () => [{ route: { path: '/search' } }], Outlet: () =>
Outlet Content
@@ -212,6 +214,7 @@ describe('SideBarBody', () => { (typeDefHeaderSlice.fetchTypeHeaderData as jest.Mock) = jest.fn().mockReturnValue(mockDispatch); (allEntityTypesSlice.fetchRootEntity as jest.Mock) = jest.fn().mockReturnValue(mockDispatch); (metricsSlice.fetchMetricEntity as jest.Mock) = jest.fn().mockReturnValue(mockDispatch); + (sessionSlice.fetchVersionData as jest.Mock) = jest.fn().mockReturnValue(mockDispatch); }); afterEach(() => { @@ -459,6 +462,12 @@ describe('SideBarBody', () => { expect(metricsSlice.fetchMetricEntity).toHaveBeenCalled(); }); + it('should dispatch fetchVersionData on mount', () => { + renderWithProviders(); + + expect(sessionSlice.fetchVersionData).toHaveBeenCalled(); + }); + it('should pass loading state to tree components', () => { const store = createMockStore({ loading: true }); renderWithProviders(defaultProps, { store }); @@ -688,18 +697,6 @@ describe('SideBarBody', () => { }); }); - it('should apply active state markers correctly', async () => { - // Since our mock route is /search, isEntitiesActive should be true if type param exists, etc. - // But we can just test if the style is applied correctly to the container box based on the current state. - // We will look at the border color for the entities box which is active if type param is present. - // For this test, let's verify the tooltips exist and the buttons are rendered. - const entitiesIcon = screen.getByAltText('entities'); - expect(entitiesIcon).toBeInTheDocument(); - - const classificationsIcon = screen.getByAltText('classifications'); - expect(classificationsIcon).toBeInTheDocument(); - }); - it('should close popover when clicking outside', async () => { // Open glossary popover const glossaryIcon = screen.getByAltText('glossary'); @@ -722,4 +719,34 @@ describe('SideBarBody', () => { }); }); }); + + describe('Active State Markers', () => { + it('should apply active state markers correctly', async () => { + // Test Entities active (type param present but isCF is not true) + (global as any).mockLocation = { pathname: '/search', search: '?type=table' }; + const { unmount: unmount1 } = renderWithProviders(); + let toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let entitiesIcon = screen.getByAltText('entities'); + expect(entitiesIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + unmount1(); + + // Test Custom Filters active (isCF=true) + (global as any).mockLocation = { pathname: '/search', search: '?isCF=true&type=myFilter' }; + const { unmount: unmount2 } = renderWithProviders(); + toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let customFiltersIcon = screen.getByAltText('custom filters'); + expect(customFiltersIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + + // Entities should NOT be active if isCF=true + entitiesIcon = screen.getByAltText('entities'); + expect(entitiesIcon.closest('.sidebar-icon-active')).not.toBeInTheDocument(); + unmount2(); + + (global as any).mockLocation = undefined; + }); + }); }); From fc244678ed37fd3ba70411a666e1050f8e6e323b Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Tue, 11 Aug 2026 22:49:03 +0530 Subject: [PATCH 07/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../img/sidebar-icons/icon-business-metadata.svg | 16 ++++++++++++++++ .../img/sidebar-icons/icon-classifications.svg | 16 ++++++++++++++++ .../img/sidebar-icons/icon-custom-filters.svg | 16 ++++++++++++++++ .../public/img/sidebar-icons/icon-entities.svg | 16 ++++++++++++++++ .../public/img/sidebar-icons/icon-glossary.svg | 16 ++++++++++++++++ .../img/sidebar-icons/icon-relationships.svg | 16 ++++++++++++++++ .../public/img/sidebar-icons/icon-search.svg | 16 ++++++++++++++++ dashboard/src/components/EntityDisplayImage.tsx | 8 ++++---- .../src/components/GlobalSearch/QuickSearch.tsx | 2 +- .../views/SideBar/SideBarTree/SideBarTree.tsx | 9 +++++---- 10 files changed, 122 insertions(+), 9 deletions(-) diff --git a/dashboard/public/img/sidebar-icons/icon-business-metadata.svg b/dashboard/public/img/sidebar-icons/icon-business-metadata.svg index 7eb1bad564a..96236aa299c 100644 --- a/dashboard/public/img/sidebar-icons/icon-business-metadata.svg +++ b/dashboard/public/img/sidebar-icons/icon-business-metadata.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-classifications.svg b/dashboard/public/img/sidebar-icons/icon-classifications.svg index f193f878ef6..90014ebf636 100644 --- a/dashboard/public/img/sidebar-icons/icon-classifications.svg +++ b/dashboard/public/img/sidebar-icons/icon-classifications.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-custom-filters.svg b/dashboard/public/img/sidebar-icons/icon-custom-filters.svg index a34dae1ce7b..78f54253444 100644 --- a/dashboard/public/img/sidebar-icons/icon-custom-filters.svg +++ b/dashboard/public/img/sidebar-icons/icon-custom-filters.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-entities.svg b/dashboard/public/img/sidebar-icons/icon-entities.svg index 86f5adce5c4..873caab615b 100644 --- a/dashboard/public/img/sidebar-icons/icon-entities.svg +++ b/dashboard/public/img/sidebar-icons/icon-entities.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-glossary.svg b/dashboard/public/img/sidebar-icons/icon-glossary.svg index 701ee70b964..889c0ed4cd0 100644 --- a/dashboard/public/img/sidebar-icons/icon-glossary.svg +++ b/dashboard/public/img/sidebar-icons/icon-glossary.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-relationships.svg b/dashboard/public/img/sidebar-icons/icon-relationships.svg index bab762dcebf..d2f02163e37 100644 --- a/dashboard/public/img/sidebar-icons/icon-relationships.svg +++ b/dashboard/public/img/sidebar-icons/icon-relationships.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/public/img/sidebar-icons/icon-search.svg b/dashboard/public/img/sidebar-icons/icon-search.svg index 5904b7e6a31..fe7ff9f66f2 100644 --- a/dashboard/public/img/sidebar-icons/icon-search.svg +++ b/dashboard/public/img/sidebar-icons/icon-search.svg @@ -1,3 +1,19 @@ + diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index c95ec307214..21fed4c41af 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -19,7 +19,7 @@ import { Avatar } from "@mui/material"; import { getEntityIconPath } from "../utils/Utils"; interface DisplayImageProps { - entity: Record; + entity: Record; width?: string | number; height?: string | number; avatarDisplay?: boolean; @@ -48,11 +48,11 @@ const DisplayImage = ({ return (
- {avatarDisplay == undefined ? ( + {avatarDisplay === undefined ? ( Entity Icon { } > {types === "Entities" && !isEmpty(entityObj) && ( - + )} {types === "Entities" && !isEmpty(entityObj) ? parts.map((part, index) => ( diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index f87f06db068..f9d3d7f2527 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -27,6 +27,7 @@ import { useRef, useState, useMemo, + useCallback, SyntheticEvent, memo, } from "react"; @@ -418,14 +419,14 @@ const BarTreeView: FC<{ setExpandedItems(expandedItemsMemo); }, [expandedItemsMemo]); - const getNodeId = (node: TreeNode) => { + const getNodeId = useCallback((node: TreeNode) => { if (treeName == "Classifications" && node.types == "parent") { return node.label; } else if (treeName == "Classifications" && node.types == "child") { return `${node.id}@${node.label}`; } return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; - }; + }, [treeName]); useEffect(() => { const searchParams = new URLSearchParams(location.search); @@ -490,7 +491,7 @@ const BarTreeView: FC<{ customFilter: null, }); } - }, [location.search, treeData, treeName, businessMetaData, bmguid]); + }, [location.search, location.pathname, treeData, treeName, businessMetaData, bmguid, getNodeId]); const getEmptyTypesTitle = () => { switch (treeName) { @@ -978,7 +979,7 @@ const BarTreeView: FC<{ if (el) { setIsOverflown(el.scrollWidth > el.clientWidth); } - }, [label, searchTerm]); + }, [label]); return ( From 90f198225ddf555975da1afa0c0061232d9f3cc8 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Thu, 13 Aug 2026 16:17:53 +0530 Subject: [PATCH 08/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../views/SideBar/SideBarTree/SideBarTree.tsx | 135 +++++++----------- 1 file changed, 53 insertions(+), 82 deletions(-) diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index f9d3d7f2527..3a8d6305542 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -27,7 +27,6 @@ import { useRef, useState, useMemo, - useCallback, SyntheticEvent, memo, } from "react"; @@ -70,7 +69,7 @@ import { toast } from "react-toastify"; import { EnumTypeDefData, TreeNode } from "@models/treeStructureType"; import ImportDialog from "@components/ImportDialog"; import TreeIcons from "@components/Treeicons"; -import { useAppSelector, useAppDispatch } from "@hooks/reducerHook"; +import { useAppDispatch, useAppSelector } from "@hooks/reducerHook"; import { fetchGlossaryData } from "@redux/slice/glossarySlice"; import TreeNodeIcons from "@components/TreeNodeIcons"; import ClassificationForm from "@views/Classification/ClassificationForm"; @@ -81,6 +80,15 @@ import { IconButton } from "@components/muiComponents"; import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; +type SelectedNode = { + type: string | null; + tag: string | null; + relationship: string | null; + businessMetadata: string | null; + term: string | null; + customFilter: string | null; +}; + type CustomContentRootProps = HTMLAttributes & { selectedNodeType?: string | null; selectedNodeTag?: string | null; @@ -88,26 +96,20 @@ type CustomContentRootProps = HTMLAttributes & { selectedNodeBM?: string | null; selectedNodeTerm?: string | null; selectedNodeCustomFilter?: string | null; - node?: Record | null; - selectedNode?: Record | null; -}; - -import { Box } from "@mui/material"; - -type HoverableProps = HTMLAttributes & { - children?: React.ReactNode | ((isHovered: boolean) => React.ReactNode); + node?: string | null; + selectedNode?: SelectedNode; }; -const HoverableTreeItemContainer = ({ children, ...props }: HoverableProps) => { +const HoverableTreeItemContainer = ({ children, ...props }: { children: React.ReactNode | ((isHovered: boolean) => React.ReactNode) } & HTMLAttributes) => { const [isHovered, setIsHovered] = useState(false); return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} {...props} > {typeof children === "function" ? children(isHovered) : children} - +
); }; @@ -153,12 +155,15 @@ const CustomContentRoot = styled("div")( // borderRadius: "4px" }, }), - ...(props?.selectedNode == props?.node && { + ...((props.selectedNodeType === props.node || + props.selectedNodeTag === props.node || + props.selectedNodeRelationship === props.node || + props.selectedNodeBM === props.node || + props.selectedNodeTerm === props.node || + props.selectedNodeCustomFilter === props.node) && { "&.Mui-selected .MuiTreeItem-label": { color: "white", }, - }), - ...(props?.selectedNode == props?.node && { "&.Mui-selected & .MuiTreeItem-content svg": { color: "white", }, @@ -319,8 +324,8 @@ const BarTreeView: FC<{ loader, isPopover, }) => { - const { savedSearchData } = useAppSelector( - (state) => state.savedSearch + const { savedSearchData }: any = useAppSelector( + (state: any) => state.savedSearch ); const { bmguid } = useParams(); const dispatch = useAppDispatch(); @@ -328,14 +333,7 @@ const BarTreeView: FC<{ const navigate = useNavigate(); const searchParams = new URLSearchParams(location.search); const [expand, setExpand] = useState(null); - const [selectedNode, setSelectedNode] = useState<{ - type: string | null; - tag: string | null; - relationship: string | null; - businessMetadata: string | null; - term: string | null; - customFilter: string | null; - }>({ + const [selectedNode, setSelectedNode] = useState({ type: null, tag: null, relationship: null, @@ -350,37 +348,20 @@ const BarTreeView: FC<{ const [expandedItems, setExpandedItems] = useState([]); const [tagModal, setTagModal] = useState(false); const [glossaryModal, setGlossaryModal] = useState(false); - const { businessMetaData } = useAppSelector( - (state) => state.businessMetaData as unknown as { businessMetaData?: { businessMetadataDefs?: EnumTypeDefData[] } } + const { businessMetaData }: any = useAppSelector( + (state: any) => state.businessMetaData ); const filteredData = useMemo(() => { - if (!searchTerm) return treeData; - - const lowerTerm = searchTerm.toLowerCase(); - - const filterNodes = (nodes: TreeNode[]): TreeNode[] => { - return nodes.reduce((acc: TreeNode[], node) => { - const isMatch = node.label?.toLowerCase().includes(lowerTerm); - let filteredChildren: TreeNode[] | undefined = undefined; - - if (node.children) { - filteredChildren = filterNodes(node.children); - } - - const hasMatchingChildren = filteredChildren && filteredChildren.length > 0; - - if (isMatch || hasMatchingChildren) { - acc.push({ - ...node, - children: isMatch ? node.children : filteredChildren - }); - } - return acc; - }, []); - }; - - return filterNodes(treeData); + return treeData.filter((node) => { + return ( + node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + (node.children && + node.children.some((child) => + child.label?.toLowerCase().includes(searchTerm.toLowerCase()) + )) + ); + }); }, [treeData, searchTerm]); const displayTreeName = useMemo(() => { @@ -391,11 +372,10 @@ const BarTreeView: FC<{ return (text: string) => { if (!searchTerm) return text; - const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const parts = text.split(new RegExp(`(${escapedSearchTerm})`, "gi")); + const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); return parts.map((part, index) => part.toLowerCase() === searchTerm.toLowerCase() ? ( - + {part} ) : ( @@ -419,14 +399,14 @@ const BarTreeView: FC<{ setExpandedItems(expandedItemsMemo); }, [expandedItemsMemo]); - const getNodeId = useCallback((node: TreeNode) => { + const getNodeId = (node: TreeNode) => { if (treeName == "Classifications" && node.types == "parent") { return node.label; } else if (treeName == "Classifications" && node.types == "child") { return `${node.id}@${node.label}`; } return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; - }, [treeName]); + }; useEffect(() => { const searchParams = new URLSearchParams(location.search); @@ -447,7 +427,7 @@ const BarTreeView: FC<{ } }) : {}; - const { name = "" } = (bmObj || {}) as { name?: string }; + const { name = "" } = bmObj || {}; setSelectedNode({ type: nodeIdFromParamsType, @@ -491,7 +471,7 @@ const BarTreeView: FC<{ customFilter: null, }); } - }, [location.search, location.pathname, treeData, treeName, businessMetaData, bmguid, getNodeId]); + }, [location.search, treeData, treeName, businessMetaData, bmguid]); const getEmptyTypesTitle = () => { switch (treeName) { @@ -979,13 +959,16 @@ const BarTreeView: FC<{ if (el) { setIsOverflown(el.scrollWidth > el.clientWidth); } - }, [label]); + }, [label, searchTerm]); return ( {highlightText(label)} @@ -1111,12 +1094,11 @@ const BarTreeView: FC<{ className="tree-item-parent-label" > - {treeName === "Entities" && } - {treeName === "Classifications" && } - {treeName === "Business MetaData" && } - {treeName === "Glossary" && } - {treeName === "CustomFilters" && } - {treeName === "Relationships" && } + {treeName === "Entities" && } + {treeName === "Classifications" && } + {treeName === "Business MetaData" && } + {treeName === "Glossary" && } + {treeName === "CustomFilters" && } {displayTreeName} @@ -1281,11 +1263,6 @@ const BarTreeView: FC<{ handleClose(); }} data-cy="downloadBusinessMetadata" - disabled={ - treeName == "Glossary" && !isEmptyServicetype - ? true - : false - } className="sidebar-menu-item" > @@ -1307,11 +1284,6 @@ const BarTreeView: FC<{ handleClose(); }} data-cy="importBusinessMetadata" - disabled={ - treeName == "Glossary" && !isEmptyServicetype - ? true - : false - } className="sidebar-menu-item" > @@ -1383,13 +1355,12 @@ const BarTreeView: FC<{ onImportSuccess={ treeName == "Glossary" ? () => { - void dispatch(fetchGlossaryData()); - } + void dispatch(fetchGlossaryData()); + } : undefined } /> - {tagModal && ( From 598cd630707427ed534fd54d90ea472517826edc Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 14 Aug 2026 15:26:26 +0530 Subject: [PATCH 09/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/SidebarSearchInput.tsx | 9 +-- .../__tests__/EntityDisplayImage.test.tsx | 19 +++++ dashboard/src/styles/sidebar.scss | 8 ++ dashboard/src/views/SideBar/SideBarBody.tsx | 79 +++++++++++++++---- .../views/SideBar/SideBarTree/SideBarTree.tsx | 9 ++- 5 files changed, 98 insertions(+), 26 deletions(-) diff --git a/dashboard/src/components/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx index 77f927855f7..9103e5afa34 100644 --- a/dashboard/src/components/SidebarSearchInput.tsx +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -52,6 +52,7 @@ export const SidebarSearchInput: React.FC = ({ {searchTerm.length > 0 && ( onChange("")} edge="end" @@ -62,13 +63,7 @@ export const SidebarSearchInput: React.FC = ({ )} Search diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 71cb7cb8c76..44eb41595a3 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -69,6 +69,25 @@ describe('EntityDisplayImage', () => { expect(img?.getAttribute('src')).toBe('/icons/entity-1.png-fallback') }) + it('prevents infinite loop when fallback image also fails', () => { + const { container } = render( + + ) + + const img = container.querySelector('img')! + + // First error triggers fallback + fireEvent.error(img) + expect(img.getAttribute('src')).toBe('/icons/entity-1.png-fallback') + expect(img.onerror).toBeNull() + + // Second error (fallback failed) + fireEvent.error(img) + + // Ensure src hasn't changed again (it should still be the fallback) + expect(img.getAttribute('src')).toBe('/icons/entity-1.png-fallback') + }) + it('renders Avatar when avatarDisplay is provided and handles fallback', () => { const { container } = render( (""); - const { data: versionData } = useAppSelector((state) => state.session?.versionData || {}); + const { data: versionData, loading: isVersionLoading, error: versionError } = useAppSelector((state) => state.session?.versionData || {}); const searchParams = new URLSearchParams(location.search); - const isCustomFilterActive = searchParams.get("isCF") === "true"; - const isGlossaryActive = !isCustomFilterActive && (location.pathname.includes("/glossary") || !!searchParams.get("gtype") || !!searchParams.get("term") || !!searchParams.get("category")); - const isBusinessMetadataActive = !isCustomFilterActive && location.pathname.includes("/administrator/businessMetadata"); - const isClassificationActive = !isCustomFilterActive && (!!searchParams.get("tag") || location.pathname.includes("/tag/tagAttribute")); - const isRelationshipActive = !isCustomFilterActive && (!!searchParams.get("relationshipName") || location.pathname.includes("/relationshipDetailPage")); + const getActiveModule = () => { + if (searchParams.get("isCF") === "true") return "customFilters"; + if (location.pathname.includes("/glossary") || !!searchParams.get("gtype") || !!searchParams.get("term") || !!searchParams.get("category")) return "glossary"; + if (location.pathname.includes("/administrator/businessMetadata")) return "businessMetadata"; + if (!!searchParams.get("tag") || location.pathname.includes("/tag/tagAttribute")) return "classification"; + if (!!searchParams.get("relationshipName") || location.pathname.includes("/relationshipDetailPage")) return "relationships"; + if (!!searchParams.get("type") || location.pathname.includes("/detailPage")) return "entities"; + return null; + }; + + const activeModule = getActiveModule(); - const isEntitiesActive = !isCustomFilterActive && (!!searchParams.get("type") || location.pathname.includes("/detailPage")); + const isCustomFilterActive = activeModule === "customFilters"; + const isGlossaryActive = activeModule === "glossary"; + const isBusinessMetadataActive = activeModule === "businessMetadata"; + const isClassificationActive = activeModule === "classification"; + const isRelationshipActive = activeModule === "relationships"; + const isEntitiesActive = activeModule === "entities"; - const modules = [ + const modules = useMemo(() => [ { id: "entities", title: "Entities", isActive: isEntitiesActive, iconUrl: "/img/sidebar-icons/icon-entities.svg", Component: EntitiesTree, isVisible: true }, { id: "classification", title: "Classifications", isActive: isClassificationActive, iconUrl: "/img/sidebar-icons/icon-classifications.svg", Component: ClassificationTree, isVisible: true }, { id: "glossary", title: "Glossary", isActive: isGlossaryActive, iconUrl: "/img/sidebar-icons/icon-glossary.svg", Component: GlossaryTree, isVisible: true }, { id: "businessMetadata", title: "Business Metadata", isActive: isBusinessMetadataActive, iconUrl: "/img/sidebar-icons/icon-business-metadata.svg", Component: BusinessMetadataTree, isVisible: true }, { id: "relationships", title: "Relationships", isActive: isRelationshipActive, iconUrl: "/img/sidebar-icons/icon-relationships.svg", Component: RelationshipsTree, isVisible: !!relationshipSearch }, { id: "customFilters", title: "Custom Filters", isActive: isCustomFilterActive, iconUrl: "/img/sidebar-icons/icon-custom-filters.svg", Component: CustomFiltersTree, isVisible: true } - ]; - - const handleDrawerOpen = () => { - setOpen(!open); - }; + ], [ + isEntitiesActive, + isClassificationActive, + isGlossaryActive, + isBusinessMetadataActive, + isRelationshipActive, + isCustomFilterActive, + relationshipSearch + ]); const [popoverAnchor, setPopoverAnchor] = useState(null); const [activePopover, setActivePopover] = useState(null); const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); const [isBottomHalf, setIsBottomHalf] = useState(false); + const popoverTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (popoverTimeoutRef.current) { + clearTimeout(popoverTimeoutRef.current); + } + }; + }, []); const handlePopoverOpen = (event: React.MouseEvent, id: string) => { const target = event.currentTarget; @@ -165,10 +189,16 @@ const SideBarBody = (props: { } }; + // Clear any pending timeout from rapid clicks + if (popoverTimeoutRef.current) { + clearTimeout(popoverTimeoutRef.current); + popoverTimeoutRef.current = null; + } + // If a different popover is already open, close it first to ensure clean unmount if (activePopover && activePopover !== id) { handlePopoverClose(); - setTimeout(openNewPopover, 0); + popoverTimeoutRef.current = setTimeout(openNewPopover, 0); } else { openNewPopover(); } @@ -179,6 +209,13 @@ const SideBarBody = (props: { setActivePopover(null); }; + const handleDrawerOpen = () => { + setOpen(!open); + if (!open) { + handlePopoverClose(); + } + }; + const renderPopoverSearch = () => ( @@ -350,7 +387,7 @@ const SideBarBody = (props: { {/* Search */} - setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + { setOpen(true); handlePopoverClose(); }} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> search @@ -369,7 +406,7 @@ const SideBarBody = (props: { }} > - handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> {m.title.toLowerCase()} @@ -574,7 +611,15 @@ const SideBarBody = (props: { {open && ( - {versionData?.Version ? `V ${versionData.Version}` : ''} + {isVersionLoading ? ( + + ) : versionError ? ( + 'Version unavailable' + ) : versionData?.Version ? ( + `V ${versionData.Version}` + ) : ( + '' + )} )} diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 3a8d6305542..9a02c9f9394 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -372,10 +372,15 @@ const BarTreeView: FC<{ return (text: string) => { if (!searchTerm) return text; - const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); + const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + }; + + const escapedSearchTerm = escapeRegExp(searchTerm); + const parts = text.split(new RegExp(`(${escapedSearchTerm})`, "gi")); return parts.map((part, index) => part.toLowerCase() === searchTerm.toLowerCase() ? ( - + {part} ) : ( From 8a40bb2d29115cf878fbd7d26a5393b1dfc56782 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Mon, 17 Aug 2026 17:26:40 +0530 Subject: [PATCH 10/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../__tests__/EntityDisplayImage.test.tsx | 39 ++++++++++++++++++- dashboard/src/views/Layout/About.tsx | 4 +- dashboard/src/views/SideBar/SideBarBody.tsx | 26 ++----------- .../__tests__/SideBarTree.test.tsx | 11 +++--- .../SideBar/__tests__/SideBarBody.test.tsx | 27 ------------- 5 files changed, 48 insertions(+), 59 deletions(-) diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 44eb41595a3..34fce046913 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -132,4 +132,41 @@ describe('EntityDisplayImage', () => { }) ) }) -}) + it('handles empty primaryUrl and fallbackUrl', () => { + // Mock getEntityIconPath to return empty string + (Utils.getEntityIconPath as jest.Mock).mockReturnValue(''); + + const { container } = render( + + ); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img?.getAttribute('src')).toBe(''); + }); + + it('explicitly sets onerror to null to prevent infinite loops', () => { + const { container } = render( + + ) + + const img = container.querySelector('img')! + + // Simulate what the browser does natively when an image fails to load + const event = new Event('error'); + Object.defineProperty(event, 'currentTarget', { + value: img, + enumerable: true + }); + + // Add a dummy onerror handler to prove it gets cleared + img.onerror = () => {}; + expect(img.onerror).not.toBeNull(); + + // Call the React onError handler + fireEvent(img, event); + + // The handler should have explicitly cleared the onerror property + expect(img.onerror).toBeNull(); + }); +}); diff --git a/dashboard/src/views/Layout/About.tsx b/dashboard/src/views/Layout/About.tsx index b73d5af3f8c..b8c42bef2cf 100644 --- a/dashboard/src/views/Layout/About.tsx +++ b/dashboard/src/views/Layout/About.tsx @@ -26,7 +26,7 @@ import { } from "@mui/material"; const About = () => { - const { data: versionData, loading: loader } = useAppSelector((state: any) => state.session.versionData); + const { data: versionData, loading: loader, error } = useAppSelector((state: any) => state.session.versionData); return ( <> @@ -37,7 +37,7 @@ const About = () => { Version: - {versionData?.Version} + {error ? "Unknown (failed to fetch version)" : (versionData?.Version || "N/A")} Get involved! diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 1ad696c4b2c..ab9ca2dce60 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -158,15 +158,7 @@ const SideBarBody = (props: { const [activePopover, setActivePopover] = useState(null); const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); const [isBottomHalf, setIsBottomHalf] = useState(false); - const popoverTimeoutRef = useRef(null); - useEffect(() => { - return () => { - if (popoverTimeoutRef.current) { - clearTimeout(popoverTimeoutRef.current); - } - }; - }, []); const handlePopoverOpen = (event: React.MouseEvent, id: string) => { const target = event.currentTarget; @@ -189,19 +181,7 @@ const SideBarBody = (props: { } }; - // Clear any pending timeout from rapid clicks - if (popoverTimeoutRef.current) { - clearTimeout(popoverTimeoutRef.current); - popoverTimeoutRef.current = null; - } - - // If a different popover is already open, close it first to ensure clean unmount - if (activePopover && activePopover !== id) { - handlePopoverClose(); - popoverTimeoutRef.current = setTimeout(openNewPopover, 0); - } else { - openNewPopover(); - } + openNewPopover(); }; const handlePopoverClose = () => { @@ -406,7 +386,7 @@ const SideBarBody = (props: { }} > - handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> {m.title.toLowerCase()} @@ -624,7 +604,7 @@ const SideBarBody = (props: { )} - handleDrawerOpen()}> + handleDrawerOpen()}> {open ? ( {
{label}
) return ( -
+
{Content} {children}
@@ -2208,13 +2208,12 @@ describe('SideBarTree', () => { treeData, treeName: 'CustomFilters', isPopover: true - }, {}, ['/search/searchResult?searchType=BASIC&isCF=true&type=customFilter1']) + }, {}, ['/search/searchResult?searchType=BASIC&isCF=true&customFilter=customFilter1']) await waitFor(() => { - const treeView = screen.getByTestId('simple-tree-view') - expect(treeView).toBeInTheDocument() - const expandedItems = JSON.parse(treeView.getAttribute('data-expanded-items') || '[]') - expect(expandedItems).toContain('customFilter1') + const treeItem = screen.getByTestId('tree-item-customFilter1') + expect(treeItem).toBeInTheDocument() + expect(treeItem.querySelector('.Mui-selected')).toBeInTheDocument() }) }) diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index d31b70db7d6..537bc01e4fa 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -476,34 +476,7 @@ describe('SideBarBody', () => { }); }); - describe('Mouse Events for Resizing', () => { - it('should handle mouse events for drawer resizing', () => { - // The dragger ref and mouse event handlers are internal implementation details - // Testing them directly would require exposing internal refs - // Instead, we verify the component renders and functions correctly - renderWithProviders(); - - expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); - }); - it('should maintain drawer width constraints', () => { - // Window width and drawer constraints are calculated internally - // The component should render without errors - renderWithProviders(); - - expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); - }); - - it('should cleanup event listeners on unmount', () => { - const { unmount } = renderWithProviders(); - - // Unmount should cleanup all event listeners - unmount(); - - // Verify no errors during unmount - expect(true).toBe(true); - }); - }); describe('Props Handling', () => { it('should pass handleOpenModal to Header', () => { From 90c0f0f2842c9a273e1e239b641c3131c6aea9a9 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Thu, 20 Aug 2026 14:58:21 +0530 Subject: [PATCH 11/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/EntityDisplayImage.tsx | 3 +- .../slice/__tests__/sessionSlice.test.ts | 8 ++++ dashboard/src/styles/sidebar.scss | 16 ------- dashboard/src/views/SideBar/SideBarBody.tsx | 6 +-- .../views/SideBar/SideBarTree/SideBarTree.tsx | 7 +--- .../SideBar/__tests__/SideBarBody.test.tsx | 42 +++++++++++++++---- 6 files changed, 47 insertions(+), 35 deletions(-) diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index 21fed4c41af..9c4bea863d7 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -15,6 +15,7 @@ * limitations under the License. */ +import type { SyntheticEvent } from "react"; import { Avatar } from "@mui/material"; import { getEntityIconPath } from "../utils/Utils"; @@ -38,7 +39,7 @@ const DisplayImage = ({ const primaryUrl = getEntityIconPath({ entityData }) || ""; const fallbackUrl = getEntityIconPath({ entityData, errorUrl: primaryUrl }) || ""; - const handleError = (e: React.SyntheticEvent) => { + const handleError = (e: SyntheticEvent) => { const target = e.currentTarget; if (target.src !== fallbackUrl) { target.onerror = null; diff --git a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts index 65ece1879e1..dd5231a9413 100644 --- a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts +++ b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts @@ -46,6 +46,11 @@ describe('sessionSlice', () => { loading: false, data: null, error: null + }, + versionData: { + loading: false, + data: null, + error: null } }; @@ -58,6 +63,9 @@ describe('sessionSlice', () => { expect(state.sessionObj.loading).toBe(false); expect(state.sessionObj.data).toBeNull(); expect(state.sessionObj.error).toBeNull(); + expect(state.versionData.loading).toBe(false); + expect(state.versionData.data).toBeNull(); + expect(state.versionData.error).toBeNull(); }); it('should handle fetchSessionData.pending', () => { diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 7233f2f55ed..1c4e1d9bfcd 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -150,23 +150,7 @@ color: v.$text-grey; } -.sidebar-dragger { - position: inherit; - min-width: 2px; - width: 2px; - background-color: #f4f7f9; - cursor: col-resize; - padding: 4px 0 0; - top: 0; - right: 0; - bottom: 0; - z-index: 999; - clear: both; -} -.sidebar-dragger:hover { - background: #4a90e2; -} .tree-item-label { flex: 1; diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index ab9ca2dce60..cc6f114da28 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -118,7 +118,7 @@ const SideBarBody = (props: { const { data: versionData, loading: isVersionLoading, error: versionError } = useAppSelector((state) => state.session?.versionData || {}); const searchParams = new URLSearchParams(location.search); - const getActiveModule = () => { + const activeModule = useMemo(() => { if (searchParams.get("isCF") === "true") return "customFilters"; if (location.pathname.includes("/glossary") || !!searchParams.get("gtype") || !!searchParams.get("term") || !!searchParams.get("category")) return "glossary"; if (location.pathname.includes("/administrator/businessMetadata")) return "businessMetadata"; @@ -126,9 +126,7 @@ const SideBarBody = (props: { if (!!searchParams.get("relationshipName") || location.pathname.includes("/relationshipDetailPage")) return "relationships"; if (!!searchParams.get("type") || location.pathname.includes("/detailPage")) return "entities"; return null; - }; - - const activeModule = getActiveModule(); + }, [location.pathname, location.search]); const isCustomFilterActive = activeModule === "customFilters"; const isGlossaryActive = activeModule === "glossary"; diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 9a02c9f9394..4081bb3faaa 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -970,10 +970,7 @@ const BarTreeView: FC<{ {highlightText(label)} @@ -1159,7 +1156,7 @@ const BarTreeView: FC<{ )} {treeName == "Business MetaData" && ( - + { diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 537bc01e4fa..cdcf4b99a1c 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -173,20 +173,23 @@ describe('SideBarBody', () => { handleOpenAboutModal: mockHandleOpenAboutModal }; - const createMockStore = (initialState = {}) => { + const createMockStore = (initialState: any = {}) => { return configureStore({ reducer: { typeHeader: () => ({ loading: false, - ...initialState + ...(initialState.typeHeader || initialState) }), entity: () => ({ loading: false, - entityData: {} + entityData: {}, + ...(initialState.entity || {}) }), session: () => ({ sessionObj: { loading: false, data: null, error: null }, - versionData: { loading: false, data: null, error: null } + versionData: { loading: false, data: null, error: null }, + globalSessionData: { relationshipSearch: true }, + ...(initialState.session || {}) }) } }); @@ -497,11 +500,32 @@ describe('SideBarBody', () => { expect(mockHandleOpenAboutModal).toHaveBeenCalled(); }); - it('should pass loading prop to ClassificationTree', () => { - const props = { ...defaultProps, loading: true }; - renderWithProviders(props); - - expect(screen.getByTestId('classification-tree')).toBeInTheDocument(); + it('should show "Version unavailable" if versionError is set', () => { + const stateWithVersionError = { + session: { + versionData: { + loading: false, + data: null, + error: { message: "Failed to fetch version" } + } + } + }; + renderWithProviders({}, { store: createMockStore(stateWithVersionError) }); + + expect(screen.getByText('Version unavailable')).toBeInTheDocument(); + }); + + it('should hide relationships icon when relationshipSearch is falsy', () => { + const stateWithoutRelSearch = { + session: { + globalSessionData: { + relationshipSearch: false + } + } + }; + renderWithProviders({}, { store: createMockStore(stateWithoutRelSearch) }); + + expect(screen.queryByTestId('relationship-icon')).not.toBeInTheDocument(); }); }); From 9afb2273f8cdf0060b7604164bea71ef225d8791 Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Fri, 21 Aug 2026 10:43:13 +0530 Subject: [PATCH 12/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/SidebarSearchInput.tsx | 11 +-- .../__tests__/SidebarSearchInput.test.tsx | 21 ++++++ dashboard/src/styles/sidebar.scss | 37 ++++------ dashboard/src/styles/variables.scss | 12 ++++ dashboard/src/views/SideBar/SideBarBody.tsx | 42 +++++------ .../SideBar/SideBarTree/CustomFiltersTree.tsx | 2 +- .../views/SideBar/SideBarTree/SideBarTree.tsx | 42 ++++++----- .../__tests__/SideBarTree.test.tsx | 21 ------ .../SideBar/__tests__/SideBarBody.test.tsx | 70 ++++++++++++++++++- 9 files changed, 163 insertions(+), 95 deletions(-) diff --git a/dashboard/src/components/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx index 9103e5afa34..eacca99a87c 100644 --- a/dashboard/src/components/SidebarSearchInput.tsx +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -33,10 +33,7 @@ export const SidebarSearchInput: React.FC = ({ }) => ( @@ -55,6 +52,12 @@ export const SidebarSearchInput: React.FC = ({ aria-label="Clear search" size="small" onClick={() => onChange("")} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChange(""); + } + }} edge="end" sx={{ padding: "4px" }} > diff --git a/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx index 06714b07c31..a7fe000be7a 100644 --- a/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx +++ b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx @@ -63,4 +63,25 @@ describe("SidebarSearchInput Component", () => { const container = input.closest(".MuiInputBase-root"); expect(container).toHaveAttribute("data-cy", "my-search-input"); }); + + it("should support keyboard-only clear (Enter/Space) and have correct aria-labels", () => { + const mockOnChange = jest.fn(); + render(); + + // Verify aria-label on input + const input = screen.getByPlaceholderText("Search"); + expect(input).toHaveAttribute("aria-label", "search"); + + // Verify aria-label on clear button + const clearButton = screen.getByRole("button", { name: "Clear search" }); + expect(clearButton).toBeInTheDocument(); + + // Test Enter key + fireEvent.keyDown(clearButton, { key: "Enter", code: "Enter" }); + expect(mockOnChange).toHaveBeenCalledWith(""); + + // Test Space key + fireEvent.keyDown(clearButton, { key: " ", code: "Space" }); + expect(mockOnChange).toHaveBeenCalledWith(""); + }); }); diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 1c4e1d9bfcd..b1972a3ddc9 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -20,7 +20,6 @@ .sidebar-box { display: flex !important; height: 100%; - // margin-bottom: 20px; } .sidebar-appbar { @@ -30,13 +29,13 @@ box-shadow: none !important; z-index: 12000 !important; background: transparent !important; - color: #4a90e2 !important; + color: v.$text-blue !important; top: 60px !important; } .sidebar-toggle { margin: 0 !important; - border: 1px solid #4a90e2 !important; + border: 1px solid v.$text-blue !important; background: white !important; } @@ -45,11 +44,8 @@ } .sidebar-tree-box { - // min-height: 180px; flex-grow: 1; - // position: fixed; top: 128px; - // height: calc(100vh - 128px); width: inherit; } @@ -68,14 +64,10 @@ position: relative; } -// .sidebar-refresh { -// height: 30px !important; -// width: 30px !important; -// padding: 6px; -// } + .sidebar-divider { - background-color: #ddd; + background-color: v.$sidebar-divider; height: 20px !important; align-self: center !important; margin: 0px 4px !important; @@ -124,7 +116,7 @@ } .modal-title { - color: #686868; + color: v.$text-grey; font-weight: 600 !important; font-size: 18px !important; display: flex; @@ -132,7 +124,7 @@ } .modal-error-title { - color: #686868; + color: v.$text-grey; font-weight: 600 !important; font-size: 18px !important; display: flex; @@ -160,10 +152,11 @@ font-size: 14px; line-height: 29px; height: 29px; + white-space: nowrap; } .sidebar-wrapper { - background: #034858 !important; + background: v.$sidebar-bg !important; top: 20px; height: 100%; overflow-y: auto; @@ -198,10 +191,11 @@ .sidebar-searchbar { - background: #f1f1f1 !important; + background: v.$sidebar-search-bg !important; display: flex; justify-content: space-between; - padding: 0 10px 0 16px; + align-items: center; + padding: 0 10px 0 8px; color: rgba(0, 0, 0, 0.7); font-size: 14px; @@ -243,7 +237,7 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m } .sidebar-icon-active { - border-left: 4px solid #2ccebb !important; + border-left: 4px solid v.$sidebar-active !important; background: rgba(255, 255, 255, 0.08) !important; } .layout-header-container { @@ -301,7 +295,7 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m bottom: 0px; z-index: 9; left: 0; - background: #034858; + background: v.$sidebar-bg; display: flex; align-items: center; } @@ -319,7 +313,7 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m } .sidebar-tree-highlight { - color: #D3D3D3; + color: v.$sidebar-highlight; font-weight: 600; } @@ -329,6 +323,3 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m opacity: 1; } -.sidebar-tree-label-nowrap { - white-space: nowrap; -} diff --git a/dashboard/src/styles/variables.scss b/dashboard/src/styles/variables.scss index 7336e745b30..af20781f31f 100644 --- a/dashboard/src/styles/variables.scss +++ b/dashboard/src/styles/variables.scss @@ -23,3 +23,15 @@ $action_gray: #999999; $tag_color: #4a90e2; $color_havelock_blue_approx: #4a90e2; $gray: #808080; + +$sidebar-bg: #034858; +$sidebar-active: #2ccebb; +$sidebar-divider: #ddd; +$sidebar-search-bg: #f1f1f1; +$sidebar-highlight: #D3D3D3; + +:root { + --sidebar-bg: #{$sidebar-bg}; + --sidebar-active: #{$sidebar-active}; + --text-blue: #{$text-blue}; +} diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index cc6f114da28..0ce724febab 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -112,7 +112,7 @@ const SideBarBody = (props: { const dispatch = useAppDispatch(); const { handleOpenModal, handleOpenAboutModal } = props; const navigate = useNavigate(); - const { relationshipSearch = {} } = globalSessionData || {}; + const { relationshipSearch = false } = globalSessionData || {}; const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const { data: versionData, loading: isVersionLoading, error: versionError } = useAppSelector((state) => state.session?.versionData || {}); @@ -310,7 +310,7 @@ const SideBarBody = (props: { visibility: "visible !important", }), "& .MuiDrawer-paper": { - background: "#034858", + background: "var(--sidebar-bg)", boxSizing: "border-box", overflow: "hidden", position: "fixed", @@ -335,7 +335,7 @@ const SideBarBody = (props: { sx={{ height: "100vh", width: "100%", - backgroundColor: "#034858", + backgroundColor: "var(--sidebar-bg)", }} > {/* Collapsed sidebar logo and module icons */} @@ -412,7 +412,7 @@ const SideBarBody = (props: { maxHeight: popoverMaxHeight, display: 'flex', flexDirection: 'column', - backgroundColor: '#034858', + backgroundColor: 'var(--sidebar-bg)', border: '1px solid rgba(255, 255, 255, 0.15)', borderRadius: 1, boxShadow: 6, @@ -427,7 +427,7 @@ const SideBarBody = (props: { left: -6, width: 10, height: 10, - backgroundColor: '#034858', + backgroundColor: 'var(--sidebar-bg)', borderLeft: '1px solid rgba(255, 255, 255, 0.15)', borderBottom: '1px solid rgba(255, 255, 255, 0.15)', transform: 'rotate(45deg)', @@ -458,7 +458,7 @@ const SideBarBody = (props: { position: "sticky", top: 0, zIndex: 10, - backgroundColor: "#034858", + backgroundColor: "var(--sidebar-bg)", flexShrink: 0, }} > @@ -487,21 +487,16 @@ const SideBarBody = (props: { )} - - {open && ( - <> + {open && ( +
- - )} -
+
+ )}
diff --git a/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx b/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx index f50a73c1aa8..0b11f316a79 100644 --- a/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/CustomFiltersTree.tsx @@ -43,7 +43,7 @@ const CustomFiltersTree = ({ sideBarOpen, searchTerm, isPopover }: Props) => { const { savedSearchData }: any = useAppSelector( (state: any) => state.savedSearch ); - const { relationshipSearch = {} } = globalSessionData || {}; + const { relationshipSearch = false } = globalSessionData || {}; const [savedSearchTypeData, setSavedSearchTypeData] = useState< SavedSearchArrType diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 4081bb3faaa..ed70cb57a8b 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -150,7 +150,7 @@ const CustomContentRoot = styled("div")( props.selectedNodeCustomFilter === props.node) && { "&.Mui-selected .MuiTreeItem-contentBar": { backgroundColor: "rgba(255,255,255,0.08)", - borderLeft: "4px solid #2ccebb", + borderLeft: "4px solid var(--sidebar-active)", // color: "white" // borderRadius: "4px" }, @@ -353,15 +353,21 @@ const BarTreeView: FC<{ ); const filteredData = useMemo(() => { - return treeData.filter((node) => { - return ( - node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || - (node.children && - node.children.some((child) => - child.label?.toLowerCase().includes(searchTerm.toLowerCase()) - )) - ); - }); + if (!searchTerm) return treeData; + const lowerSearch = searchTerm.toLowerCase(); + return treeData.reduce((acc: any[], node: any) => { + const nodeMatches = node.label?.toLowerCase().includes(lowerSearch); + let filteredChildren = node.children; + if (!nodeMatches && node.children) { + filteredChildren = node.children.filter((child: any) => + child.label?.toLowerCase().includes(lowerSearch) + ); + } + if (nodeMatches || (filteredChildren && filteredChildren.length > 0)) { + acc.push({ ...node, children: filteredChildren }); + } + return acc; + }, []); }, [treeData, searchTerm]); const displayTreeName = useMemo(() => { @@ -391,14 +397,12 @@ const BarTreeView: FC<{ }, [searchTerm]); const expandedItemsMemo = useMemo(() => { - const allNodeIds = filteredData.flatMap((node) => { - return [ - node.id, - ...(node.children ? node.children.map((child) => child.id) : []), - ]; - }); - return [...allNodeIds, ...[treeName]]; - }, [filteredData, treeName]); + if (!searchTerm) { + return [treeName]; + } + const parentNodeIds = filteredData.map((node) => node.id); + return [...parentNodeIds, treeName]; + }, [filteredData, treeName, searchTerm]); useEffect(() => { setExpandedItems(expandedItemsMemo); @@ -970,7 +974,7 @@ const BarTreeView: FC<{ {highlightText(label)} diff --git a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx index dcd917d291e..f049414dcdc 100644 --- a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx @@ -1088,27 +1088,6 @@ describe('SideBarTree', () => { }) }) - describe('TreeLabelWithTooltip', () => { - it('should show tooltip when text is overflown', async () => { - renderComponent({ - treeData: [{ id: 'node1', label: 'Very Long Node Name That Should Overflow', children: [] }] - }) - - await waitFor(() => { - expect(screen.getByTestId('simple-tree-view')).toBeInTheDocument() - }) - }) - - it('should not show tooltip when text is not overflown', async () => { - renderComponent({ - treeData: [{ id: 'node1', label: 'Short', children: [] }] - }) - - await waitFor(() => { - expect(screen.getByTestId('simple-tree-view')).toBeInTheDocument() - }) - }) - }) describe('getEmptyTypesTitle', () => { it('should return correct title for Entities', async () => { diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index cdcf4b99a1c..949e973b615 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -142,7 +142,7 @@ jest.mock('@redux/slice/sessionSlice'); // Mock utils jest.mock('@utils/Enum', () => ({ globalSessionData: { - relationshipSearch: {} + relationshipSearch: true }, PathAssociateWithModule: { SEARCH: ['/search'], @@ -286,7 +286,7 @@ describe('SideBarBody', () => { }); it('should render relationships tree when relationshipSearch is enabled', () => { - // The relationshipSearch is enabled in our mock (globalSessionData.relationshipSearch = {}) + // The relationshipSearch is enabled in our mock (globalSessionData.relationshipSearch = true) // The relationships tree is rendered by default in our test setup renderWithProviders(); @@ -515,7 +515,7 @@ describe('SideBarBody', () => { expect(screen.getByText('Version unavailable')).toBeInTheDocument(); }); - it('should hide relationships icon when relationshipSearch is falsy', () => { + it('should hide relationships icon and module when relationshipSearch is falsy', () => { const stateWithoutRelSearch = { session: { globalSessionData: { @@ -526,6 +526,36 @@ describe('SideBarBody', () => { renderWithProviders({}, { store: createMockStore(stateWithoutRelSearch) }); expect(screen.queryByTestId('relationship-icon')).not.toBeInTheDocument(); + // Should also hide the tree in the expanded view + expect(screen.queryByTestId('r_relationshipTreeRender')).not.toBeInTheDocument(); + }); + + it('should show V x.x display for version footer', () => { + const stateWithVersion = { + session: { + versionData: { + loading: false, + data: { Version: "3.12.1.0" }, + error: null + } + } + }; + renderWithProviders({}, { store: createMockStore(stateWithVersion) }); + expect(screen.getByText('V 3.12.1.0')).toBeInTheDocument(); + }); + + it('should show loading spinner for version footer when loading', () => { + const stateLoadingVersion = { + session: { + versionData: { + loading: true, + data: null, + error: null + } + } + }; + renderWithProviders({}, { store: createMockStore(stateLoadingVersion) }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); }); }); @@ -715,6 +745,40 @@ describe('SideBarBody', () => { expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); }); }); + + it('should NOT open popover when sidebar is expanded', async () => { + // Re-open sidebar that was closed in beforeEach + const toggleOpenButton = screen.getByTestId('KeyboardDoubleArrowRightIcon').closest('button'); + fireEvent.click(toggleOpenButton!); + + // Ensure sidebar is expanded + expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); + + // Module icons don't exist when expanded + const icons = screen.queryByAltText('glossary'); + expect(icons).not.toBeInTheDocument(); + }); + + it('should only open one popover at a time when switching modules', async () => { + // Find the glossary icon and click it + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + expect(screen.getAllByTestId('glossary-tree').length).toBeGreaterThan(0); + }); + + // Click entities icon + const entitiesIcon = screen.getByAltText('entities'); + fireEvent.click(entitiesIcon.closest('button')!); + + await waitFor(() => { + // Entities should be open + expect(screen.getAllByTestId('entities-tree').length).toBeGreaterThan(0); + // Glossary should be closed + expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + }); + }); }); describe('Active State Markers', () => { From 738dcbfa06e9bee6d626ae9504cbf31c54acd5ab Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Mon, 24 Aug 2026 12:33:51 +0530 Subject: [PATCH 13/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/EntityDisplayImage.tsx | 8 +-- .../components/GlobalSearch/QuickSearch.tsx | 38 ++--------- dashboard/src/components/TreeNodeIcons.tsx | 6 +- .../slice/__tests__/sessionSlice.test.ts | 18 +++++ dashboard/src/styles/globalSearch.scss | 40 +++++++++++ .../src/views/Layout/__tests__/About.test.tsx | 3 + .../views/SideBar/SideBarTree/SideBarTree.tsx | 2 +- .../SideBar/__tests__/SideBarBody.test.tsx | 66 +++++++++++++++---- 8 files changed, 129 insertions(+), 52 deletions(-) diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index 9c4bea863d7..19a85463e30 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -34,14 +34,14 @@ const DisplayImage = ({ avatarDisplay, isProcess }: DisplayImageProps) => { - const entityData = { ...entity, isProcess: isProcess }; + const entityData = { ...entity, isProcess }; const primaryUrl = getEntityIconPath({ entityData }) || ""; const fallbackUrl = getEntityIconPath({ entityData, errorUrl: primaryUrl }) || ""; const handleError = (e: SyntheticEvent) => { const target = e.currentTarget; - if (target.src !== fallbackUrl) { + if (!target.src.endsWith(fallbackUrl)) { target.onerror = null; target.src = fallbackUrl; } @@ -52,8 +52,8 @@ const DisplayImage = ({ {avatarDisplay === undefined ? ( Entity Icon = { businessMetadata: "Business Metadata" }; + + const QuickSearch = () => { const navigate = useNavigate(); const location = useLocation(); @@ -400,7 +402,7 @@ const QuickSearch = () => { onChange={handleScopeChange} aria-label="Search scope" displayEmpty - sx={{ height: "32px", boxSizing: "border-box" }} + className="quick-search-select" renderValue={(v) => SCOPE_LABELS[v as QuickSearchScope]} > Select All @@ -646,15 +648,8 @@ const QuickSearch = () => { }} className="text-black-default" InputProps={{ - sx: { - height: "32px", - padding: "0 10px !important", - borderRadius: "4px", - color: "#1a1a1a", - backgroundColor: "white", - boxSizing: "border-box" - }, ...params.InputProps, + className: `quick-search-input ${params.InputProps.className || ""}`, type: "search", endAdornment: ( @@ -685,16 +680,7 @@ const QuickSearch = () => { @@ -704,19 +690,7 @@ const QuickSearch = () => { { setOpenAdvanceSearch(true); }} diff --git a/dashboard/src/components/TreeNodeIcons.tsx b/dashboard/src/components/TreeNodeIcons.tsx index 3a613d3b2dd..adbdb9d497f 100644 --- a/dashboard/src/components/TreeNodeIcons.tsx +++ b/dashboard/src/components/TreeNodeIcons.tsx @@ -188,9 +188,8 @@ const TreeNodeIcons = (props: { handleClickNodeMenu(e); }} size="small" - className="tree-item-more-label" + className={`tree-item-more-label ${isHovered || openNode ? "" : "invisible"}`} data-cy="dropdownMenuButton" - style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > @@ -208,10 +207,9 @@ const TreeNodeIcons = (props: { onClick={(e) => { handleClickNodeMenu(e); }} - className="tree-item-more-label" + className={`tree-item-more-label ${isHovered || openNode ? "" : "invisible"}`} size="small" data-cy="dropdownMenuButton" - style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > diff --git a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts index dd5231a9413..e6a19489568 100644 --- a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts +++ b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts @@ -217,6 +217,24 @@ describe('sessionSlice', () => { expect(state.versionData.loading).toBe(false); expect(state.versionData.data).toEqual(mockVersionData); }); + + it('should handle fetchVersionData error', async () => { + const { getVersion } = require('../../../api/apiMethods/headerApiMethods'); + const error = 'API Error'; + getVersion.mockRejectedValue(error); + + const store = configureStore({ + reducer: { + session: sessionReducer + } + }); + + await store.dispatch(fetchVersionData()); + + const state = store.getState().session; + expect(state.versionData.loading).toBe(false); + expect(state.versionData.error).toBeTruthy(); + }); }); }); diff --git a/dashboard/src/styles/globalSearch.scss b/dashboard/src/styles/globalSearch.scss index ba1ebb4f1dd..2e9a546f31a 100644 --- a/dashboard/src/styles/globalSearch.scss +++ b/dashboard/src/styles/globalSearch.scss @@ -164,3 +164,43 @@ .dashboard-quick-search .advanced-search-link p { font-size: 1rem; } + +.quick-search-select { + height: 32px; + box-sizing: border-box; +} + +.quick-search-input { + height: 32px; + padding: 0 10px !important; + border-radius: 4px; + color: #1a1a1a; + background-color: white; + box-sizing: border-box; +} + +.quick-search-btn { + background-color: #4a90e2 !important; + color: #fff !important; + text-transform: none !important; + font-weight: 600 !important; + height: 32px !important; + min-height: 32px !important; + max-height: 32px !important; + box-sizing: border-box; +} + +.quick-search-advanced-btn { + background-color: white !important; + color: #4a90e2 !important; + border-color: #dddddd !important; + height: 32px !important; + min-height: 32px !important; + max-height: 32px !important; + box-sizing: border-box; + + &:hover { + background-color: rgba(74, 144, 226, 0.08) !important; + color: #4a90e2 !important; + } +} diff --git a/dashboard/src/views/Layout/__tests__/About.test.tsx b/dashboard/src/views/Layout/__tests__/About.test.tsx index b82d024b5f6..eb8e3207e70 100644 --- a/dashboard/src/views/Layout/__tests__/About.test.tsx +++ b/dashboard/src/views/Layout/__tests__/About.test.tsx @@ -190,6 +190,9 @@ describe('About', () => { // Verify no skeleton is stuck expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() + // Verify exact error message + expect(screen.getByText('Unknown (failed to fetch version)')).toBeInTheDocument() + // Verify no crash, UI still renders expect(screen.getByText(/Version:/i)).toBeInTheDocument() expect(screen.getByText('Get involved!')).toBeInTheDocument() diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index ed70cb57a8b..0b66e37678a 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -968,7 +968,7 @@ const BarTreeView: FC<{ if (el) { setIsOverflown(el.scrollWidth > el.clientWidth); } - }, [label, searchTerm]); + }, [label]); return ( diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 949e973b615..5d65e14814c 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -515,19 +515,23 @@ describe('SideBarBody', () => { expect(screen.getByText('Version unavailable')).toBeInTheDocument(); }); - it('should hide relationships icon and module when relationshipSearch is falsy', () => { - const stateWithoutRelSearch = { - session: { - globalSessionData: { - relationshipSearch: false - } - } - }; - renderWithProviders({}, { store: createMockStore(stateWithoutRelSearch) }); + it('should hide relationships icon when relationshipSearch is falsy', () => { + const { globalSessionData } = require('@utils/Enum'); + const originalValue = globalSessionData.relationshipSearch; + globalSessionData.relationshipSearch = false; + + renderWithProviders(); - expect(screen.queryByTestId('relationship-icon')).not.toBeInTheDocument(); + // Collapse drawer to check icon + const toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + expect(screen.queryByAltText('relationships')).not.toBeInTheDocument(); // Should also hide the tree in the expanded view - expect(screen.queryByTestId('r_relationshipTreeRender')).not.toBeInTheDocument(); + expect(screen.queryByTestId('relationships-tree')).not.toBeInTheDocument(); + + // Restore value + globalSessionData.relationshipSearch = originalValue; }); it('should show V x.x display for version footer', () => { @@ -807,6 +811,46 @@ describe('SideBarBody', () => { expect(entitiesIcon.closest('.sidebar-icon-active')).not.toBeInTheDocument(); unmount2(); + // Test Glossary active + (global as any).mockLocation = { pathname: '/glossary', search: '' }; + const { unmount: unmount3 } = renderWithProviders(); + toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let glossaryIcon = screen.getByAltText('glossary'); + expect(glossaryIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + unmount3(); + + // Test Classification active + (global as any).mockLocation = { pathname: '/search', search: '?tag=PII' }; + const { unmount: unmount4 } = renderWithProviders(); + toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let classificationIcon = screen.getByAltText('classifications'); + expect(classificationIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + unmount4(); + + // Test Business Metadata active + (global as any).mockLocation = { pathname: '/administrator/businessMetadata', search: '' }; + const { unmount: unmount5 } = renderWithProviders(); + toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let bmIcon = screen.getByAltText('business metadata'); + expect(bmIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + unmount5(); + + // Test Relationships active + (global as any).mockLocation = { pathname: '/search', search: '?relationshipName=Employee' }; + const { unmount: unmount6 } = renderWithProviders(); + toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + let relIcon = screen.getByAltText('relationships'); + expect(relIcon.closest('.sidebar-icon-active')).toBeInTheDocument(); + unmount6(); + (global as any).mockLocation = undefined; }); }); From 9a004603bf471e753cd2aba0d987ef10d0fc46fc Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Mon, 24 Aug 2026 20:24:36 +0530 Subject: [PATCH 14/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- dashboard/src/setupTests.simple.ts | 3 + dashboard/src/setupTests.ts | 3 + dashboard/src/styles/sidebar.scss | 117 ++++++++++++++++++ dashboard/src/views/Layout/About.tsx | 2 +- dashboard/src/views/SideBar/SideBarBody.tsx | 92 +++----------- .../views/SideBar/SideBarTree/SideBarTree.tsx | 28 ++--- .../SideBar/__tests__/SideBarBody.test.tsx | 43 ++++++- 7 files changed, 191 insertions(+), 97 deletions(-) diff --git a/dashboard/src/setupTests.simple.ts b/dashboard/src/setupTests.simple.ts index d1bca5c8461..e18ed273261 100644 --- a/dashboard/src/setupTests.simple.ts +++ b/dashboard/src/setupTests.simple.ts @@ -18,6 +18,9 @@ /** Simplified test setup file for Node 12 compatibility */ import '@testing-library/jest-dom'; +import { TextEncoder, TextDecoder } from 'util'; + +Object.assign(global, { TextDecoder, TextEncoder }); export {}; diff --git a/dashboard/src/setupTests.ts b/dashboard/src/setupTests.ts index 665628f4cc4..a666f52e40b 100644 --- a/dashboard/src/setupTests.ts +++ b/dashboard/src/setupTests.ts @@ -18,6 +18,9 @@ /** Test setup file for Jest and React Testing Library */ import '@testing-library/jest-dom'; +import { TextEncoder, TextDecoder } from 'util'; + +Object.assign(global, { TextDecoder, TextEncoder }); // Mock ResizeObserver (global as any).ResizeObserver = class ResizeObserver { diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index b1972a3ddc9..553b8e3092c 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -323,3 +323,120 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m opacity: 1; } + +/* Refactored from SideBarBody.tsx sx props */ +.sidebar-drawer { + flex-shrink: 0; + min-height: calc(100vh - 64px); + min-width: 60px; + transition: width 0.2s; + + &.open { + width: 20%; + } + + &.closed { + width: 60px; + transform: none !important; + visibility: visible !important; + } + + .MuiDrawer-paper { + background: v.$sidebar-bg; + box-sizing: border-box; + overflow: hidden; + position: fixed; + top: 0; + left: 0; + transition: width 0.2s; + } + + &.open .MuiDrawer-paper { + width: 20%; + } + + &.closed .MuiDrawer-paper { + width: 60px; + transform: none !important; + visibility: visible !important; + } +} + +.sidebar-popover-paper { + margin-left: 16px; + width: 320px; + display: flex; + flex-direction: column; + background-color: v.$sidebar-bg !important; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2), 0px 6px 10px 0px rgba(0,0,0,0.14), 0px 1px 18px 0px rgba(0,0,0,0.12); + padding-bottom: 16px; + overflow: visible !important; + + &::before { + content: ""; + display: block; + position: absolute; + left: -6px; + width: 10px; + height: 10px; + background-color: v.$sidebar-bg !important; + border-left: 1px solid rgba(255, 255, 255, 0.15); + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + transform: rotate(45deg); + z-index: 1; + } + + &.top-half::before { + top: 16px; + bottom: auto; + } + + &.bottom-half::before { + top: auto; + bottom: 16px; + } +} + +.sidebar-module-box { + display: flex; + justify-content: center; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + background: transparent; +} + +.sidebar-module-btn { + color: rgba(255, 255, 255, 0.6) !important; + &:hover { + color: white !important; + background: rgba(255, 255, 255, 0.1) !important; + } + &.active { + color: white !important; + } +} + +.sidebar-version-container { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + padding-left: 4px; +} + +.sidebar-version-text { + color: rgba(255, 255, 255, 0.6) !important; + padding-left: 4px !important; +} + +.sidebar-version-loader { + color: rgba(255, 255, 255, 0.6) !important; +} + +.sidebar-stack { + height: 100vh; + width: 100%; + background-color: v.$sidebar-bg; +} diff --git a/dashboard/src/views/Layout/About.tsx b/dashboard/src/views/Layout/About.tsx index b8c42bef2cf..f1efebcc140 100644 --- a/dashboard/src/views/Layout/About.tsx +++ b/dashboard/src/views/Layout/About.tsx @@ -26,7 +26,7 @@ import { } from "@mui/material"; const About = () => { - const { data: versionData, loading: loader, error } = useAppSelector((state: any) => state.session.versionData); + const { data: versionData, loading: loader, error } = useAppSelector((state) => state.session.versionData); return ( <> diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 0ce724febab..badf1757f93 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -116,9 +116,8 @@ const SideBarBody = (props: { const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const { data: versionData, loading: isVersionLoading, error: versionError } = useAppSelector((state) => state.session?.versionData || {}); - const searchParams = new URLSearchParams(location.search); - const activeModule = useMemo(() => { + const searchParams = new URLSearchParams(location.search); if (searchParams.get("isCF") === "true") return "customFilters"; if (location.pathname.includes("/glossary") || !!searchParams.get("gtype") || !!searchParams.get("term") || !!searchParams.get("category")) return "glossary"; if (location.pathname.includes("/administrator/businessMetadata")) return "businessMetadata"; @@ -299,45 +298,15 @@ const SideBarBody = (props: { - + {/* Collapsed sidebar logo and module icons */} {!open && ( {/* Search */} - + - { setOpen(true); handlePopoverClose(); }} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + { setOpen(true); handlePopoverClose(); }} className="sidebar-module-btn"> search @@ -374,17 +343,10 @@ const SideBarBody = (props: { {modules.filter(m => m.isVisible).map(m => ( - handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + handlePopoverOpen(e, m.id)} className={`sidebar-module-btn ${m.isActive ? "active" : ""}`}> {m.title.toLowerCase()} @@ -406,34 +368,8 @@ const SideBarBody = (props: { horizontal: 'left' }} PaperProps={{ - sx: { - ml: 2, - width: 320, - maxHeight: popoverMaxHeight, - display: 'flex', - flexDirection: 'column', - backgroundColor: 'var(--sidebar-bg)', - border: '1px solid rgba(255, 255, 255, 0.15)', - borderRadius: 1, - boxShadow: 6, - pb: 2, - overflow: 'visible', - '&::before': { - content: '""', - display: 'block', - position: 'absolute', - top: isBottomHalf ? 'auto' : 16, - bottom: isBottomHalf ? 16 : 'auto', - left: -6, - width: 10, - height: 10, - backgroundColor: 'var(--sidebar-bg)', - borderLeft: '1px solid rgba(255, 255, 255, 0.15)', - borderBottom: '1px solid rgba(255, 255, 255, 0.15)', - transform: 'rotate(45deg)', - zIndex: 1 - } - } + className: `sidebar-popover-paper ${isBottomHalf ? 'bottom-half' : 'top-half'}`, + style: { maxHeight: popoverMaxHeight } }} > {renderPopoverSearch()} @@ -581,10 +517,10 @@ const SideBarBody = (props: { className={`sidebar-toggle-container ${open ? 'sidebar-toggle-open' : 'sidebar-toggle-closed'}`} > {open && ( - - +
+ {isVersionLoading ? ( - + ) : versionError ? ( 'Version unavailable' ) : versionData?.Version ? ( @@ -593,7 +529,7 @@ const SideBarBody = (props: { '' )} - +
)} handleDrawerOpen()}> diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 0b66e37678a..3d92083fa24 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -27,6 +27,7 @@ import { useRef, useState, useMemo, + useCallback, SyntheticEvent, memo, } from "react"; @@ -396,26 +397,23 @@ const BarTreeView: FC<{ }; }, [searchTerm]); - const expandedItemsMemo = useMemo(() => { - if (!searchTerm) { - return [treeName]; - } - const parentNodeIds = filteredData.map((node) => node.id); - return [...parentNodeIds, treeName]; - }, [filteredData, treeName, searchTerm]); - - useEffect(() => { - setExpandedItems(expandedItemsMemo); - }, [expandedItemsMemo]); - - const getNodeId = (node: TreeNode) => { + const getNodeId = useCallback((node: TreeNode) => { if (treeName == "Classifications" && node.types == "parent") { return node.label; } else if (treeName == "Classifications" && node.types == "child") { return `${node.id}@${node.label}`; } return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; - }; + }, [treeName]); + + const expandedItemsMemo = useMemo(() => { + const parentNodeIds = filteredData.map((node) => getNodeId(node)); + return [...parentNodeIds, treeName]; + }, [filteredData, treeName, getNodeId]); + + useEffect(() => { + setExpandedItems(expandedItemsMemo); + }, [expandedItemsMemo]); useEffect(() => { const searchParams = new URLSearchParams(location.search); @@ -480,7 +478,7 @@ const BarTreeView: FC<{ customFilter: null, }); } - }, [location.search, treeData, treeName, businessMetaData, bmguid]); + }, [location.pathname, location.search, treeData, treeName, businessMetaData, bmguid, getNodeId]); const getEmptyTypesTitle = () => { switch (treeName) { diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 5d65e14814c..b20655ad509 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -548,6 +548,27 @@ describe('SideBarBody', () => { expect(screen.getByText('V 3.12.1.0')).toBeInTheDocument(); }); + it('should not display version footer when drawer is closed', async () => { + const stateWithVersion = { + session: { + versionData: { + loading: false, + data: { Version: "3.12.1.0" }, + error: null + } + } + }; + renderWithProviders({}, { store: createMockStore(stateWithVersion) }); + expect(screen.getByText('V 3.12.1.0')).toBeInTheDocument(); + + const toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + + await waitFor(() => { + expect(screen.queryByText('V 3.12.1.0')).not.toBeInTheDocument(); + }); + }); + it('should show loading spinner for version footer when loading', () => { const stateLoadingVersion = { session: { @@ -737,12 +758,10 @@ describe('SideBarBody', () => { expect(screen.getAllByTestId('glossary-tree').length).toBeGreaterThan(0); }); - // Press escape to close the popover (MUI Popover default behavior for outside click/escape) const backdrop = document.querySelector('.MuiBackdrop-root'); + expect(backdrop).toBeInTheDocument(); if (backdrop) { fireEvent.click(backdrop); - } else { - fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' }); } await waitFor(() => { @@ -750,6 +769,24 @@ describe('SideBarBody', () => { }); }); + it('should close popover on Escape key press', async () => { + // Open glossary popover + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + expect(screen.getAllByTestId('glossary-tree').length).toBeGreaterThan(0); + }); + + // Press Escape to close the popover + // MUI Popover listens for Escape on the document or active element + fireEvent.keyDown(document.activeElement || document.body, { key: 'Escape', code: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + }); + }); + it('should NOT open popover when sidebar is expanded', async () => { // Re-open sidebar that was closed in beforeEach const toggleOpenButton = screen.getByTestId('KeyboardDoubleArrowRightIcon').closest('button'); From 4dbfd9065842e5fe828c70698ee66abc680575eb Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 26 Aug 2026 11:26:01 +0530 Subject: [PATCH 15/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/EntityDisplayImage.tsx | 3 +- .../components/GlobalSearch/QuickSearch.tsx | 2 - .../src/components/SidebarSearchInput.tsx | 9 +-- .../src/components/TreeSkeletonLoader.tsx | 2 +- .../__tests__/TreeSkeletonLoader.test.tsx | 16 ++++ .../slice/__tests__/sessionSlice.test.ts | 35 +++++++++ dashboard/src/redux/slice/sessionSlice.ts | 14 +--- dashboard/src/styles/sidebar.scss | 60 ++++++++++++++- dashboard/src/views/SideBar/SideBarBody.tsx | 63 +++++---------- .../views/SideBar/SideBarTree/SideBarTree.tsx | 76 +++++++++---------- .../SideBar/__tests__/SideBarBody.test.tsx | 13 ++-- 11 files changed, 183 insertions(+), 110 deletions(-) diff --git a/dashboard/src/components/EntityDisplayImage.tsx b/dashboard/src/components/EntityDisplayImage.tsx index 19a85463e30..916d944c073 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -41,7 +41,8 @@ const DisplayImage = ({ const handleError = (e: SyntheticEvent) => { const target = e.currentTarget; - if (!target.src.endsWith(fallbackUrl)) { + if (target.dataset.fallbackApplied !== "true") { + target.dataset.fallbackApplied = "true"; target.onerror = null; target.src = fallbackUrl; } diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx index 0edbf3eab78..eafeeee88fc 100644 --- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx +++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx @@ -85,8 +85,6 @@ const SCOPE_LABELS: Record = { businessMetadata: "Business Metadata" }; - - const QuickSearch = () => { const navigate = useNavigate(); const location = useLocation(); diff --git a/dashboard/src/components/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx index eacca99a87c..0930b16f1e1 100644 --- a/dashboard/src/components/SidebarSearchInput.tsx +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -32,14 +32,11 @@ export const SidebarSearchInput: React.FC = ({ dataCy }) => ( = ({ } }} edge="end" - sx={{ padding: "4px" }} + className="sidebar-searchbar-clear-btn" > - + )} { return ( - {allRows.slice(0, count)} + {allRows.slice(0, Math.max(0, count))} ); }; diff --git a/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx b/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx index c7388f30cc5..57d20c1f1c6 100644 --- a/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx +++ b/dashboard/src/components/__tests__/TreeSkeletonLoader.test.tsx @@ -44,4 +44,20 @@ describe('TreeSkeletonLoader', () => { const skeletons = container.querySelectorAll('.MuiSkeleton-root'); expect(skeletons.length).toBe(0); }); + + it('renders correctly with negative count', () => { + const { container } = render(); + + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(0); + }); + + it('renders default number of skeletons when count is explicitly undefined', () => { + const { container } = render(); + + // By default count is 7 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + // 7 rows * 2 = 14 skeletons + expect(skeletons.length).toBe(14); + }); }); diff --git a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts index e6a19489568..5c59eccc36c 100644 --- a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts +++ b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts @@ -77,6 +77,24 @@ describe('sessionSlice', () => { expect(state.sessionObj.error).toBeNull(); }); + it('should handle fetchSessionData.pending while retaining previous data', () => { + const previousState = { + ...initialState, + sessionObj: { + loading: false, + data: { 'atlas.entity.create.allowed': true }, + error: null + } + }; + const action = { type: fetchSessionData.pending.type }; + const state = sessionReducer(previousState, action); + + expect(state.sessionObj.loading).toBe(true); + expect(state.sessionObj.data).toEqual({ 'atlas.entity.create.allowed': true }); + expect(state.sessionObj.error).toBeNull(); + }); + + it('should handle fetchSessionData.fulfilled', () => { const mockData = { 'atlas.entity.create.allowed': true, @@ -172,6 +190,23 @@ describe('sessionSlice', () => { expect(state.versionData.data).toBeNull(); expect(state.versionData.error).toBeNull(); }); + + it('should handle fetchVersionData.pending while retaining previous data', () => { + const previousState = { + ...initialState, + versionData: { + loading: false, + data: { Version: '3.0.0' }, + error: null + } + }; + const action = { type: fetchVersionData.pending.type }; + const state = sessionReducer(previousState, action); + + expect(state.versionData.loading).toBe(true); + expect(state.versionData.data).toEqual({ Version: '3.0.0' }); + expect(state.versionData.error).toBeNull(); + }); it('should handle fetchVersionData.fulfilled', () => { const mockVersionData = { Version: '3.0.0' }; diff --git a/dashboard/src/redux/slice/sessionSlice.ts b/dashboard/src/redux/slice/sessionSlice.ts index f4eee3bad65..57dfa319232 100644 --- a/dashboard/src/redux/slice/sessionSlice.ts +++ b/dashboard/src/redux/slice/sessionSlice.ts @@ -74,11 +74,8 @@ const sessionSlice = createSlice({ reducers: {}, extraReducers: (builder) => { builder.addCase(fetchSessionData.pending, (state) => { - state.sessionObj = { - loading: true, - data: null, - error: null - }; + state.sessionObj.loading = true; + state.sessionObj.error = null; }), builder.addCase( fetchSessionData.fulfilled, @@ -98,11 +95,8 @@ const sessionSlice = createSlice({ }; }), builder.addCase(fetchVersionData.pending, (state) => { - state.versionData = { - loading: true, - data: null, - error: null - }; + state.versionData.loading = true; + state.versionData.error = null; }), builder.addCase( fetchVersionData.fulfilled, diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index 553b8e3092c..b96a973cc14 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -20,6 +20,7 @@ .sidebar-box { display: flex !important; height: 100%; + overflow: hidden; } .sidebar-appbar { @@ -160,7 +161,9 @@ top: 20px; height: 100%; overflow-y: auto; - padding-bottom: 16px; + overflow-x: hidden; + flex: 1; + padding-bottom: 48px; padding-left: 8px; padding-right: 8px; } @@ -191,6 +194,7 @@ .sidebar-searchbar { + width: 100%; background: v.$sidebar-search-bg !important; display: flex; justify-content: space-between; @@ -199,6 +203,18 @@ color: rgba(0, 0, 0, 0.7); font-size: 14px; + &-input { + color: rgba(0, 0, 0, 0.7) !important; + } + + &-clear-btn { + padding: 4px !important; + } + + &-clear-icon { + color: rgba(0, 0, 0, 0.4) !important; + } + &-icon { width: 16px; height: 16px; @@ -440,3 +456,45 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m width: 100%; background-color: v.$sidebar-bg; } + +.sidebar-mini-module-container { + width: 100%; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + box-sizing: border-box; + padding-bottom: 60px; +} + +.sidebar-drawer-header { + position: sticky; + top: 0; + z-index: 10; + background-color: v.$sidebar-bg; + flex-shrink: 0; +} + +.sidebar-toggle-icon { + color: white; +} + +.sidebar-main-content { + margin: 0; + overflow-x: auto; + background: #f5f7f9; + padding: 0; +} + +.sidebar-circular-progress { + display: inline-block; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +.sidebar-module-stack { + width: 100%; +} + diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index badf1757f93..31ed5a04c1e 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -153,7 +153,7 @@ const SideBarBody = (props: { const [popoverAnchor, setPopoverAnchor] = useState(null); const [activePopover, setActivePopover] = useState(null); - const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); + const [popoverMaxHeight, setPopoverMaxHeight] = useState("calc(100vh - 100px)"); const [isBottomHalf, setIsBottomHalf] = useState(false); @@ -264,13 +264,7 @@ const SideBarBody = (props: {
} @@ -293,12 +287,11 @@ const SideBarBody = (props: {
{/* Module Icons for Mini Drawer */} - + {/* Search */} @@ -360,15 +353,15 @@ const SideBarBody = (props: { anchorEl={popoverAnchor} onClose={handlePopoverClose} anchorOrigin={{ - vertical: isBottomHalf ? 'bottom' : 'top', - horizontal: 'right' + vertical: isBottomHalf ? "bottom" : "top", + horizontal: "right" }} transformOrigin={{ - vertical: isBottomHalf ? 'bottom' : 'top', - horizontal: 'left' + vertical: isBottomHalf ? "bottom" : "top", + horizontal: "left" }} PaperProps={{ - className: `sidebar-popover-paper ${isBottomHalf ? 'bottom-half' : 'top-half'}`, + className: `sidebar-popover-paper ${isBottomHalf ? "bottom-half" : "top-half"}`, style: { maxHeight: popoverMaxHeight } }} > @@ -390,13 +383,7 @@ const SideBarBody = (props: { {open && ( )} - {open && ( - +
- )}
{open && (
@@ -535,12 +515,12 @@ const SideBarBody = (props: { handleDrawerOpen()}> {open ? ( ) : ( )} @@ -551,12 +531,7 @@ const SideBarBody = (props: {
{rightSideContent}
diff --git a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx index 3d92083fa24..dc69d21043e 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -398,9 +398,9 @@ const BarTreeView: FC<{ }, [searchTerm]); const getNodeId = useCallback((node: TreeNode) => { - if (treeName == "Classifications" && node.types == "parent") { + if (treeName === "Classifications" && node.types === "parent") { return node.label; - } else if (treeName == "Classifications" && node.types == "child") { + } else if (treeName === "Classifications" && node.types === "child") { return `${node.id}@${node.label}`; } return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; @@ -429,7 +429,7 @@ const BarTreeView: FC<{ const bmObj = !isEmpty(businessMetaData?.businessMetadataDefs) ? businessMetaData?.businessMetadataDefs?.find((obj: EnumTypeDefData) => { - if (bmguid == obj.guid) { + if (bmguid === obj.guid) { return obj; } }) @@ -926,8 +926,8 @@ const BarTreeView: FC<{ case "Relationships": case "CustomFilters": if ( - treeName == "Relationships" || - (treeName == "CustomFilters" && node.parent == "BASIC_RELATIONSHIP") + treeName === "Relationships" || + (treeName === "CustomFilters" && node.parent === "BASIC_RELATIONSHIP") ) { navigate( { @@ -1011,7 +1011,7 @@ const BarTreeView: FC<{ > {(isHovered: boolean) => ( <> - {node.id != "No Records Found" && ( + {node.id !== "No Records Found" && ( )} - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "CustomFilters" || - treeName == "Glossary") && - node.id != "No Records Found" && ( + {(treeName === "Entities" || + treeName === "Classifications" || + treeName === "CustomFilters" || + treeName === "Glossary") && + node.id !== "No Records Found" && ( { try { - if (treeName == "Glossary") { + if (treeName === "Glossary") { await downloadGlossaryImportTemplate(); return; } @@ -1074,7 +1074,7 @@ const BarTreeView: FC<{ - {treeName === "Entities" && } - {treeName === "Classifications" && } - {treeName === "Business MetaData" && } - {treeName === "Glossary" && } - {treeName === "CustomFilters" && } + {treeName === "Entities" && } + {treeName === "Classifications" && } + {treeName === "Business MetaData" && } + {treeName === "Glossary" && } + {treeName === "CustomFilters" && } {displayTreeName} @@ -1121,9 +1121,9 @@ const BarTreeView: FC<{ - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "Glossary") && ( + {(treeName === "Entities" || + treeName === "Classifications" || + treeName === "Glossary") && ( <> { @@ -1144,9 +1144,9 @@ const BarTreeView: FC<{ )} - {(treeName == "Entities" || - treeName == "Classifications" || - treeName == "Glossary") && ( + {(treeName === "Entities" || + treeName === "Classifications" || + treeName === "Glossary") && ( { e.stopPropagation(); @@ -1157,7 +1157,7 @@ const BarTreeView: FC<{ /> )} - {treeName == "Business MetaData" && ( + {treeName === "Business MetaData" && ( - {(treeName == "Entities" || - treeName == "Classifications") && ( + {(treeName === "Entities" || + treeName === "Classifications") && ( { e.stopPropagation(); @@ -1230,14 +1230,14 @@ const BarTreeView: FC<{ )} - {(treeName == "Classifications" || - treeName == "Glossary") && ( + {(treeName === "Classifications" || + treeName === "Glossary") && ( { e.stopPropagation(); - if (treeName == "Classifications") { + if (treeName === "Classifications") { setTagModal(true); - } else if (treeName == "Glossary") { + } else if (treeName === "Glossary") { setGlossaryModal(true); } handleClose(); @@ -1253,13 +1253,13 @@ const BarTreeView: FC<{ Create{" "} - {treeName == "Classifications" + {treeName === "Classifications" ? "Classifications" : "Glossary"} )} - {(treeName == "Entities" || treeName == "Glossary") && ( + {(treeName === "Entities" || treeName === "Glossary") && ( { e.stopPropagation(); @@ -1280,7 +1280,7 @@ const BarTreeView: FC<{ )} - {(treeName == "Entities" || treeName == "Glossary") && ( + {(treeName === "Entities" || treeName === "Glossary") && ( { e.stopPropagation(); @@ -1298,13 +1298,13 @@ const BarTreeView: FC<{ - {treeName == "Entities" + {treeName === "Entities" ? "Import Business Metadata" : "Import Glossary Term"} )} - {treeName == "Glossary" && ( + {treeName === "Glossary" && ( { e.stopPropagation(); @@ -1352,12 +1352,12 @@ const BarTreeView: FC<{ open={openModal} onClose={handleCloseModal} title={ - treeName == "Entities" + treeName === "Entities" ? "Import Business Metadata" : "Import Glossary Term" } onImportSuccess={ - treeName == "Glossary" + treeName === "Glossary" ? () => { void dispatch(fetchGlossaryData()); } diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index b20655ad509..595873a5cb6 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -602,7 +602,8 @@ describe('SideBarBody', () => { fireEvent.click(toggleButton!); await waitFor(() => { - expect(screen.queryByTestId('entities-tree')).not.toBeInTheDocument(); + expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); + expect(screen.getByTestId('entities-tree').closest('.sidebar-wrapper')).toHaveStyle({ display: 'none' }); }); }); }); @@ -765,7 +766,7 @@ describe('SideBarBody', () => { } await waitFor(() => { - expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('glossary-tree')).toHaveLength(1); }); }); @@ -783,7 +784,7 @@ describe('SideBarBody', () => { fireEvent.keyDown(document.activeElement || document.body, { key: 'Escape', code: 'Escape' }); await waitFor(() => { - expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('glossary-tree')).toHaveLength(1); }); }); @@ -814,10 +815,8 @@ describe('SideBarBody', () => { fireEvent.click(entitiesIcon.closest('button')!); await waitFor(() => { - // Entities should be open - expect(screen.getAllByTestId('entities-tree').length).toBeGreaterThan(0); - // Glossary should be closed - expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('entities-tree')).toHaveLength(2); + expect(screen.getAllByTestId('glossary-tree')).toHaveLength(1); }); }); }); From 8c69ede3db5c12e95e7260f48e42c06396ec704b Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Wed, 26 Aug 2026 12:52:24 +0530 Subject: [PATCH 16/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- dashboard/src/components/TreeNodeIcons.tsx | 6 ++-- .../src/components/TreeSkeletonLoader.tsx | 4 +-- .../ClassificationDistributionCard.tsx | 10 ++++--- .../DashboardOverview/EntityStatusDonut.tsx | 11 ++++--- .../DashboardOverview/EntityTypeBarChart.tsx | 2 +- .../MessageConsumptionChart.tsx | 2 +- dashboard/src/views/SideBar/SideBarBody.tsx | 20 ++++--------- .../src/views/Statistics/EntityStatsChart.tsx | 30 +++++++++++-------- 8 files changed, 42 insertions(+), 43 deletions(-) diff --git a/dashboard/src/components/TreeNodeIcons.tsx b/dashboard/src/components/TreeNodeIcons.tsx index adbdb9d497f..47805836d9f 100644 --- a/dashboard/src/components/TreeNodeIcons.tsx +++ b/dashboard/src/components/TreeNodeIcons.tsx @@ -59,7 +59,7 @@ const TreeNodeIcons = (props: { }) => { const { node, treeName, updatedData, isEmptyServicetype, isHovered } = props; const navigate = useNavigate(); - const toastId: any = useRef(null); + const toastId = useRef(null); const [expandNode, setExpandNode] = useState(null); const [renameModal, setRenameModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false); @@ -144,7 +144,7 @@ const TreeNodeIcons = (props: { }, { replace: true } ); - toast.dismiss(toastId.current); + if (toastId.current) toast.dismiss(toastId.current); toastId.current = toast.success(`${node.id} was deleted successfully`); } catch (error) { serverError(error, toastId); @@ -161,7 +161,7 @@ const TreeNodeIcons = (props: { updatedData(); setRenameModal(false); setExpandNode(null); - toast.dismiss(toastId.current); + if (toastId.current) toast.dismiss(toastId.current); toastId.current = toast.success( `${filterData.name} was updated successfully` ); diff --git a/dashboard/src/components/TreeSkeletonLoader.tsx b/dashboard/src/components/TreeSkeletonLoader.tsx index 7d5391bf91b..e8bcd00ae4f 100644 --- a/dashboard/src/components/TreeSkeletonLoader.tsx +++ b/dashboard/src/components/TreeSkeletonLoader.tsx @@ -21,8 +21,8 @@ import SkeletonLoader from "./SkeletonLoader"; const TreeSkeletonLoader = ({ count = 7 }: { count?: number }) => { const treeItemSkeleton = (indentLevel: number, textWidth: string, key: number) => ( - - + + ); diff --git a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx index 41b2741be32..12b2da30c58 100644 --- a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx +++ b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx @@ -60,8 +60,10 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD ); const handleBarClick = useCallback( - (entry: { name: string }) => { - navigateToClassificationSearch(navigate, entry.name); + (entry: { name?: string }) => { + if (entry?.name) { + navigateToClassificationSearch(navigate, entry.name); + } }, [navigate] ); @@ -203,14 +205,14 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD name="Entities" fill={BAR_COLOR} radius={[0, 4, 4, 0]} - onClick={(entry) => handleBarClick(entry)} + onClick={handleBarClick} cursor="pointer" > numberFormatWithComma(v)} + formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} style={{ fontSize: 12, fontWeight: 500, diff --git a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx index 9eb93dc1ed5..ee8a21aab3d 100644 --- a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx +++ b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx @@ -35,7 +35,7 @@ const EntityStatusDonut = memo(({ entity, isLoading }: EntityStatusDonutProps) = const totals = getEntityStatusTotals(entity); const total = totals.active + totals.shell + totals.deleted; - const chartData = [ + const chartData: Array<{ name: string; value: number; color: string }> = [ { name: "Active", value: totals.active, color: COLORS.Active }, { name: "Shell", value: totals.shell, color: COLORS.Shell }, { name: "Deleted", value: totals.deleted, color: COLORS.Deleted } @@ -147,18 +147,21 @@ const EntityStatusDonut = memo(({ entity, isLoading }: EntityStatusDonutProps) = isAnimationActive animationDuration={800} animationEasing="ease-out" - activeIndex={activeIndex} + {...({ activeIndex: activeIndex >= 0 ? activeIndex : undefined } as Record)} activeShape={renderActiveShape} onMouseEnter={(_, index) => setActiveIndex(index)} onMouseLeave={() => setActiveIndex(-1)} - onClick={(data) => handleStatusClick(data.name as "Active" | "Shell" | "Deleted")} + onClick={(data) => { + const d = data as { name?: "Active" | "Shell" | "Deleted" }; + if (d?.name) handleStatusClick(d.name); + }} > {chartData.map((entry, index) => ( ))} numberFormatWithComma(value)} + formatter={(value: unknown) => (typeof value === "number" ? numberFormatWithComma(value) : "")} contentStyle={{ borderRadius: 8 }} /> diff --git a/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx index 184538b5486..0c3806cf8e9 100644 --- a/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx +++ b/dashboard/src/views/DashboardOverview/EntityTypeBarChart.tsx @@ -305,7 +305,7 @@ const EntityTypeBarChart = memo( dataKey="count" position="right" offset={10} - formatter={(v: number) => numberFormatWithComma(v)} + formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} style={{ fontSize: 12, fontWeight: 500, diff --git a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx index f2b98f687c2..06bceb8de9b 100644 --- a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx +++ b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx @@ -234,7 +234,7 @@ const MessageConsumptionChart = memo( dataKey="count" position="top" offset={8} - formatter={(v: number) => numberFormatWithComma(v)} + formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} style={{ fontSize: 11, fontWeight: 600, diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 31ed5a04c1e..91a63e14654 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -74,23 +74,14 @@ const CustomFiltersTree = lazy(() => import("./SideBarTree/CustomFiltersTree")); export const defaultDrawerWidth = "20%"; -const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ - open?: boolean; -}>(({ theme, open }) => ({ +const Main = styled("main")(({ theme }) => ({ flexGrow: 1, - padding: theme.spacing(3), - transition: theme.transitions.create("margin", { + minWidth: 0, + padding: 0, + transition: theme.transitions.create(["margin", "width"], { easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), - marginLeft: `-${defaultDrawerWidth}`, - ...(open && { - transition: theme.transitions.create("margin", { - easing: theme.transitions.easing.easeOut, - duration: theme.transitions.duration.enteringScreen, - }), - marginLeft: 0, - }), })); const DrawerHeader = styled("div")(({ theme }) => ({ @@ -112,7 +103,7 @@ const SideBarBody = (props: { const dispatch = useAppDispatch(); const { handleOpenModal, handleOpenAboutModal } = props; const navigate = useNavigate(); - const { relationshipSearch = false } = globalSessionData || {}; + const relationshipSearch = Boolean(globalSessionData?.relationshipSearch); const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const { data: versionData, loading: isVersionLoading, error: versionError } = useAppSelector((state) => state.session?.versionData || {}); @@ -530,7 +521,6 @@ const SideBarBody = (props: {
{rightSideContent} diff --git a/dashboard/src/views/Statistics/EntityStatsChart.tsx b/dashboard/src/views/Statistics/EntityStatsChart.tsx index 83aad86c6a9..c43ea82d8ef 100644 --- a/dashboard/src/views/Statistics/EntityStatsChart.tsx +++ b/dashboard/src/views/Statistics/EntityStatsChart.tsx @@ -26,6 +26,7 @@ import { XAxis, YAxis, } from "recharts"; +import type { LegendPayload } from "recharts"; import GraphCustomTooltip from "./StatsGraphs/GraphCustomTooltip"; type ChartDataPoint = { @@ -87,21 +88,24 @@ const EntityStatsChart = ({ cursor={{ stroke: "rgba(0, 0, 0, 0.1)", strokeWidth: 2 }} /> { - if (e && e.id) { - onLegendClick(String(e.id)); + onClick={(e: LegendPayload) => { + const key = e?.value || e?.id; + if (key) { + onLegendClick(key); } }} - payload={Object.keys(activeKeys).map((key) => ({ - id: key, - type: "square", - value: key, - color: - activeKeys[key as keyof ActiveKeys] === true - ? getColorForKey(key) - : "#d3d3d3", - inactive: !activeKeys[key as keyof ActiveKeys], - }))} + {...({ + payload: Object.keys(activeKeys).map((key): LegendPayload => ({ + id: key, + value: key, + type: "square", + color: + activeKeys[key as keyof ActiveKeys] === true + ? getColorForKey(key) + : "#d3d3d3", + inactive: !activeKeys[key as keyof ActiveKeys], + })) + } as Record)} /> {activeKeys.Active && ( Date: Thu, 27 Aug 2026 16:50:30 +0530 Subject: [PATCH 17/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../__tests__/EntityDisplayImage.test.tsx | 11 +++ dashboard/src/redux/slice/sessionSlice.ts | 2 +- dashboard/src/styles/sidebar.scss | 32 ++++++- .../Classification/AddValidityPeriod.tsx | 2 +- dashboard/src/views/Layout/About.tsx | 2 +- dashboard/src/views/SideBar/SideBarBody.tsx | 37 ++++---- .../views/SideBar/SideBarTree/SideBarTree.tsx | 89 +++++++++---------- .../SideBar/__tests__/SideBarBody.test.tsx | 21 +---- .../src/views/Statistics/EntityStatsChart.tsx | 12 ++- 9 files changed, 116 insertions(+), 92 deletions(-) diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 34fce046913..2bd3b34e2b5 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -54,6 +54,17 @@ describe('EntityDisplayImage', () => { expect(img?.getAttribute('data-cy')).toBe('entity-1') }) + it('does not render undefined for id or data-cy when entity lacks guid', () => { + const { container } = render( + + ) + + const img = container.querySelector('img') + expect(img).toBeInTheDocument() + expect(img?.getAttribute('id')).toBeNull() + expect(img?.getAttribute('data-cy')).toBeNull() + }) + it('switches to fallback image when native onError is triggered', () => { const { container } = render( diff --git a/dashboard/src/redux/slice/sessionSlice.ts b/dashboard/src/redux/slice/sessionSlice.ts index 57dfa319232..aecb54ae270 100644 --- a/dashboard/src/redux/slice/sessionSlice.ts +++ b/dashboard/src/redux/slice/sessionSlice.ts @@ -21,7 +21,7 @@ import { getVersion } from "@api/apiMethods/headerApiMethods"; import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import { globalSession } from "@utils/Global"; -type DynamicData = Record; +type DynamicData = Record; interface SessionState { sessionObj: { diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index b96a973cc14..c6dbc24d4a8 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -47,7 +47,13 @@ .sidebar-tree-box { flex-grow: 1; top: 128px; - width: inherit; + width: 100%; + min-width: 30px; + + &--hidden { + visibility: hidden; + top: 62px; + } } .loader-box { @@ -108,6 +114,10 @@ display: block; } +.custom-treeitem-content { + position: relative; +} + .treeitem-loader { width: 20px !important; position: absolute; @@ -166,6 +176,10 @@ padding-bottom: 48px; padding-left: 8px; padding-right: 8px; + + &--hidden { + display: none !important; + } } .tree-item-loader { @@ -498,3 +512,19 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m width: 100%; } +.sidebar-menuitem-icon { + min-width: 28px !important; +} + +.sidebar-tree-typography { + font-weight: 500; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-menu-paper { + transition: none !important; +} diff --git a/dashboard/src/views/Classification/AddValidityPeriod.tsx b/dashboard/src/views/Classification/AddValidityPeriod.tsx index 5e3a41fab65..cfed7886e11 100644 --- a/dashboard/src/views/Classification/AddValidityPeriod.tsx +++ b/dashboard/src/views/Classification/AddValidityPeriod.tsx @@ -171,7 +171,7 @@ const AddValidityPeriod = (props: { control: any }) => { control }); - const { timezones = [] } = sessionObj.data || {}; + const timezones = (sessionObj.data?.timezones as string[]) || []; const timeZonesList = timezones.map((obj: string) => ({ label: obj, value: obj diff --git a/dashboard/src/views/Layout/About.tsx b/dashboard/src/views/Layout/About.tsx index f1efebcc140..14b716a3bf9 100644 --- a/dashboard/src/views/Layout/About.tsx +++ b/dashboard/src/views/Layout/About.tsx @@ -37,7 +37,7 @@ const About = () => { Version: - {error ? "Unknown (failed to fetch version)" : (versionData?.Version || "N/A")} + {error ? "Unknown (failed to fetch version)" : ((versionData?.Version as string) || "N/A")} Get involved! diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 91a63e14654..3af9e244b9c 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -151,25 +151,21 @@ const SideBarBody = (props: { const handlePopoverOpen = (event: React.MouseEvent, id: string) => { const target = event.currentTarget; - const openNewPopover = () => { - setPopoverAnchor(target); - setActivePopover(id); - - // Calculate remaining screen height from the anchor to the bottom - const rect = target.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.top - 24; - const isBottom = spaceBelow < 350; - setIsBottomHalf(isBottom); - - if (isBottom) { - const spaceAbove = rect.bottom - 24; - setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`); - } else { - setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`); - } - }; - - openNewPopover(); + setPopoverAnchor(target); + setActivePopover(id); + + // Calculate remaining screen height from the anchor to the bottom + const rect = target.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.top - 24; + const isBottom = spaceBelow < 350; + setIsBottomHalf(isBottom); + + if (isBottom) { + const spaceAbove = rect.bottom - 24; + setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`); + } else { + setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`); + } }; const handlePopoverClose = () => { @@ -402,8 +398,7 @@ const SideBarBody = (props: { )}
({ + "& .MuiTreeItem-label": { + // fontWeight: "400 !important", + fontSize: "14px !important", + lineHeight: "24px !important", + color: "rgba(255,255,255,0.8)", + }, + + "& .MuiTreeItem-content svg": { + color: "rgba(255,255,255,0.8)", + fontSize: "14px !important", + }, +})); + +export const StyledParentTreeItem = styled(TreeItem)(() => ({ + "& .MuiTreeItem-label": { + fontWeight: "600 !important", + fontSize: "14px !important", + lineHeight: "26px !important", + color: "white", + }, + "& .MuiTreeItem-content svg": { + color: "white", + fontSize: "20px !important", + }, +})); + const CustomTreeItem = memo( forwardRef(function CustomTreeItem( props: TreeItemProps, ref: Ref ) { return ( - - } {treeName === "Glossary" && } {treeName === "CustomFilters" && } - {displayTreeName} + {displayTreeName} @@ -1189,10 +1194,8 @@ const BarTreeView: FC<{ onClose={handleClose} transformOrigin={{ horizontal: "right", vertical: "top" }} anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - sx={{ - "& .MuiPaper-root": { - transition: "none !important", - }, + PaperProps={{ + className: "sidebar-menu-paper", }} disableScrollLock={true} > @@ -1210,7 +1213,7 @@ const BarTreeView: FC<{ className="sidebar-menu-item" > {isGroupView ? ( @@ -1246,7 +1249,7 @@ const BarTreeView: FC<{ className="sidebar-menu-item" > @@ -1269,7 +1272,7 @@ const BarTreeView: FC<{ data-cy="downloadBusinessMetadata" className="sidebar-menu-item" > - + - + - + } - sx={{ - "& .MuiTreeItem-label": { - fontWeight: "600 !important", - fontSize: "14px !important", - lineHeight: "26px !important", - color: "white", - }, - "& .MuiTreeItem-content svg": { - color: "white", - fontSize: "20px !important", - }, - }} > {loader ? ( ) : ( filteredData.map((node: TreeNode) => renderTreeItem(node)) )} - + { expect(atlasLogo).toBeInTheDocument(); fireEvent.click(atlasLogo); - expect(atlasLogo).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/search' }, { replace: true }); }); it('should navigate to search page when Apache Atlas logo is clicked', async () => { @@ -413,7 +413,7 @@ describe('SideBarBody', () => { await waitFor(() => { const apacheLogo = screen.getByAltText('Apache Atlas logo'); fireEvent.click(apacheLogo); - expect(apacheLogo).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/search' }, { replace: true }); }); }); @@ -603,7 +603,7 @@ describe('SideBarBody', () => { await waitFor(() => { expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); - expect(screen.getByTestId('entities-tree').closest('.sidebar-wrapper')).toHaveStyle({ display: 'none' }); + expect(screen.getByTestId('entities-tree').closest('.sidebar-wrapper')).toHaveClass('sidebar-wrapper--hidden'); }); }); }); @@ -689,20 +689,7 @@ describe('SideBarBody', () => { }); }); - describe('Window Resize', () => { - it('should handle window resize for drawer width constraints', () => { - // Mock window.innerWidth - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1920 - }); - - renderWithProviders(); - - expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); - }); - }); + describe('Collapsed Sidebar Popovers', () => { beforeEach(() => { diff --git a/dashboard/src/views/Statistics/EntityStatsChart.tsx b/dashboard/src/views/Statistics/EntityStatsChart.tsx index c43ea82d8ef..838b62afd80 100644 --- a/dashboard/src/views/Statistics/EntityStatsChart.tsx +++ b/dashboard/src/views/Statistics/EntityStatsChart.tsx @@ -26,9 +26,19 @@ import { XAxis, YAxis, } from "recharts"; -import type { LegendPayload } from "recharts"; import GraphCustomTooltip from "./StatsGraphs/GraphCustomTooltip"; +// Workaround: Recharts in the current version does not export LegendPayload. +// This type is manually defined here to fix a TS2305 error and should be +// refactored to use the exported type once Recharts is upgraded. +type LegendPayload = { + id?: string; + value?: string; + type?: string; + color?: string; + inactive?: boolean; +}; + type ChartDataPoint = { timestamp: number; Active: number; From 54e9291f485af59164073d4a967db967d09f4ced Mon Sep 17 00:00:00 2001 From: Brijesh Bhalala Date: Thu, 3 Sep 2026 15:21:50 +0530 Subject: [PATCH 18/19] ATLAS-5324: Enhance Collapsed Sidebar with Module Icons, Interactive Tree Tooltips, and Active State Markers --- .../src/components/TreeSkeletonLoader.tsx | 4 +- .../__tests__/EntityDisplayImage.test.tsx | 25 +- dashboard/src/index.scss | 3 +- .../slice/__tests__/sessionSlice.test.ts | 4 +- dashboard/src/redux/slice/sessionSlice.ts | 7 +- dashboard/src/setupTests.simple.ts | 4 +- dashboard/src/setupTests.ts | 10 +- dashboard/src/styles/dashboard.scss | 459 ++++++++++++++++++ dashboard/src/styles/sidebar.scss | 5 + .../Administrator/Audits/AdminAuditTable.tsx | 9 +- .../Classification/AddValidityPeriod.tsx | 16 +- dashboard/src/views/DashBoard.tsx | 9 +- .../ClassificationDistributionCard.tsx | 26 +- .../DashboardOverview/DashboardOverview.tsx | 21 +- .../DashboardOverview/EntityStatusDonut.tsx | 18 +- .../DashboardOverview/EntityTypeBarChart.tsx | 49 +- .../MessageConsumptionChart.tsx | 57 +-- dashboard/src/views/Layout/About.tsx | 2 +- dashboard/src/views/Layout/Layout.tsx | 2 +- .../src/views/Layout/__tests__/About.test.tsx | 12 +- .../Layout/__tests__/DebugMetrics.test.tsx | 62 +-- .../__tests__/SideBarTree.test.tsx | 70 +-- .../SideBar/__tests__/SideBarBody.test.tsx | 89 ++-- 23 files changed, 681 insertions(+), 282 deletions(-) create mode 100644 dashboard/src/styles/dashboard.scss diff --git a/dashboard/src/components/TreeSkeletonLoader.tsx b/dashboard/src/components/TreeSkeletonLoader.tsx index e8bcd00ae4f..6c73dded8b7 100644 --- a/dashboard/src/components/TreeSkeletonLoader.tsx +++ b/dashboard/src/components/TreeSkeletonLoader.tsx @@ -21,8 +21,8 @@ import SkeletonLoader from "./SkeletonLoader"; const TreeSkeletonLoader = ({ count = 7 }: { count?: number }) => { const treeItemSkeleton = (indentLevel: number, textWidth: string, key: number) => ( - - + + ); diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 2bd3b34e2b5..eeac136e8c4 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -156,28 +156,5 @@ describe('EntityDisplayImage', () => { expect(img?.getAttribute('src')).toBe(''); }); - it('explicitly sets onerror to null to prevent infinite loops', () => { - const { container } = render( - - ) - - const img = container.querySelector('img')! - - // Simulate what the browser does natively when an image fails to load - const event = new Event('error'); - Object.defineProperty(event, 'currentTarget', { - value: img, - enumerable: true - }); - - // Add a dummy onerror handler to prove it gets cleared - img.onerror = () => {}; - expect(img.onerror).not.toBeNull(); - - // Call the React onError handler - fireEvent(img, event); - - // The handler should have explicitly cleared the onerror property - expect(img.onerror).toBeNull(); - }); + }); diff --git a/dashboard/src/index.scss b/dashboard/src/index.scss index 3291320343c..27f30d082f6 100644 --- a/dashboard/src/index.scss +++ b/dashboard/src/index.scss @@ -36,6 +36,7 @@ @use "@styles/customdatepPicker.scss" as *; @use "@styles/administration.scss" as *; @use "@styles/glossary.scss" as *; +@use "@styles/dashboard.scss" as *; @font-face { font-family: "Source Sans 3"; @@ -341,4 +342,4 @@ textarea::placeholder { .min-w-0 { min-width: 0 !important; -} \ No newline at end of file +} diff --git a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts index 5c59eccc36c..bf05cb42cb3 100644 --- a/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts +++ b/dashboard/src/redux/slice/__tests__/sessionSlice.test.ts @@ -44,12 +44,12 @@ describe('sessionSlice', () => { const initialState = { sessionObj: { loading: false, - data: null, + data: null as unknown as Record, error: null }, versionData: { loading: false, - data: null, + data: null as unknown as Record, error: null } }; diff --git a/dashboard/src/redux/slice/sessionSlice.ts b/dashboard/src/redux/slice/sessionSlice.ts index aecb54ae270..c21be2bf999 100644 --- a/dashboard/src/redux/slice/sessionSlice.ts +++ b/dashboard/src/redux/slice/sessionSlice.ts @@ -63,7 +63,7 @@ const sessionInitialState: SessionState = { }, versionData: { loading: false, - data: null, + data: null as Record | null, error: null } }; @@ -90,11 +90,12 @@ const sessionSlice = createSlice({ builder.addCase(fetchSessionData.rejected, (state, action) => { state.sessionObj = { loading: false, - data: null, + data: null as Record | null, error: (action.payload as string) || action.error?.message || 'An error occurred' }; }), builder.addCase(fetchVersionData.pending, (state) => { + // Preserve existing state.versionData.data on pending (stale-while-revalidate) state.versionData.loading = true; state.versionData.error = null; }), @@ -111,7 +112,7 @@ const sessionSlice = createSlice({ builder.addCase(fetchVersionData.rejected, (state, action) => { state.versionData = { loading: false, - data: null, + data: null as Record | null, error: (action.payload as string) || action.error?.message || 'An error occurred' }; }); diff --git a/dashboard/src/setupTests.simple.ts b/dashboard/src/setupTests.simple.ts index e18ed273261..17143a88748 100644 --- a/dashboard/src/setupTests.simple.ts +++ b/dashboard/src/setupTests.simple.ts @@ -26,7 +26,7 @@ export {}; // Basic mocks that don't rely on newer JS features -(global as any).ResizeObserver = function() { +(global as unknown as Record).ResizeObserver = function() { return { observe: function() {}, unobserve: function() {}, @@ -34,7 +34,7 @@ export {}; }; }; -(global as any).IntersectionObserver = function() { +(global as unknown as Record).IntersectionObserver = function() { return { observe: function() {}, unobserve: function() {}, diff --git a/dashboard/src/setupTests.ts b/dashboard/src/setupTests.ts index a666f52e40b..1c217110d5c 100644 --- a/dashboard/src/setupTests.ts +++ b/dashboard/src/setupTests.ts @@ -23,8 +23,8 @@ import { TextEncoder, TextDecoder } from 'util'; Object.assign(global, { TextDecoder, TextEncoder }); // Mock ResizeObserver -(global as any).ResizeObserver = class ResizeObserver { - constructor(cb: any) { +(global as unknown as Record).ResizeObserver = class ResizeObserver { + constructor(cb: Record) { this.callback = cb; } @@ -44,8 +44,8 @@ Object.assign(global, { TextDecoder, TextEncoder }); }; // Mock IntersectionObserver -(global as any).IntersectionObserver = class IntersectionObserver { - constructor(cb: any) { +(global as unknown as Record).IntersectionObserver = class IntersectionObserver { + constructor(cb: Record) { this.callback = cb; } @@ -98,7 +98,7 @@ Object.defineProperty(window, 'getComputedStyle', { HTMLCanvasElement.prototype.getContext = jest.fn(); // Polyfill structuredClone for Jest environment -(global as any).structuredClone = (global as any).structuredClone || ((obj: any) => { +(global as unknown as Record).structuredClone = (global as unknown as Record).structuredClone || ((obj: Record) => { return JSON.parse(JSON.stringify(obj)); }); diff --git a/dashboard/src/styles/dashboard.scss b/dashboard/src/styles/dashboard.scss new file mode 100644 index 00000000000..25a1e6dd681 --- /dev/null +++ b/dashboard/src/styles/dashboard.scss @@ -0,0 +1,459 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.admin-audit-table-element-1 { + /* sx */ + margin-top: 13px !important; + margin-left: 13px !important; +} + +.add-validity-period-card-2 { + /* sx */ + margin-bottom: 2rem; +} + +.add-validity-period-custom-button-3 { + /* sx */ + align-self: flex-end; +} + +.add-validity-period-stack-4 { + /* sx */ + min-height: 56px; +} + +.add-validity-period-icon-button-5 { + /* sx */ + display: inline-flex; + position: relative; + padding: 4px; + margin-left: 4px; + margin-top: 1.5rem !important; +} + +.dash-board-stack-6 { + /* sx */ + box-sizing: border-box; + overflow: hidden; +} + +.dash-board-stack-7 { + /* sx */ + flex-shrink: 0; + margin-bottom: 16px; +} + +.dash-board-stack-8 { + /* sx */ + min-width: 0; +} + +.classification-distribution-card-box-9 { + /* sx */ + padding: 12px; + background-color: #ffffff; + border-radius: 4px; + box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + min-width: 140px; +} + +.classification-distribution-card-typography-10 { + /* sx */ + margin-bottom: 4px; +} + +.classification-distribution-card-box-11 { + /* sx */ + padding-bottom: 16px; + border-bottom: 1px solid; + border-color: divider; +} + +.classification-distribution-card-typography-12 { + /* sx */ + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; +} + +.classification-distribution-card-link-13 { + /* sx */ + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + color: #1976d2; +} + +.classification-distribution-card-typography-14 { + /* sx */ + color: #6c757d; + margin-top: 16px; + line-height: 1.4; +} + +.classification-distribution-card-typography-15 { + /* sx */ + color: #868e96; + display: block; + margin-top: 6px; + line-height: 1.4; +} + +.classification-distribution-card-box-16 { + /* sx */ + margin-top: 16px; + min-height: 260px; + height: 260px; + width: 100%; + min-width: 280px; +} + +.classification-distribution-card-responsive-container-17 { + /* style */ + cursor: pointer; +} + +.classification-distribution-card-element-18 { + /* style */ + cursor: pointer; +} + +.classification-distribution-card-element-19 { + /* style */ + font-size: 12px; + font-weight: 500; + fill: #1976d2; +} + +.dashboard-overview-stack-20 { + /* sx */ + max-width: 100%; + box-sizing: border-box; + background-color: #f5f7f9; + border-radius: 8px; + padding: 24px; +} + +.dashboard-overview-grid-21 { + align-items: stretch; +} + +.dashboard-overview-grid-22 { + /* sx */ + display: flex; + min-width: 0; +} + +.dashboard-overview-grid-22 { + /* sx */ + display: flex; + min-width: 0; +} + +.dashboard-overview-grid-22 { + /* sx */ + display: flex; + min-width: 0; +} + +.dashboard-overview-grid-22 { + /* sx */ + display: flex; + min-width: 0; +} + +.dashboard-overview-grid-22 { + /* sx */ + display: flex; + min-width: 0; +} + +.classification-distribution-card-box-11 { + /* sx */ + padding-bottom: 16px; + border-bottom: 1px solid; + border-color: divider; +} + +.classification-distribution-card-typography-12 { + /* sx */ + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; +} + +.entity-status-donut-stack-23 { + /* sx */ + padding-top: 16px; +} + +.entity-status-donut-box-24 { + /* sx */ + width: 12px; + height: 12px; + border-radius: 50%; + background-color: inherit; + flex-shrink: 0; +} + +.entity-status-donut-typography-25 { + /* sx */ + font-size: 0.875rem; + color: #374151; +} + +.classification-distribution-card-responsive-container-17 { + /* style */ + cursor: pointer; +} + +.classification-distribution-card-box-9 { + /* sx */ + padding: 12px; + background-color: #ffffff; + border-radius: 4px; + box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + min-width: 140px; +} + +.classification-distribution-card-typography-10 { + /* sx */ + margin-bottom: 4px; +} + +.entity-type-bar-chart-typography-26 { + /* sx */ + color: #ef4444; +} + +.classification-distribution-card-box-11 { + /* sx */ + padding-bottom: 16px; + border-bottom: 1px solid; + border-color: divider; +} + +.classification-distribution-card-typography-12 { + /* sx */ + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; +} + +.entity-type-bar-chart-link-27 { + /* sx */ + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + color: #1976d2; +} + +.classification-distribution-card-box-16 { + /* sx */ + margin-top: 16px; + min-height: 260px; + height: 260px; + width: 100%; + min-width: 280px; +} + +.entity-type-bar-chart-stack-28 { + /* sx */ + margin-bottom: 8px; + flex-wrap: wrap; +} + +.entity-type-bar-chart-box-29 { + /* sx */ + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #1976d2; +} + +.entity-type-bar-chart-typography-30 { + /* sx */ + color: #6c757d; + font-size: 0.8125rem; +} + +.entity-type-bar-chart-box-31 { + /* sx */ + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #ef4444; +} + +.entity-type-bar-chart-typography-30 { + /* sx */ + color: #6c757d; + font-size: 0.8125rem; +} + +.classification-distribution-card-responsive-container-17 { + /* style */ + cursor: pointer; +} + +.classification-distribution-card-element-18 { + /* style */ + cursor: pointer; +} + +.entity-type-bar-chart-element-32 { + /* style */ + font-size: 12px; + font-weight: 500; + fill: #1976d2; +} + +.message-consumption-chart-box-33 { + /* sx */ + padding: 12px; + background-color: #ffffff; + border-radius: 4px; + box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12); + min-width: 160px; +} + +.classification-distribution-card-typography-10 { + /* sx */ + margin-bottom: 4px; +} + +.message-consumption-chart-typography-34 { + /* sx */ + color: #10b981; +} + +.message-consumption-chart-typography-35 { + /* sx */ + color: #1976d2; +} + +.message-consumption-chart-typography-36 { + /* sx */ + color: #ef4444; +} + +.message-consumption-chart-typography-37 { + /* sx */ + color: #6c757d; + margin-top: 4px; +} + +.message-consumption-chart-typography-38 { + /* sx */ + color: #6c757d; +} + +.message-consumption-chart-box-39 { + /* sx */ + min-height: 260px; + height: 260px; + width: 100%; + min-width: 280px; +} + +.entity-type-bar-chart-stack-28 { + /* sx */ + margin-bottom: 8px; + flex-wrap: wrap; +} + +.message-consumption-chart-box-40 { + /* sx */ + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #10b981; +} + +.entity-type-bar-chart-typography-30 { + /* sx */ + color: #6c757d; + font-size: 0.8125rem; +} + +.message-consumption-chart-box-41 { + /* sx */ + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #1976d2; +} + +.entity-type-bar-chart-typography-30 { + /* sx */ + color: #6c757d; + font-size: 0.8125rem; +} + +.message-consumption-chart-box-42 { + /* sx */ + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #ef4444; +} + +.entity-type-bar-chart-typography-30 { + /* sx */ + color: #6c757d; + font-size: 0.8125rem; +} + +.message-consumption-chart-element-43 { + /* style */ + font-size: 11px; + font-weight: 600; + fill: #374151; +} + +.about-skeleton-loader-44 { + /* sx */ + margin-top: 0px !important; +} + +.layout-circular-progress-45 { + /* sx */ + display: block; + margin-left: auto; + margin-right: auto; +} + +/* Dynamic Classes added for components */ +.entity-status-donut-box-Active { + background-color: #10b981 !important; +} + +.entity-status-donut-box-Shell { + background-color: #f59e0b !important; +} + +.entity-status-donut-box-Deleted { + background-color: #ef4444 !important; +} + +.classification-distribution-card-element-18-pointer { + cursor: pointer !important; +} + +.classification-distribution-card-element-18-default { + cursor: default !important; +} \ No newline at end of file diff --git a/dashboard/src/styles/sidebar.scss b/dashboard/src/styles/sidebar.scss index c6dbc24d4a8..41716de979a 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -528,3 +528,8 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m .sidebar-menu-paper { transition: none !important; } + +.tree-skeleton-item { + border-radius: 4px; + background-color: var(--skeleton-bg, rgba(255,255,255,0.08)); +} diff --git a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx index 92a1af2bf8c..6d3daf5c2cc 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -40,7 +40,7 @@ const AdminAuditTable = () => { const toastId: any = useRef(null); const [loader, setLoader] = useState(true); const [auditData, setAuditData] = useState([]); - const [updateTable, setupdateTable] = useState(moment.now()); + const [_updateTable, setupdateTable] = useState(moment.now()); const [queryApiObj, setQueryApiObj] = useState({}); const [filtersPopover, setFiltersPopover] = useState(null); @@ -99,7 +99,7 @@ const AdminAuditTable = () => { setLoader(false); } }, - [updateTable] + [queryApiObj] ); const defaultColumns = useMemo[]>( @@ -242,10 +242,7 @@ const AdminAuditTable = () => { ) } - sx={{ - marginTop: "13px !important", - marginLeft: "13px !important" - }} + className="admin-audit-table-element-1" > Filters diff --git a/dashboard/src/views/Classification/AddValidityPeriod.tsx b/dashboard/src/views/Classification/AddValidityPeriod.tsx index cfed7886e11..d096b1e3edd 100644 --- a/dashboard/src/views/Classification/AddValidityPeriod.tsx +++ b/dashboard/src/views/Classification/AddValidityPeriod.tsx @@ -179,14 +179,12 @@ const AddValidityPeriod = (props: { control: any }) => { return ( <> - + { @@ -205,7 +203,7 @@ const AddValidityPeriod = (props: { control: any }) => { key={field.id} direction="row" alignItems="center" - sx={{ minHeight: "56px" }} + className="add-validity-period-stack-4" > { aria-label="back" color="error" size="small" - sx={{ - display: "inline-flex", - position: "relative", - padding: "4px", - marginLeft: "4px", - marginTop: "1.5rem !important" - }} + className="add-validity-period-icon-button-5" onClick={() => remove(index)} > diff --git a/dashboard/src/views/DashBoard.tsx b/dashboard/src/views/DashBoard.tsx index c7e7e239ee5..0f2e76acf57 100644 --- a/dashboard/src/views/DashBoard.tsx +++ b/dashboard/src/views/DashBoard.tsx @@ -32,20 +32,19 @@ const DashBoard = () => { position="relative" height="100%" flex="1" - paddingTop={1} - paddingBottom={0} + padding={0} spacing={0} - sx={{ boxSizing: "border-box", overflow: "hidden" }} + className="dash-board-stack-6" > - + diff --git a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx index 12b2da30c58..bda2095d6a1 100644 --- a/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx +++ b/dashboard/src/views/DashboardOverview/ClassificationDistributionCard.tsx @@ -85,8 +85,8 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD const row = p.payload[0]?.payload; if (!row) return null; return ( - - + + {row.name} @@ -113,26 +113,26 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD "&:hover": { boxShadow: 4 } }} > - + - + Classification Distribution View All - + Tag–entity associations (total):{" "} {numberFormatWithComma(associationTotal)} - + The chart shows the top 5 classifications by number of entities in use. {data.length === 0 ? ( @@ -142,8 +142,8 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD ) : ( - - + + (value ? handleLabelClick(value) : undefined)} - style={{ cursor: value ? "pointer" : "default" }} + className={`classification-distribution-card-element-18-${value ? 'pointer' : 'default'}`} role={value ? "button" : undefined} tabIndex={value ? 0 : undefined} aria-label={value || undefined} @@ -213,11 +213,7 @@ const ClassificationDistributionCard = memo(({ tag, isLoading }: ClassificationD position="right" offset={10} formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} - style={{ - fontSize: 12, - fontWeight: 500, - fill: BAR_COLOR, - }} + className="classification-distribution-card-element-19" /> {data.map((_, index) => )} diff --git a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx index 756e9cfaa2a..0f8882bad3a 100644 --- a/dashboard/src/views/DashboardOverview/DashboardOverview.tsx +++ b/dashboard/src/views/DashboardOverview/DashboardOverview.tsx @@ -93,16 +93,9 @@ const DashboardOverview = () => { - + {isLoading ? : } @@ -119,27 +112,27 @@ const DashboardOverview = () => { /> )} - + {isLoading ? ( ) : ( )} - + {latestLoading ? ( ) : ( )} - + - + {isLoading ? : } - + {isLoading ? : } diff --git a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx index ee8a21aab3d..ff50a2a6631 100644 --- a/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx +++ b/dashboard/src/views/DashboardOverview/EntityStatusDonut.tsx @@ -91,12 +91,12 @@ const EntityStatusDonut = memo(({ entity, isLoading }: EntityStatusDonutProps) = "&:hover": { boxShadow: 4 } }} > - - + + Entity Status Overview - + {(["Active", "Shell", "Deleted"] as const).map((status) => ( - + {status} {getPercent(totals[status.toLowerCase() as keyof typeof totals])}% ))} - + - + + {row.name} Active: {numberFormatWithComma(row.active)} - + Deleted: {numberFormatWithComma(row.deleted)} @@ -155,20 +155,15 @@ const EntityTypeBarChart = memo( "&:hover": { boxShadow: 4 }, }} > - + - + Service Type Distribution View All @@ -182,38 +177,28 @@ const EntityTypeBarChart = memo( ) : ( - - + + - + Active - + Deleted - + (value ? handleLabelClick(value) : undefined)} - style={{ cursor: value ? "pointer" : "default" }} + className="classification-distribution-card-element-18" role={value ? "button" : undefined} tabIndex={value ? 0 : undefined} onKeyDown={ @@ -306,11 +291,7 @@ const EntityTypeBarChart = memo( position="right" offset={10} formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} - style={{ - fontSize: 12, - fontWeight: 500, - fill: ACTIVE_COLOR, - }} + className="entity-type-bar-chart-element-32" /> {data.map((_, index) => ( diff --git a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx index 06bceb8de9b..cab8cb9c9a6 100644 --- a/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx +++ b/dashboard/src/views/DashboardOverview/MessageConsumptionChart.tsx @@ -66,30 +66,24 @@ const MessageConsumptionChart = memo( if (!row) return null; return ( - + {row.period} - + Creates: {numberFormatWithComma(row.creates)} - + Updates: {numberFormatWithComma(row.updates)} - + Deletes: {numberFormatWithComma(row.deletes)} - + Messages processed: {numberFormatWithComma(row.count)} - + Avg time (ms): {numberFormatWithComma(row.avgTime)} @@ -112,61 +106,46 @@ const MessageConsumptionChart = memo( Creates Updates Deletes @@ -235,11 +214,7 @@ const MessageConsumptionChart = memo( position="top" offset={8} formatter={(v: unknown) => (typeof v === "number" ? numberFormatWithComma(v) : "")} - style={{ - fontSize: 11, - fontWeight: 600, - fill: "#374151", - }} + className="message-consumption-chart-element-43" /> {data.map((_, index) => ( diff --git a/dashboard/src/views/Layout/About.tsx b/dashboard/src/views/Layout/About.tsx index 14b716a3bf9..861f5b7f306 100644 --- a/dashboard/src/views/Layout/About.tsx +++ b/dashboard/src/views/Layout/About.tsx @@ -32,7 +32,7 @@ const About = () => { <> {loader ? ( - + ) : ( diff --git a/dashboard/src/views/Layout/Layout.tsx b/dashboard/src/views/Layout/Layout.tsx index cd79874f46f..0ee8c1e1418 100644 --- a/dashboard/src/views/Layout/Layout.tsx +++ b/dashboard/src/views/Layout/Layout.tsx @@ -139,7 +139,7 @@ const Layout: React.FC = () => { button2Label="Cancel" button2Handler={handleCloseModal} > - + } > diff --git a/dashboard/src/views/Layout/__tests__/About.test.tsx b/dashboard/src/views/Layout/__tests__/About.test.tsx index eb8e3207e70..ed2084d6076 100644 --- a/dashboard/src/views/Layout/__tests__/About.test.tsx +++ b/dashboard/src/views/Layout/__tests__/About.test.tsx @@ -31,7 +31,7 @@ import * as reducerHook from '@hooks/reducerHook' // Mock SkeletonLoader component jest.mock('@components/SkeletonLoader', () => ({ __esModule: true, - default: ({ count, animation, variant, width, sx }: any) => ( + default: ({ count, animation, variant, width, sx }: Record) => (
({ // Mock MUI components jest.mock('@mui/material', () => ({ - Stack: ({ children, spacing, direction, ...props }: any) => ( + Stack: ({ children, spacing, direction, ...props }: Record) => (
({ {children}
), - Typography: ({ children, variant, color, ...props }: any) => ( + Typography: ({ children, variant, color, ...props }: Record) => (
({ {children}
), - List: ({ children, dense, ...props }: any) => ( + List: ({ children, dense, ...props }: Record) => (
{children}
), - ListItem: ({ children, button, component, href, target, ...props }: any) => ( + ListItem: ({ children, button, component, href, target, ...props }: Record) => (
({ {children}
), - ListItemText: ({ primary, ...props }: any) => ( + ListItemText: ({ primary, ...props }: Record) => (
{primary}
diff --git a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx index 78c2ed763c5..ac9335e9053 100644 --- a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx +++ b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx @@ -36,21 +36,21 @@ jest.mock('@api/apiMethods/metricsApiMethods', () => ({ // Mock MUI components jest.mock('@components/muiComponents', () => ({ - AutorenewIcon: ({ className }: any) =>
AutorenewIcon
, - CustomButton: ({ children, onClick, variant, size, 'data-cy': dataCy }: any) => ( + AutorenewIcon: ({ className }: Record) =>
AutorenewIcon
, + CustomButton: ({ children, onClick, variant, size, 'data-cy': dataCy }: Record) => ( ), - LightTooltip: ({ children, title }: any) => ( + LightTooltip: ({ children, title }: Record) => (
{children}
), - LinkTab: ({ label }: any) =>
{label}
+ LinkTab: ({ label }: Record) =>
{label}
})) // Mock TableLayout component jest.mock('@components/Table/TableLayout', () => ({ - TableLayout: ({ data, columns, emptyText, isFetching, columnVisibility, clientSideSorting, columnSort, showPagination, showRowSelection, tableFilters }: any) => ( + TableLayout: ({ data, columns, emptyText, isFetching, columnVisibility, clientSideSorting, columnSort, showPagination, showRowSelection, tableFilters }: Record) => (
{isFetching &&
Loading...
} {!isFetching && (!data || data.length === 0) &&
{emptyText}
} @@ -83,21 +83,21 @@ jest.mock('@components/Table/TableLayout', () => ({ // Mock MUI components jest.mock('@mui/material', () => ({ Divider: () =>
Divider
, - Grid: ({ children, container }: any) =>
{children}
, - List: ({ children, className }: any) =>
{children}
, - ListItem: ({ children, className }: any) =>
{children}
, - ListItemText: ({ primary, secondary }: any) => ( + Grid: ({ children, container }: Record) =>
{children}
, + List: ({ children, className }: Record) =>
{children}
, + ListItem: ({ children, className }: Record) =>
{children}
, + ListItemText: ({ primary, secondary }: Record) => (
{primary}
{secondary}
), - Stack: ({ children, ...props }: any) =>
{children}
, - styled: (component: any) => (styles: any) => component, - Tabs: ({ children, value, className, 'data-cy': dataCy }: any) => ( + Stack: ({ children, ...props }: Record) =>
{children}
, + styled: (component: Record) => (styles: Record) => component, + Tabs: ({ children, value, className, 'data-cy': dataCy }: Record) => (
{children}
), - Tooltip: ({ children, title, arrow, placement, classes }: any) => { + Tooltip: ({ children, title, arrow, placement, classes }: Record) => { // Call the styled component's theme function to cover line 53 const theme = createTheme() const styledStyles = { @@ -119,7 +119,7 @@ jest.mock('@mui/material', () => ({ tooltipClasses: { tooltip: 'tooltip-class' }, - Typography: ({ children, color, className }: any) => ( + Typography: ({ children, color, className }: Record) => (
{children}
) })) @@ -137,9 +137,9 @@ const mockCustomSortBy = jest.fn() const mockServerError = jest.fn() jest.mock('@utils/Utils', () => ({ - isEmpty: (val: any) => mockIsEmpty(val), + isEmpty: (val: Record) => mockIsEmpty(val), customSortBy: (arr: any, keys: string[]) => mockCustomSortBy(arr, keys), - serverError: (error: any, toastId: any) => mockServerError(error, toastId) + serverError: (error: any, toastId: Record) => mockServerError(error, toastId) })) // Mock moment @@ -154,7 +154,7 @@ jest.mock('moment', () => { // Mock Item component jest.mock('@utils/Muiutils', () => ({ - Item: ({ children, variant, className }: any) => ( + Item: ({ children, variant, className }: Record) => (
{children}
) })) @@ -194,14 +194,14 @@ describe('DebugMetrics', () => { beforeEach(() => { jest.clearAllMocks() - mockIsEmpty.mockImplementation((val: any) => { + mockIsEmpty.mockImplementation((val: Record) => { if (val == null) return true if (Array.isArray(val)) return val.length === 0 if (typeof val === 'object') return Object.keys(val).length === 0 if (val === '') return true return false }) - mockCustomSortBy.mockImplementation((arr: any) => arr || []) + mockCustomSortBy.mockImplementation((arr: Record) => arr || []) mockMomentNow.mockReturnValue(1234567890) mockGetDebugMetrics.mockResolvedValue({ data: mockDebugMetricsData @@ -355,7 +355,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyName }) - mockIsEmpty.mockImplementation((val: any) => val === '') + mockIsEmpty.mockImplementation((val: Record) => val === '') mockCustomSortBy.mockReturnValue([dataWithEmptyName['api1']]) render() @@ -376,7 +376,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithNullName }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithNullName['api1']]) render() @@ -407,7 +407,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyNumops }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithEmptyNumops['api1']]) render() @@ -439,7 +439,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyMinTime }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithEmptyMinTime['api1']]) render() @@ -492,7 +492,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyMaxTime }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithEmptyMaxTime['api1']]) render() @@ -545,7 +545,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyAvgTime }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithEmptyAvgTime['api1']]) render() @@ -674,7 +674,7 @@ describe('DebugMetrics', () => { it('should handle empty debugMetricsData object', async () => { mockGetDebugMetrics.mockResolvedValue({ data: {} }) - mockIsEmpty.mockImplementation((val: any) => { + mockIsEmpty.mockImplementation((val: Record) => { if (val == null) return true if (typeof val === 'object' && Object.keys(val).length === 0) return true return false @@ -1053,7 +1053,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithEmptyStringName }) - mockIsEmpty.mockImplementation((val: any) => val === '') + mockIsEmpty.mockImplementation((val: Record) => val === '') mockCustomSortBy.mockReturnValue([dataWithEmptyStringName['api1']]) render() @@ -1074,7 +1074,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithUndefinedName }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithUndefinedName['api1']]) render() @@ -1095,7 +1095,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithAllNullTimes }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithAllNullTimes['api1']]) render() @@ -1117,7 +1117,7 @@ describe('DebugMetrics', () => { } } mockGetDebugMetrics.mockResolvedValue({ data: dataWithAllEmpty }) - mockIsEmpty.mockImplementation((val: any) => val == null) + mockIsEmpty.mockImplementation((val: Record) => val == null) mockCustomSortBy.mockReturnValue([dataWithAllEmpty['api1']]) render() @@ -1200,7 +1200,7 @@ describe('DebugMetrics', () => { it('should handle API response with empty data object', async () => { mockGetDebugMetrics.mockResolvedValue({ data: {} }) - mockIsEmpty.mockImplementation((val: any) => { + mockIsEmpty.mockImplementation((val: Record) => { if (val == null) return true if (typeof val === 'object' && Object.keys(val).length === 0) return true return false diff --git a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx index f049414dcdc..79dea44b449 100644 --- a/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx @@ -75,7 +75,7 @@ jest.mock('@redux/slice/glossarySlice', () => ({ })) jest.mock('@components/ImportDialog', () => { - return function MockImportDialog(props: any) { + return function MockImportDialog(props: Record) { return props.open ? (
@@ -203,7 +203,7 @@ jest.mock('@components/muiComponents', () => ({ })) jest.mock('@utils/Muiutils', () => ({ - AntSwitch: ({ onClick, inputProps, ...props }: any) => ( + AntSwitch: ({ onClick, inputProps, ...props }: Record) => (
Switch
) })) @@ -220,13 +220,13 @@ jest.mock('@mui/icons-material/Refresh', () => ({ jest.mock('@mui/icons-material/LaunchOutlined', () => ({ __esModule: true, - default: ({ onClick }: any) =>
Launch
+ default: ({ onClick }: Record) =>
Launch
})) jest.mock('@mui/material/Stack', () => ({ __esModule: true, - default: ({ children, className, sx }: any) => ( + default: ({ children, className, sx }: Record) => (
{children}
) })) @@ -237,7 +237,7 @@ describe('SideBarTree', () => { const mockGetBusinessMetadataImportTmpl = getBusinessMetadataImportTmpl as jest.MockedFunction const mockGetGlossaryImportTmpl = getGlossaryImportTmpl as jest.MockedFunction - const createMockStore = (initialState: any = {}) => { + const createMockStore = (initialState: Record = {}) => { return configureStore({ reducer: { savedSearch: (state = initialState.savedSearch || { savedSearchData: [] }) => state, @@ -262,7 +262,7 @@ describe('SideBarTree', () => { } ] - const renderComponent = (props: any = {}, storeState: any = {}, initialEntries = ['/']) => { + const renderComponent = (props: Record = {}, storeState: Record = {}, initialEntries = ['/']) => { const store = createMockStore(storeState) return render( @@ -287,7 +287,7 @@ describe('SideBarTree', () => { beforeEach(() => { jest.clearAllMocks() - mockFetchGlossaryData.mockReturnValue({ type: 'glossary/fetchGlossaryData' } as any) + mockFetchGlossaryData.mockReturnValue({ type: 'glossary/fetchGlossaryData' } as unknown as Record) global.URL.createObjectURL = jest.fn(() => 'blob:url') global.URL.revokeObjectURL = jest.fn() @@ -569,7 +569,7 @@ describe('SideBarTree', () => { it('should download Business Metadata template', async () => { mockGetBusinessMetadataImportTmpl.mockResolvedValue({ data: 'template content' - } as any) + } as unknown as Record) // Mock createElement for link creation const mockLink = { @@ -580,10 +580,10 @@ describe('SideBarTree', () => { const originalCreateElement = document.createElement document.createElement = jest.fn((tagName: string) => { if (tagName === 'a') { - return mockLink as any + return mockLink as unknown as Record } return originalCreateElement.call(document, tagName) - }) as any + }) as unknown as Record renderComponent({ treeName: 'Entities' }) @@ -616,7 +616,7 @@ describe('SideBarTree', () => { it('should download Glossary template', async () => { mockGetGlossaryImportTmpl.mockResolvedValue({ data: 'template content' - } as any) + } as unknown as Record) // Mock createElement for link creation const mockLink = { @@ -627,10 +627,10 @@ describe('SideBarTree', () => { const originalCreateElement = document.createElement document.createElement = jest.fn((tagName: string) => { if (tagName === 'a') { - return mockLink as any + return mockLink as unknown as Record } return originalCreateElement.call(document, tagName) - }) as any + }) as unknown as Record renderComponent({ treeName: 'Glossary', @@ -1684,10 +1684,10 @@ describe('SideBarTree', () => { const originalCreateElement = document.createElement document.createElement = jest.fn((tagName: string) => { if (tagName === 'a') { - return mockLink as any + return mockLink as unknown as Record } return originalCreateElement.call(document, tagName) - }) as any + }) as unknown as Record renderComponent({ treeName: 'Entities' }) @@ -1720,7 +1720,7 @@ describe('SideBarTree', () => { }) it('should handle empty API response', async () => { - mockGetBusinessMetadataImportTmpl.mockResolvedValue({ data: '' } as any) + mockGetBusinessMetadataImportTmpl.mockResolvedValue({ data: '' } as unknown as Record) const mockLink = { href: '', @@ -1730,10 +1730,10 @@ describe('SideBarTree', () => { const originalCreateElement = document.createElement document.createElement = jest.fn((tagName: string) => { if (tagName === 'a') { - return mockLink as any + return mockLink as unknown as Record } return originalCreateElement.call(document, tagName) - }) as any + }) as unknown as Record renderComponent({ treeName: 'Entities' }) diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 55136fde775..b630bd00f32 100644 --- a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx +++ b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx @@ -33,7 +33,7 @@ jest.mock('react-quill-new', () => { const React = require('react'); return { __esModule: true, - default: React.forwardRef(({ value, onChange }: any, ref: any) => ( + default: React.forwardRef(({ value, onChange }: any, ref: Record) => (