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" }}
+ >
+
+
+ )}
+
+
+ }
+ />
+
+
+ );
+
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 (