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-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/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 d9223d1d25b..c95ec307214 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -15,86 +15,58 @@ * 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"; +interface DisplayImageProps { + entity: Record; + width?: string | number; + height?: string | number; + avatarDisplay?: boolean; + isProcess?: boolean; +} + const DisplayImage = ({ entity, width, height, avatarDisplay, isProcess -}: any) => { - const [imageUrl, setImageUrl] = useState(null); - const [checkEntityImage, setCheckEntityImage] = useState({ - [entity.guid]: false - }); - - useEffect(() => { - const fetchImagePath = async () => { - let entityData = { ...entity, ...{ isProcess: isProcess } }; - let imagePath: any = getEntityIconPath({ entityData: entityData }); - try { - const response = await fetch(imagePath); - const contentType: any = response.headers.get("Content-Type"); - - if (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 }) - ); - } - }; +}: DisplayImageProps) => { + const entityData = { ...entity, isProcess: isProcess }; + + const primaryUrl = getEntityIconPath({ entityData }) || ""; + const fallbackUrl = getEntityIconPath({ entityData, errorUrl: primaryUrl }) || ""; - fetchImagePath(); - }, []); + const handleError = (e: React.SyntheticEvent) => { + const target = e.currentTarget; + if (target.src !== fallbackUrl) { + target.onerror = null; + target.src = fallbackUrl; + } + }; - 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/SidebarSearchInput.tsx b/dashboard/src/components/SidebarSearchInput.tsx new file mode 100644 index 00000000000..77f927855f7 --- /dev/null +++ b/dashboard/src/components/SidebarSearchInput.tsx @@ -0,0 +1,78 @@ +/* + * 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/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 41f759ec1da..4b419fc1ba6 100644 --- a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx +++ b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx @@ -15,271 +15,100 @@ * 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 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) { - (global as any).fetch = jest.fn().mockRejectedValue(new Error('fetch failed')) - return - } - (global as any).fetch = jest.fn().mockResolvedValue({ - ok: true, - headers: { - get: jest.fn((header: string) => { - return header === 'Content-Type' ? (contentType || '') : null - }) - } - }) -} - 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/components/__tests__/SidebarSearchInput.test.tsx b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx new file mode 100644 index 00000000000..06714b07c31 --- /dev/null +++ b/dashboard/src/components/__tests__/SidebarSearchInput.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 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/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/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/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 4f6fbb583fd..459d2accb5d 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; @@ -193,9 +209,10 @@ } .tree-item-parent-label { - border-bottom: "1px solid rgba(25,255,255,0.1)"; + border-bottom: 1px solid rgba(25,255,255,0.1); } + .sidebar-searchbar { background: #f1f1f1 !important; display: flex; @@ -213,8 +230,7 @@ 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; } @@ -222,3 +238,105 @@ 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; +} +.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/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 86732832284..6f0f17e8f05 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -217,6 +217,10 @@ const AdminAuditTable = () => { ) } + sx={{ + marginTop: "13px !important", + marginLeft: "13px !important" + }} > Filters 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/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/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/Layout/__tests__/About.test.tsx b/dashboard/src/views/Layout/__tests__/About.test.tsx index 2ccb2720e31..b82d024b5f6 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,43 @@ 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) + it('should handle versionData with undefined Version property', () => { + useAppSelectorSpy.mockReturnValue({ data: { Description: 'Some description' }, loading: false }) 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' } - }) - - 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' } - }) + it('should render gracefully on Redux error state', () => { + useAppSelectorSpy.mockReturnValue({ data: null, loading: false, error: 'Network error' }) 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() - }) - + // 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/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 91b5bda096f..98ad3913ebf 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -21,11 +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 { @@ -39,25 +41,23 @@ 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 { 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"; -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 SkeletonLoader from "@components/SkeletonLoader"; const Header = lazy(() => import("@views/Layout/Header")); @@ -101,54 +101,93 @@ const DrawerHeader = styled("div")(({ theme }) => ({ marginBottom: "1rem", })); + const SideBarBody = (props: { - loading: boolean; - handleOpenModal: any; - handleOpenAboutModal: any; + handleOpenModal: () => void; + handleOpenAboutModal: () => void; }) => { const location = useLocation(); 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 { data: versionData } = 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 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 [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; + 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) => { + 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 (newPosition < minPosition) { - newPosition = minPosition; - } else if (newPosition > maxPosition) { - newPosition = maxPosition; + // If a different popover is already open, close it first to ensure clean unmount + if (activePopover && activePopover !== id) { + handlePopoverClose(); + setTimeout(openNewPopover, 0); + } else { + openNewPopover(); } - - setPosition(newPosition); }; - const handleMouseUp = () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); + const handlePopoverClose = () => { + setPopoverAnchor(null); + setActivePopover(null); }; - const handleMouseDown = () => { - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }; + + + const renderPopoverSearch = () => ( +
+ +
+ ); + + const headerRef = useRef(null); useEffect(() => { dispatch(fetchTypeHeaderData()); @@ -156,6 +195,7 @@ const SideBarBody = (props: { dispatch(fetchRootClassification()); dispatch(fetchEnumData()); dispatch(fetchMetricEntity()); + dispatch(fetchVersionData()); }, [dispatch]); const handleAtlasLogoClick = useCallback(() => { @@ -179,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 { @@ -199,6 +231,49 @@ 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 + Apache Atlas logo +
+ + {/* Module Icons for Mini Drawer */} + + {/* Search */} + + + setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + 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()} + + + + ))} + + + -
+ PaperProps={{ + sx: { + ml: 2, + width: 320, + maxHeight: popoverMaxHeight, + display: 'flex', + flexDirection: 'column', + backgroundColor: '#034858', + 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: '#034858', + borderLeft: '1px solid rgba(255, 255, 255, 0.15)', + borderBottom: '1px solid rgba(255, 255, 255, 0.15)', + transform: 'rotate(45deg)', + zIndex: 1 + } + } + }} + > + {renderPopoverSearch()} +
+ }> +
+ {modules.filter(m => m.isVisible && activePopover === m.id).map(m => { + const Component = m.Component; + return ; + })} +
+
+
+ + )} {open && ( @@ -308,28 +464,11 @@ const SideBarBody = (props: { data-cy="atlas-logo" /> - - ) => { - setSearchTerm(e.target.value); - }} - data-cy="searchNode" - /> - - - - - + )} @@ -337,160 +476,109 @@ const SideBarBody = (props: { className="sidebar-wrapper" sx={{ flex: 1, - overflow: "hidden auto", - paddingBottom: "0px", // Account for bottom toggle button - ...(open == false && { + overflowX: "hidden", + overflowY: "auto", + paddingBottom: "48px", // Added space so it doesn't touch the bottom toggle button + ...(!open && { overflow: "hidden", + display: "none", }), }} > -
- - // - // - } - > - - -
+ {open && ( + <> +
+ } + > + + +
-
- - // - // - } - > - - -
+
+ } + > + + +
-
- - // - // - } - > - - -
+
+ } + > + + +
-
- - // - // - } - > - - -
- {relationshipSearch && ( -
- + } + > + - // - // - } + +
+ {relationshipSearch && ( +
+ } + > + + +
+ )} + +
- - -
+ } + > + + + + )} - -
- - // - // - } - > - - -
+ {open && ( + + + {versionData?.Version ? `V ${versionData.Version}` : ''} + + + )} + 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 ec36f54abf4..5222ff205f5 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -76,24 +76,46 @@ 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; - 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; +}; + +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} + + ); }; 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%", @@ -119,7 +141,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", @@ -196,8 +220,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); @@ -275,6 +303,7 @@ const BarTreeView: FC<{ searchTerm: string; sideBarOpen: boolean; loader?: boolean; + isPopover?: boolean; }> = ({ treeData, treeName, @@ -286,1008 +315,1069 @@ const BarTreeView: FC<{ sideBarOpen, searchTerm, loader, + isPopover, }) => { - 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 } = useAppSelector( + (state) => 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; + 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 } = useAppSelector( + (state) => state.businessMetaData as unknown as { businessMetaData?: { businessMetadataDefs?: EnumTypeDefData[] } } + ); - const highlightText = useMemo(() => { - return (text: string) => { - if (!searchTerm) return text; + const filteredData = useMemo(() => { + if (!searchTerm) return treeData; + + const lowerTerm = searchTerm.toLowerCase(); - 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 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(() => { + return treeName === "CustomFilters" ? "Custom Filters" : treeName + }, [treeName]); + + const highlightText = useMemo(() => { + return (text: string) => { + if (!searchTerm) return text; + + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const parts = text.split(new RegExp(`(${escapedSearchTerm})`, "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]); - useEffect(() => { - setExpandedItems(expandedItemsMemo); - }, [expandedItemsMemo]); + 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; + }; - 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 || {}) as { name?: string }; - 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, + }); } - return; - } - - if (shouldSetSearchParams(node, treeName)) { - setSearchParams( - node, - treeName, - searchParams, - isEmptyServicetype, - savedSearchData + 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, + }); + } + }; + + 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" && } + {treeName === "Relationships" && } + {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" - > - - - - - Create{" "} - {treeName == "Classifications" - ? "Classifications" - : "Glossary"} - - - )} - {(treeName == "Entities" || treeName == "Glossary") && ( - { - e.stopPropagation(); - downloadFile(); - handleClose(); - }} - data-cy="downloadBusinessMetadata" - disabled={ - treeName == "Glossary" && !isEmptyServicetype - ? true - : false - } - className="sidebar-menu-item" - > - - - - - Download Import template - - - )} - {(treeName == "Entities" || treeName == "Glossary") && ( - { - e.stopPropagation(); - handleOpenModal(); - handleClose(); - }} - data-cy="importBusinessMetadata" - disabled={ - treeName == "Glossary" && !isEmptyServicetype - ? true - : false - } - className="sidebar-menu-item" - > - - - - - - {treeName == "Entities" - ? "Import Business Metadata" - : "Import Glossary Term"} - - - )} - - - } - 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)) - )} - - - + + + {treeName == "Entities" + ? "Import Business Metadata" + : "Import Glossary Term"} + + + )} + + + } + 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)) + )} + + + + + + + + {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 ab02347a99e..5420b74aa9f 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' @@ -104,6 +105,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') @@ -139,8 +146,8 @@ jest.mock('@mui/x-tree-view/TreeItem', () => { ) } - return { - TreeItem, + return { + TreeItem, useTreeItemState, TreeItemProps: {}, TreeItemContentProps: {} @@ -151,7 +158,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) => ( @@ -260,7 +267,7 @@ describe('SideBarTree', () => { jest.clearAllMocks() 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 @@ -308,7 +315,7 @@ describe('SideBarTree', () => { renderComponent({ loader: true }) await waitFor(() => { - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() + expect(screen.getAllByTestId('tree-skeleton-loader').length).toBeGreaterThan(0) }) }) @@ -316,7 +323,7 @@ describe('SideBarTree', () => { renderComponent({ loader: false }) await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() + expect(screen.queryByTestId('tree-skeleton-loader')).not.toBeInTheDocument() }) }) }) @@ -570,7 +577,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { fireEvent.click(downloadItem) } @@ -620,7 +627,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { fireEvent.click(downloadItem) } @@ -649,7 +656,7 @@ describe('SideBarTree', () => { await waitFor(() => { const menuItems = screen.getAllByTestId('menu-item') const downloadItem = menuItems.find(item => item.textContent?.includes('Download')) - + if (downloadItem) { expect(downloadItem).toHaveAttribute('data-disabled', 'true') } @@ -677,15 +684,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() @@ -712,7 +719,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) @@ -745,7 +752,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const createItem = menuItems.find(item => item.textContent?.includes('Create')) - + if (createItem) { fireEvent.click(createItem) } @@ -773,7 +780,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const createItem = menuItems.find(item => item.textContent?.includes('Create')) - + if (createItem) { fireEvent.click(createItem) } @@ -1319,10 +1326,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'ADVANCED', - searchParameters: { query: 'test' } + savedSearchData: [{ + name: 'filter1', + searchType: 'ADVANCED', + searchParameters: { query: 'test' } }] } }, ['/search/searchResult']) @@ -1349,10 +1356,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: mockEntityFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: mockEntityFilters } }] } }, ['/search/searchResult']) @@ -1379,10 +1386,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { tagFilters: mockTagFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { tagFilters: mockTagFilters } }] } }, ['/search/searchResult']) @@ -1409,10 +1416,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']) @@ -1432,10 +1439,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']) @@ -1455,10 +1462,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { typeName: 'EntityType' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { typeName: 'EntityType' } }] } }, ['/search/searchResult']) @@ -1478,10 +1485,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { classification: 'Tag1' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { classification: 'Tag1' } }] } }, ['/search/searchResult']) @@ -1501,14 +1508,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']) @@ -1613,7 +1620,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) @@ -1659,7 +1666,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) @@ -1697,10 +1704,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: nestedFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: nestedFilters } }] } }, ['/search/searchResult']) @@ -1727,10 +1734,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: qbFilters } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: qbFilters } }] } }, ['/search/searchResult']) @@ -1750,10 +1757,10 @@ describe('SideBarTree', () => { treeData }, { savedSearch: { - savedSearchData: [{ - name: 'filter1', - searchType: 'BASIC', - searchParameters: { entityFilters: 'invalid' } + savedSearchData: [{ + name: 'filter1', + searchType: 'BASIC', + searchParameters: { entityFilters: 'invalid' } }] } }, ['/search/searchResult']) @@ -1917,7 +1924,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') } @@ -1942,7 +1949,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') } @@ -1967,7 +1974,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const importItem = menuItems.find(item => item.textContent?.includes('Import')) - + if (importItem) { expect(importItem).toHaveAttribute('data-disabled', 'true') } @@ -2016,7 +2023,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) @@ -2042,4 +2049,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 5b76d74752b..d31b70db7d6 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'; @@ -25,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', () => { @@ -135,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', () => ({ @@ -153,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
@@ -180,6 +183,10 @@ describe('SideBarBody', () => { entity: () => ({ loading: false, entityData: {} + }), + session: () => ({ + sessionObj: { loading: false, data: null, error: null }, + versionData: { loading: false, data: null, error: null } }) } }); @@ -207,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(() => { @@ -252,7 +260,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 +356,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 +368,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' } }); @@ -454,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 }); @@ -536,7 +550,7 @@ describe('SideBarBody', () => { fireEvent.click(toggleButton!); await waitFor(() => { - expect(screen.getByTestId('entities-tree')).toHaveTextContent('Open: false'); + expect(screen.queryByTestId('entities-tree')).not.toBeInTheDocument(); }); }); }); @@ -595,7 +609,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 +619,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: '!@#$%^&*()' } }); @@ -636,4 +650,103 @@ 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 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(); + }); + }); + }); + + 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; + }); + }); });