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..91e83398e54 100644 --- a/dashboard/src/components/EntityDisplayImage.tsx +++ b/dashboard/src/components/EntityDisplayImage.tsx @@ -15,8 +15,7 @@ * 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"; const DisplayImage = ({ @@ -26,75 +25,40 @@ const DisplayImage = ({ avatarDisplay, isProcess }: any) => { - const [imageUrl, setImageUrl] = useState(null); - const [checkEntityImage, setCheckEntityImage] = useState({ - [entity.guid]: false - }); + const entityData = { ...entity, isProcess: isProcess }; + + const primaryUrl = getEntityIconPath({ entityData }) || ""; + const fallbackUrl = getEntityIconPath({ entityData, errorUrl: primaryUrl }) || ""; - useEffect(() => { - const fetchImagePath = async () => { - let entityData = { ...entity, ...{ isProcess: isProcess } }; - let imagePath: any = getEntityIconPath({ entityData: entityData }); - try { - const response = await fetch(imagePath); - const contentType: any = response.headers.get("Content-Type"); + const handleError = (e: React.SyntheticEvent) => { + const target = e.currentTarget; + if (target.src !== fallbackUrl) { + target.onerror = null; + target.src = fallbackUrl; + } + }; - 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 }) - ); - } - }; - - fetchImagePath(); - }, []); - - return imageUrl != undefined ? ( + return (
- {checkEntityImage[entity.guid] !== false ? ( - avatarDisplay == undefined ? ( - Entity Icon - ) : ( - - ) - ) : avatarDisplay == undefined ? ( + {avatarDisplay == undefined ? ( Entity Icon ) : ( )}
- ) : ( -
{}
); }; diff --git a/dashboard/src/components/GlobalSearch/QuickSearch.tsx b/dashboard/src/components/GlobalSearch/QuickSearch.tsx index 58452964b0f..52acc36d875 100644 --- a/dashboard/src/components/GlobalSearch/QuickSearch.tsx +++ b/dashboard/src/components/GlobalSearch/QuickSearch.tsx @@ -198,24 +198,24 @@ const QuickSearch = () => { entities = !isEmpty(searchResults?.entities) ? searchResults?.entities?.map((entityDef: any) => { - const { name }: { name: string; found: boolean; key: any } = - extractKeyValueFromEntity(entityDef); - return { - title: `${name}`, - parent: entityDef.typeName, - types: "Entities", - entityObj: entityDef - }; - }) + const { name }: { name: string; found: boolean; key: any } = + extractKeyValueFromEntity(entityDef); + return { + title: `${name}`, + parent: entityDef.typeName, + types: "Entities", + entityObj: entityDef + }; + }) : [{ title: "No Entities Found", types: "Entities" }]; suggestionNames = !isEmpty(suggestions) ? suggestions.map((suggestion: any) => { - return { - title: `${suggestion}`, - types: "Suggestions" - }; - }) + return { + title: `${suggestion}`, + types: "Suggestions" + }; + }) : [{ title: "No Suggestions Found", types: "Suggestions" }]; setOptions([...entities, ...suggestionNames] as GlobalOptionRow[]); @@ -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 @@ -494,14 +495,14 @@ const QuickSearch = () => { const { entityObj, types, parent } = typeof option !== "string" && - "entityObj" in option && - "types" in option && - "parent" in option + "entityObj" in option && + "types" in option && + "parent" in option ? (option as { - entityObj: { status?: string; guid?: string }; - types: string; - parent: string; - }) + entityObj: { status?: string; guid?: string }; + types: string; + parent: string; + }) : { entityObj: null, types: "", parent: "" }; const title = typeof option !== "string" && "title" in option @@ -557,7 +558,7 @@ const QuickSearch = () => { to={{ pathname: href }} color={ entityObj?.status && - entityStateReadOnly[entityObj.status] + entityStateReadOnly[entityObj.status] ? "error" : "primary" } @@ -567,48 +568,48 @@ const QuickSearch = () => { )} {types === "Entities" && !isEmpty(entityObj) ? parts.map((part, index) => ( - - {entityObj?.guid !== "-1" && !part.highlight ? ( - + {entityObj?.guid !== "-1" && !part.highlight ? ( + - {part.text} - - ) : ( - part.text - )} - - )) + ? "error" + : "primary" + } + > + {part.text} + + ) : ( + part.text + )} + + )) : parts.map((part, index) => ( - - {part.text} - - ))} + + {part.text} + + ))} {types === "Entities" && !isEmpty(entityObj) && ` (${parent})`} @@ -640,11 +641,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", @@ -681,7 +684,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" @@ -696,6 +703,10 @@ const QuickSearch = () => { backgroundColor: "white !important", color: "#4a90e2 !important", borderColor: "#dddddd !important", + height: "32px !important", + minHeight: "32px !important", + maxHeight: "32px !important", + boxSizing: "border-box", "&:hover": { backgroundColor: "rgba(74, 144, 226, 0.08) !important", color: "#4a90e2 !important" diff --git a/dashboard/src/components/TreeNodeIcons.tsx b/dashboard/src/components/TreeNodeIcons.tsx index 5403a2a9d63..3a613d3b2dd 100644 --- a/dashboard/src/components/TreeNodeIcons.tsx +++ b/dashboard/src/components/TreeNodeIcons.tsx @@ -55,8 +55,9 @@ const TreeNodeIcons = (props: { treeName: string; updatedData: any; isEmptyServicetype: boolean | undefined; + isHovered?: boolean; }) => { - const { node, treeName, updatedData, isEmptyServicetype } = props; + const { node, treeName, updatedData, isEmptyServicetype, isHovered } = props; const navigate = useNavigate(); const toastId: any = useRef(null); const [expandNode, setExpandNode] = useState(null); @@ -189,6 +190,7 @@ const TreeNodeIcons = (props: { size="small" className="tree-item-more-label" data-cy="dropdownMenuButton" + style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > @@ -209,6 +211,7 @@ const TreeNodeIcons = (props: { className="tree-item-more-label" size="small" data-cy="dropdownMenuButton" + style={{ visibility: isHovered || openNode ? "visible" : "hidden" }} > diff --git a/dashboard/src/components/TreeSkeletonLoader.tsx b/dashboard/src/components/TreeSkeletonLoader.tsx new file mode 100644 index 00000000000..4b206b5e71b --- /dev/null +++ b/dashboard/src/components/TreeSkeletonLoader.tsx @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Stack } from "@mui/material"; +import SkeletonLoader from "./SkeletonLoader"; + +const TreeSkeletonLoader = ({ count = 7 }: { count?: number }) => { + const treeItemSkeleton = (indentLevel: number, textWidth: string, key: number) => ( + + + + + ); + + const allRows = [ + treeItemSkeleton(0, "80%", 0), + treeItemSkeleton(1, "65%", 1), + treeItemSkeleton(1, "75%", 2), + treeItemSkeleton(2, "50%", 3), + treeItemSkeleton(2, "60%", 4), + treeItemSkeleton(0, "85%", 5), + treeItemSkeleton(1, "70%", 6) + ]; + + return ( + + {allRows.slice(0, count)} + + ); +}; + +export default TreeSkeletonLoader; diff --git a/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx b/dashboard/src/components/__tests__/EntityDisplayImage.test.tsx index 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__/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..ffa05952f07 100644 --- a/dashboard/src/styles/sidebar.scss +++ b/dashboard/src/styles/sidebar.scss @@ -49,10 +49,10 @@ flex-grow: 1; // position: fixed; top: 128px; - overflow-y: auto; // height: calc(100vh - 128px); width: inherit; } + .loader-box { min-height: 180px; flex-grow: 1; @@ -63,6 +63,7 @@ height: calc(100vh - 128px); width: 100%; } + .sidebar-treeview { position: relative; } @@ -88,13 +89,26 @@ color: v.$text-green; margin-bottom: 2px; } + .menuitem-label { color: v.$text-grey; } + .custom-treeitem-label { display: flex; align-items: center; gap: 0.5rem; + + .action-icon { + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease; + } + + &:hover .action-icon { + opacity: 1; + visibility: visible; + } } .custom-treeitem-icon { @@ -125,6 +139,7 @@ align-items: center; padding: 8px 16px 8px 8px !important; } + .modal-close-icon { position: absolute !important; right: 12px; @@ -154,7 +169,8 @@ } .tree-item-label { - width: calc(100% - 50px); + flex: 1; + min-width: 0; text-overflow: ellipsis; overflow: hidden; font-size: 14px; @@ -196,6 +212,7 @@ border-bottom: "1px solid rgba(25,255,255,0.1)"; } + .sidebar-searchbar { background: #f1f1f1 !important; display: flex; @@ -213,12 +230,11 @@ button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-m font-size: 1.25rem !important; } -button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-more-label - svg.MuiSvgIcon-root { +button.MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeSmall.tree-item-more-label svg.MuiSvgIcon-root { font-size: 1.25rem !important; color: white !important; } .sidebar-menu-item { padding: 4px 10px !important; -} +} \ No newline at end of file diff --git a/dashboard/src/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 e8e72bd4455..c54b1d0249d 100644 --- a/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx +++ b/dashboard/src/views/Administrator/Audits/AdminAuditTable.tsx @@ -220,7 +220,6 @@ const AdminAuditTable = () => { ) } sx={{ - zIndex: "99999", marginTop: "13px !important", marginLeft: "13px !important" }} 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 7cf9799fad0..4de04fad84e 100644 --- a/dashboard/src/views/Layout/Layout.tsx +++ b/dashboard/src/views/Layout/Layout.tsx @@ -122,7 +122,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..6defb435606 100644 --- a/dashboard/src/views/Layout/__tests__/About.test.tsx +++ b/dashboard/src/views/Layout/__tests__/About.test.tsx @@ -23,14 +23,10 @@ */ import React from 'react' -import { render, screen, waitFor } from '@utils/test-utils' +import { render, screen } from '@utils/test-utils' +import '@testing-library/jest-dom' import About from '../About' - -// Mock API methods -const mockGetVersion = jest.fn() -jest.mock('@api/apiMethods/headerApiMethods', () => ({ - getVersion: (...args: any[]) => mockGetVersion(...args) -})) +import * as reducerHook from '@hooks/reducerHook' // Mock SkeletonLoader component jest.mock('@components/SkeletonLoader', () => ({ @@ -95,31 +91,16 @@ jest.mock('@mui/material', () => ({ ) })) -// Mock Utils -const mockServerError = jest.fn() -jest.mock('@utils/Utils', () => ({ - serverError: (...args: any[]) => mockServerError(...args) -})) - -// Mock console.error to avoid noise in test output -const originalConsoleError = console.error -beforeAll(() => { - console.error = jest.fn() -}) - -afterAll(() => { - console.error = originalConsoleError -}) - describe('About', () => { + let useAppSelectorSpy: jest.SpyInstance + beforeEach(() => { jest.clearAllMocks() - mockGetVersion.mockClear() - mockServerError.mockClear() + useAppSelectorSpy = jest.spyOn(reducerHook, 'useAppSelector') }) - it('should render skeleton loader when loading', async () => { - mockGetVersion.mockImplementation(() => new Promise(() => {})) // Never resolves + it('should render skeleton loader when loading', () => { + useAppSelectorSpy.mockReturnValue({ data: {}, loading: true }) render() @@ -132,24 +113,17 @@ describe('About', () => { expect(skeletonLoader).toHaveAttribute('data-width', '100%') }) - it('should render version data when API call succeeds', async () => { + it('should render version data when loading is false and data is available', () => { const mockVersionData = { Version: '3.0.0-SNAPSHOT', Description: 'Metadata Management Platform', Revision: 'abc123' } - mockGetVersion.mockResolvedValue({ - data: mockVersionData - }) + useAppSelectorSpy.mockReturnValue({ data: mockVersionData, loading: false }) render() - // Wait for loading to finish - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - // Check version is displayed expect(screen.getByText(/Version:/i)).toBeInTheDocument() expect(screen.getByText('3.0.0-SNAPSHOT')).toBeInTheDocument() @@ -169,17 +143,11 @@ describe('About', () => { expect(listItem).toHaveAttribute('data-target', '_blank') }) - it('should render empty version when API returns empty data object', async () => { - mockGetVersion.mockResolvedValue({ - data: {} - }) + it('should render empty version when data object is empty', () => { + useAppSelectorSpy.mockReturnValue({ data: {}, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - // Version should be displayed but empty expect(screen.getByText(/Version:/i)).toBeInTheDocument() const versionTypography = screen.getByTestId('typography-body1') @@ -187,235 +155,30 @@ describe('About', () => { expect(versionTypography.textContent).toContain('Version:') }) - it('should render empty version when API returns undefined data', async () => { - mockGetVersion.mockResolvedValue({ - data: undefined - }) + it('should render empty version when data is undefined', () => { + useAppSelectorSpy.mockReturnValue({ data: undefined, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() }) - it('should render empty version when API returns null data', async () => { - mockGetVersion.mockResolvedValue({ - data: null - }) + it('should render empty version when data is null', () => { + useAppSelectorSpy.mockReturnValue({ data: null, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() }) - it('should handle API call error and call serverError', async () => { - const mockError = new Error('Network error') - mockGetVersion.mockRejectedValue(mockError) - - render() - - await waitFor(() => { - expect(mockServerError).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - // Should still show content (not skeleton) after error - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) - - it('should handle API call error with response data', async () => { - const mockError = { - response: { - data: { - errorMessage: 'Server error occurred' - } - } - } - mockGetVersion.mockRejectedValue(mockError) - - render() - - await waitFor(() => { - expect(mockServerError).toHaveBeenCalledWith(mockError, expect.any(Object)) - }) - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should call getVersion on component mount', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - expect(mockGetVersion).toHaveBeenCalledTimes(1) - expect(mockGetVersion).toHaveBeenCalledWith() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should render correct Typography variants and colors', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '2.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - // Check Typography variants - const body1Typography = screen.getByTestId('typography-body1') - expect(body1Typography).toBeInTheDocument() - - const body2Typographies = screen.getAllByTestId('typography-body2') - expect(body2Typographies.length).toBeGreaterThan(0) - - // Check color prop for "Get involved!" text - const getInvolvedTypography = body2Typographies.find( - (el) => el.textContent === 'Get involved!' - ) - expect(getInvolvedTypography).toHaveAttribute('data-color', 'info.main') - }) - - it('should render Stack components with correct props', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - // Check main Stack - const mainStack = screen.getByTestId('stack') - expect(mainStack).toHaveAttribute('data-spacing', '2') - - // Check column Stack - const columnStack = screen.getByTestId('stack-column') - expect(columnStack).toHaveAttribute('data-spacing', '1') - expect(columnStack).toHaveAttribute('data-direction', 'column') - }) - - it('should render List with dense prop', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - const list = screen.getByTestId('list') - expect(list).toHaveAttribute('data-dense', 'true') - }) - - it('should render ListItemText with correct primary text', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) + it('should handle versionData with undefined Version property', () => { + useAppSelectorSpy.mockReturnValue({ data: { Description: 'Some description' }, loading: false }) render() - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - const listItemText = screen.getByTestId('list-item-text') - expect(listItemText).toHaveAttribute( - 'data-primary', - 'Licensed under the Apache License Version 2.0' - ) - }) - - it('should handle versionData with undefined Version property', async () => { - mockGetVersion.mockResolvedValue({ - data: { Description: 'Some description' } - }) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - expect(screen.getByText(/Version:/i)).toBeInTheDocument() const versionTypography = screen.getByTestId('typography-body1') expect(versionTypography.textContent).toContain('Version:') expect(versionTypography.textContent).not.toContain('undefined') }) - - it('should set loader to false after successful API call', async () => { - mockGetVersion.mockResolvedValue({ - data: { Version: '1.0.0' } - }) - - render() - - // Initially should show loader - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() - - // After API resolves, loader should be hidden - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should set loader to false after failed API call', async () => { - mockGetVersion.mockRejectedValue(new Error('API Error')) - - render() - - // Initially should show loader - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() - - // After API rejects, loader should be hidden - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - }) - - it('should handle API response with null response object', async () => { - mockGetVersion.mockResolvedValue(null) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) - - it('should handle API response with undefined response object', async () => { - mockGetVersion.mockResolvedValue(undefined) - - render() - - await waitFor(() => { - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/Version:/i)).toBeInTheDocument() - }) }) diff --git a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx index ffc296dd8ad..78c2ed763c5 100644 --- a/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx +++ b/dashboard/src/views/Layout/__tests__/DebugMetrics.test.tsx @@ -24,6 +24,7 @@ import React from 'react' import { render, screen, fireEvent, waitFor } from '@utils/test-utils' +import '@testing-library/jest-dom' import { ThemeProvider, createTheme } from '@mui/material/styles' import DebugMetrics from '../DebugMetrics' diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 91b5bda096f..b41a57e84f6 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -25,7 +25,9 @@ import { KeyboardEvent, lazy, useRef, + useMemo, } from "react"; +import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; import atlasLogo from "/img/atlas_logo.svg"; import apacheAtlasLogo from "/img/apache-atlas-logo.svg"; import { @@ -39,25 +41,25 @@ import { import Drawer from "@mui/material/Drawer"; import CssBaseline from "@mui/material/CssBaseline"; import { IconButton } from "@components/muiComponents"; -import { useSelector } from "react-redux"; -import SearchIcon from "@mui/icons-material/Search"; -import { InputBase, Paper, Stack } from "@mui/material"; -import { TypeHeaderState } from "@models/treeStructureType.js"; + +import ClearIcon from "@mui/icons-material/Clear"; +import { getVersion } from "@api/apiMethods/headerApiMethods"; +import { InputBase, Paper, Stack, Box, Popover, Typography, Tooltip, CircularProgress } from "@mui/material"; import { globalSessionData, PathAssociateWithModule } from "@utils/Enum"; import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft"; import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; -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")); @@ -102,7 +104,6 @@ const DrawerHeader = styled("div")(({ theme }) => ({ })); const SideBarBody = (props: { - loading: boolean; handleOpenModal: any; handleOpenAboutModal: any; }) => { @@ -110,17 +111,86 @@ const SideBarBody = (props: { const routes = useRoutes(AppRoutes as RouteObject[]); const history = useHistory(); const dispatch = useAppDispatch(); - const { loading: loader, handleOpenModal, handleOpenAboutModal } = props; + const { handleOpenModal, handleOpenAboutModal } = props; const navigate = useNavigate(); - const { loading } = useSelector((state: TypeHeaderState) => state.typeHeader); const { relationshipSearch = {} } = globalSessionData || {}; const [open, setOpen] = useState(true); const [searchTerm, setSearchTerm] = useState(""); + const { data: versionData } = useAppSelector((state: any) => 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 [popoverAnchor, setPopoverAnchor] = useState(null); + const [activePopover, setActivePopover] = useState(null); + const [popoverMaxHeight, setPopoverMaxHeight] = useState('calc(100vh - 100px)'); + + const handlePopoverOpen = (event: React.MouseEvent, id: string) => { + setPopoverAnchor(event.currentTarget); + setActivePopover(id); + + // Calculate remaining screen height from the anchor to the bottom + const rect = event.currentTarget.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.top - 24; // 24px margin from bottom + // Give it a minimum sensible height of 300px just in case, otherwise use available space + setPopoverMaxHeight(`${Math.max(300, spaceBelow)}px`); + }; + + const handlePopoverClose = () => { + setPopoverAnchor(null); + setActivePopover(null); + }; + + + + const renderPopoverSearch = () => ( +
+ + ) => setSearchTerm(e.target.value)} + endAdornment={ + + {searchTerm.length > 0 && ( + setSearchTerm("")} + edge="end" + sx={{ padding: "4px" }} + > + + + )} + Search + + } + /> + +
+ ); + const [position, setPosition] = useState(defaultDrawerWidth); const draggerRef = useRef(null); const headerRef = useRef(null); @@ -156,6 +226,7 @@ const SideBarBody = (props: { dispatch(fetchRootClassification()); dispatch(fetchEnumData()); dispatch(fetchMetricEntity()); + dispatch(fetchVersionData()); }, [dispatch]); const handleAtlasLogoClick = useCallback(() => { @@ -199,6 +270,73 @@ const SideBarBody = (props: { }); const matched = matchRoutes(routeConfig, location.pathname); + const isMatched = !!matched; + + const rightSideContent = useMemo(() => ( + +
+ +
+ +
+
+ {isMatched || location.pathname.includes("!") ? ( + + +
+ } + > + + {" "} + + + ) : ( + + )} +
+ + ), [isMatched, location.pathname, history, handleOpenModal, handleOpenAboutModal]); return ( - {/* Collapsed sidebar logo */} + {/* Collapsed sidebar logo and module icons */} {!open && ( -
- Apache Atlas logo -
+ role="button" + tabIndex={0} + aria-label="Atlas home — refresh dashboard" + onClick={handleAtlasLogoClick} + onKeyDown={handleAtlasLogoKeyDown} + data-cy="apache-atlas-logo-collapsed" + > + Apache Atlas logo +
+ + {/* Module Icons for Mini Drawer */} + + {/* Search */} + + + setOpen(true)} sx={{ '&:hover': { background: 'rgba(255, 255, 255, 0.1)' } }}> + search + + + + + {modules.filter(m => m.isVisible).map(m => ( + + + handlePopoverOpen(e, m.id)} sx={{ color: m.isActive ? "white" : "rgba(255, 255, 255, 0.6)", '&:hover': { color: 'white', background: 'rgba(255, 255, 255, 0.1)' } }}> + {m.title.toLowerCase()} + + + + ))} + + + + {renderPopoverSearch()} +
+ }> +
+ {modules.filter(m => m.isVisible && activePopover === m.id).map(m => { + const Component = m.Component; + return ; + })} +
+
+
+
+ )} {open && ( @@ -311,24 +500,36 @@ const SideBarBody = (props: { ) => { setSearchTerm(e.target.value); }} data-cy="searchNode" + endAdornment={ + + {searchTerm.length > 0 && ( + setSearchTerm("")} + edge="end" + sx={{ padding: "4px" }} + > + + + )} + Search + + } /> - - - - @@ -337,160 +538,122 @@ const SideBarBody = (props: { className="sidebar-wrapper" sx={{ flex: 1, - overflow: "hidden auto", - paddingBottom: "0px", // Account for bottom toggle button + overflowX: "hidden", + overflowY: "auto", + paddingBottom: "48px", // Added space so it doesn't touch the bottom toggle button ...(open == false && { overflow: "hidden", + display: "none", }), }} > -
- - // - // - } - > - - -
+ {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..1bde918271b 100644 --- a/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx +++ b/dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx @@ -76,24 +76,40 @@ import AddUpdateGlossaryForm from "@views/Glossary/AddUpdateGlossaryForm"; import RefreshIcon from "@mui/icons-material/Refresh"; import { AntSwitch } from "@utils/Muiutils"; import { IconButton } from "@components/muiComponents"; -import SkeletonLoader from "@components/SkeletonLoader"; + +import TreeSkeletonLoader from "@components/TreeSkeletonLoader"; type CustomContentRootProps = HTMLAttributes & { selectedNodeType?: any; selectedNodeTag?: any; selectedNodeRelationship?: any; selectedNodeBM?: any; + selectedNodeTerm?: any; + selectedNodeCustomFilter?: any; node?: any; selectedNode?: any; }; +const HoverableTreeItemContainer = ({ children, ...props }: any) => { + const [isHovered, setIsHovered] = useState(false); + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + {...props} + > + {typeof children === "function" ? children(isHovered) : children} +
+ ); +}; + const CustomContentRoot = styled("div")( ({ theme, ...props }) => ({ WebkitTapHighlightColor: "#0E8173", "&&:hover, &&.Mui-disabled, &&.Mui-focused, &&.Mui-selected, &&.Mui-selected.Mui-focused, &&.Mui-selected:hover": - { - backgroundColor: "transparent", - }, + { + backgroundColor: "transparent", + }, ".MuiTreeItem-contentBar": { position: "absolute", width: "100%", @@ -119,7 +135,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 +214,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 +297,7 @@ const BarTreeView: FC<{ searchTerm: string; sideBarOpen: boolean; loader?: boolean; + isPopover?: boolean; }> = ({ treeData, treeName, @@ -286,1008 +309,1054 @@ 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 }: 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; + term: string | null; + customFilter: string | null; + }>({ + type: null, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, }); - }, [treeData, searchTerm]); - const displayTreeName = useMemo(() => { - return treeName === "CustomFilters" ? "Custom Filters" : treeName - }, [treeName]); + const [openModal, setOpenModal] = useState(false); + const toastId: any = useRef(null); + const open = Boolean(expand); + const [expandedItems, setExpandedItems] = useState([]); + const [tagModal, setTagModal] = useState(false); + const [glossaryModal, setGlossaryModal] = useState(false); + const { businessMetaData }: any = useAppSelector( + (state: any) => state.businessMetaData + ); - const highlightText = useMemo(() => { - return (text: string) => { - if (!searchTerm) return text; + const filteredData = useMemo(() => { + return treeData.filter((node) => { + return ( + node.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + (node.children && + node.children.some((child) => + child.label?.toLowerCase().includes(searchTerm.toLowerCase()) + )) + ); + }); + }, [treeData, searchTerm]); + + const displayTreeName = useMemo(() => { + return treeName === "CustomFilters" ? "Custom Filters" : treeName + }, [treeName]); + + const highlightText = useMemo(() => { + return (text: string) => { + if (!searchTerm) return text; + + const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === searchTerm.toLowerCase() ? ( + + {part} + + ) : ( + part + ) + ); + }; + }, [searchTerm]); + + const expandedItemsMemo = useMemo(() => { + const allNodeIds = filteredData.flatMap((node) => { + return [ + node.id, + ...(node.children ? node.children.map((child) => child.id) : []), + ]; + }); + return [...allNodeIds, ...[treeName]]; + }, [filteredData, treeName]); - const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); - return parts.map((part, index) => - part.toLowerCase() === searchTerm.toLowerCase() ? ( - - {part} - - ) : ( - part - ) - ); + useEffect(() => { + setExpandedItems(expandedItemsMemo); + }, [expandedItemsMemo]); + + const getNodeId = (node: TreeNode) => { + if (treeName == "Classifications" && node.types == "parent") { + return node.label; + } else if (treeName == "Classifications" && node.types == "child") { + return `${node.id}@${node.label}`; + } + return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; }; - }, [searchTerm]); - - const expandedItemsMemo = useMemo(() => { - const allNodeIds = filteredData.flatMap((node) => { - return [ - node.id, - ...(node.children ? node.children.map((child) => child.id) : []), - ]; - }); - return [...allNodeIds, ...[treeName]]; - }, [filteredData, treeName]); - - useEffect(() => { - setExpandedItems(expandedItemsMemo); - }, [expandedItemsMemo]); - useEffect(() => { - const searchParams = new URLSearchParams(location.search); - const nodeIdFromParamsType = searchParams.get("type"); - const nodeIdFromParamsTag = searchParams.get("tag"); - const nodeIdFromParamsRelationshipName = - searchParams.get("relationshipName"); - const nodeIdFromBMName = location.pathname.includes( - "/administrator/businessMetadata" - ); + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const nodeIdFromParamsType = searchParams.get("type"); + const nodeIdFromParamsTag = searchParams.get("tag"); + const nodeIdFromParamsRelationshipName = + searchParams.get("relationshipName"); + const nodeIdFromBMName = location.pathname.includes( + "/administrator/businessMetadata" + ); + const nodeIdFromParamsTerm = searchParams.get("term") || searchParams.get("category") || searchParams.get("gtype") || location.pathname.split("/glossary/")[1]; + const nodeIdFromCustomFilter = searchParams.get("customFilter"); - const bmObj = !isEmpty(businessMetaData?.businessMetadataDefs) - ? businessMetaData?.businessMetadataDefs?.find((obj: EnumTypeDefData) => { + const bmObj = !isEmpty(businessMetaData?.businessMetadataDefs) + ? businessMetaData?.businessMetadataDefs?.find((obj: EnumTypeDefData) => { if (bmguid == obj.guid) { return obj; } }) - : {}; - const { name = "" } = bmObj || {}; - - setSelectedNode({ - type: nodeIdFromParamsType, - tag: nodeIdFromParamsTag, - relationship: nodeIdFromParamsRelationshipName, - businessMetadata: nodeIdFromBMName ? name : null, - }); + : {}; + const { name = "" } = bmObj || {}; - if ( - !nodeIdFromParamsType && - !nodeIdFromParamsTag && - !nodeIdFromParamsRelationshipName && - !nodeIdFromBMName - ) { setSelectedNode({ - type: null, - tag: null, - relationship: null, - businessMetadata: null, + type: nodeIdFromParamsType, + tag: nodeIdFromParamsTag, + relationship: nodeIdFromParamsRelationshipName, + businessMetadata: nodeIdFromBMName ? name : null, + term: nodeIdFromParamsTerm || null, + customFilter: nodeIdFromCustomFilter || null, }); - } - }, [location.search]); - const getEmptyTypesTitle = () => { - switch (treeName) { - case "Entities": - return `${isEmptyServicetype ? "Hide" : "Show"} empty service types`; + if (nodeIdFromParamsTerm && typeof nodeIdFromParamsTerm === "string" && nodeIdFromParamsTerm.includes("@") && treeName === "Glossary") { + const glossaryName = nodeIdFromParamsTerm.split("@")[1]; + if (glossaryName) { + const parentNode = treeData.find((n) => n.label === glossaryName); + if (parentNode) { + const nodeIdToExpand = getNodeId(parentNode); + setExpandedItems((prev) => { + if (!prev.includes(nodeIdToExpand)) { + return [...prev, nodeIdToExpand]; + } + return prev; + }); + } + } + } - case "Classifications": - return `${isEmptyServicetype ? "Show" : "Hide"} unused classifications`; + if ( + !nodeIdFromParamsType && + !nodeIdFromParamsTag && + !nodeIdFromParamsRelationshipName && + !nodeIdFromBMName && + !nodeIdFromParamsTerm && + !nodeIdFromCustomFilter + ) { + setSelectedNode({ + type: null, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + }, [location.search, treeData, treeName, businessMetaData, bmguid]); - case "Glossary": - return `Show ${isEmptyServicetype ? "Category" : "Term"}`; + const getEmptyTypesTitle = () => { + switch (treeName) { + case "Entities": + return `${isEmptyServicetype ? "Hide" : "Show"} empty service types`; - case "CustomFilters": - return `Show ${isEmptyServicetype ? "all" : "Type"}`; + case "Classifications": + return `${isEmptyServicetype ? "Show" : "Hide"} unused classifications`; - default: - return ""; - } - }; + case "Glossary": + return `Show ${isEmptyServicetype ? "Category" : "Term"}`; - const handleExpandedItemsChange = ( - _event: SyntheticEvent, - newExpandedItems: string[] - ) => { - setExpandedItems(newExpandedItems); - }; + case "CustomFilters": + return `Show ${isEmptyServicetype ? "all" : "Type"}`; - const handleOpenModal = () => { - setOpenModal(true); - }; - const handleCloseModal = () => { - setOpenModal(false); - }; + default: + return ""; + } + }; - const handleClickMenu = (event: MouseEvent) => { - event.stopPropagation(); - setExpand(event.currentTarget); - }; + const handleExpandedItemsChange = ( + _event: SyntheticEvent, + newExpandedItems: string[] + ) => { + setExpandedItems(newExpandedItems); + }; - const handleClose = () => { - setExpand(null); - }; + const handleOpenModal = () => { + setOpenModal(true); + }; + const handleCloseModal = () => { + setOpenModal(false); + }; - const handleCloseTagModal = () => { - setTagModal(false); - }; - const handleCloseGlossaryModal = () => { - setGlossaryModal(false); - }; + const handleClickMenu = (event: MouseEvent) => { + event.stopPropagation(); + setExpand(event.currentTarget); + }; - const handleClickNode = (nodeId: string) => { - const searchParams = new URLSearchParams(location.search); - const isTypeMatch = searchParams.get("type") === nodeId; - const isTagMatch = searchParams.get("tag") === nodeId; - const isRelationshipMatch = searchParams.get("relationshipName") === nodeId; - const isBusinessMetadataMatch = location.pathname.includes( - "/administrator/businessMetadata" - ); + const handleClose = () => { + setExpand(null); + }; - if (isTypeMatch) { - setSelectedNode({ - type: nodeId, - tag: null, - relationship: null, - businessMetadata: null, - }); - } - if (isTagMatch) { - setSelectedNode({ - type: null, - tag: nodeId, - relationship: null, - businessMetadata: null, - }); - } - if (isRelationshipMatch) { - setSelectedNode({ - type: null, - tag: null, - relationship: nodeId, - businessMetadata: null, - }); - } - if (isBusinessMetadataMatch) { - setSelectedNode({ - type: null, - tag: null, - relationship: null, - businessMetadata: nodeId, - }); - } - }; + const handleCloseTagModal = () => { + setTagModal(false); + }; + const handleCloseGlossaryModal = () => { + setGlossaryModal(false); + }; - const getNodeId = (node: TreeNode) => { - if (treeName == "Classifications" && node.types == "parent") { - return node.label; - } else if (treeName == "Classifications" && node.types == "child") { - return `${node.id}@${node.label}`; - } - return !isEmpty(node?.parent) ? `${node.id}@${node?.parent}` : node.id; - }; + const handleClickNode = (nodeId: string) => { + const searchParams = new URLSearchParams(location.search); + const isTypeMatch = searchParams.get("type") === nodeId; + const isTagMatch = searchParams.get("tag") === nodeId; + const isRelationshipMatch = searchParams.get("relationshipName") === nodeId; + const isBusinessMetadataMatch = location.pathname.includes( + "/administrator/businessMetadata" + ); + + if (isTypeMatch) { + setSelectedNode({ + type: nodeId, + tag: null, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isTagMatch) { + setSelectedNode({ + type: null, + tag: nodeId, + relationship: null, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isRelationshipMatch) { + setSelectedNode({ + type: null, + tag: null, + relationship: nodeId, + businessMetadata: null, + term: null, + customFilter: null, + }); + } + if (isBusinessMetadataMatch) { + setSelectedNode({ + type: null, + tag: null, + relationship: null, + businessMetadata: nodeId, + term: null, + customFilter: null, + }); + } + }; + + 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; + } - 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 (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; - } - - if (shouldSetSearchParams(node, treeName)) { - setSearchParams( - node, - treeName, - searchParams, - isEmptyServicetype, - savedSearchData + 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(), - }, - { replace: true } - ); - } - break; - case "Relationships": - case "CustomFilters": - if ( - treeName == "Relationships" || - (treeName == "CustomFilters" && node.parent == "BASIC_RELATIONSHIP") - ) { - navigate( - { - pathname: `relationship/relationshipSearchresult`, - search: searchParams.toString(), - }, + { pathname: `administrator/businessMetadata/${node.guid}` }, { 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..bd218532446 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' @@ -139,8 +140,8 @@ jest.mock('@mui/x-tree-view/TreeItem', () => { ) } - return { - TreeItem, + return { + TreeItem, useTreeItemState, TreeItemProps: {}, TreeItemContentProps: {} @@ -260,7 +261,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 +309,7 @@ describe('SideBarTree', () => { renderComponent({ loader: true }) await waitFor(() => { - expect(screen.getByTestId('skeleton-loader')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0) }) }) @@ -570,7 +571,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 +621,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 +650,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 +678,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 +713,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 +746,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 +774,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 +1320,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 +1350,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 +1380,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 +1410,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 +1433,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 +1456,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 +1479,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 +1502,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 +1614,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 +1660,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 +1698,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 +1728,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 +1751,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 +1918,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 +1943,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 +1968,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 +2017,7 @@ describe('SideBarTree', () => { const menuItems = screen.getAllByTestId('menu-item') const toggleItem = menuItems.find(item => item.textContent?.includes('flat')) - + if (toggleItem) { await act(async () => { fireEvent.click(toggleItem) diff --git a/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx b/dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx index 5b76d74752b..988c8ba10eb 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'; @@ -180,6 +181,10 @@ describe('SideBarBody', () => { entity: () => ({ loading: false, entityData: {} + }), + session: () => ({ + sessionObj: { loading: false, data: null, error: null }, + versionData: { loading: false, data: null, error: null } }) } }); @@ -252,7 +257,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 +353,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 +365,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' } }); @@ -536,7 +541,7 @@ describe('SideBarBody', () => { fireEvent.click(toggleButton!); await waitFor(() => { - expect(screen.getByTestId('entities-tree')).toHaveTextContent('Open: false'); + expect(screen.queryByTestId('entities-tree')).not.toBeInTheDocument(); }); }); }); @@ -595,7 +600,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 +610,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 +641,85 @@ describe('SideBarBody', () => { expect(screen.getByTestId('entities-tree')).toBeInTheDocument(); }); }); + + describe('Collapsed Sidebar Popovers', () => { + beforeEach(() => { + // Start with closed drawer to see popover icons + renderWithProviders(); + const toggleButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleButton!); + }); + + it('should open correct popover when module icon is clicked', async () => { + // Find the glossary icon and click it + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + // Popover should render the glossary tree + const glossaryTrees = screen.getAllByTestId('glossary-tree'); + expect(glossaryTrees.length).toBeGreaterThan(0); + }); + }); + + it('should share search term between sidebar and popover', async () => { + // Re-open sidebar to access main search input + const toggleOpenButton = screen.getByTestId('KeyboardDoubleArrowRightIcon').closest('button'); + fireEvent.click(toggleOpenButton!); + + // Set search term in the main search bar + const searchInput = screen.getAllByPlaceholderText('Search')[0]; + fireEvent.change(searchInput, { target: { value: 'popover_search' } }); + + // Close sidebar + const toggleCloseButton = screen.getByTestId('KeyboardDoubleArrowLeftIcon').closest('button'); + fireEvent.click(toggleCloseButton!); + + // Click entities icon + const entitiesIcon = screen.getByAltText('entities'); + fireEvent.click(entitiesIcon.closest('button')!); + + await waitFor(() => { + // Popover should receive the search term + const entitiesTree = screen.getAllByTestId('entities-tree').find( + el => el.textContent?.includes('Search: popover_search') + ); + expect(entitiesTree).toBeInTheDocument(); + }); + }); + + it('should apply active state markers correctly', async () => { + // Since our mock route is /search, isEntitiesActive should be true if type param exists, etc. + // But we can just test if the style is applied correctly to the container box based on the current state. + // We will look at the border color for the entities box which is active if type param is present. + // For this test, let's verify the tooltips exist and the buttons are rendered. + const entitiesIcon = screen.getByAltText('entities'); + expect(entitiesIcon).toBeInTheDocument(); + + const classificationsIcon = screen.getByAltText('classifications'); + expect(classificationsIcon).toBeInTheDocument(); + }); + + it('should close popover when clicking outside', async () => { + // Open glossary popover + const glossaryIcon = screen.getByAltText('glossary'); + fireEvent.click(glossaryIcon.closest('button')!); + + await waitFor(() => { + expect(screen.getAllByTestId('glossary-tree').length).toBeGreaterThan(0); + }); + + // Press escape to close the popover (MUI Popover default behavior for outside click/escape) + const backdrop = document.querySelector('.MuiBackdrop-root'); + if (backdrop) { + fireEvent.click(backdrop); + } else { + fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' }); + } + + await waitFor(() => { + expect(screen.queryByTestId('glossary-tree')).not.toBeInTheDocument(); + }); + }); + }); });