pawarprasad123 commented on code in PR #688:
URL: https://github.com/apache/atlas/pull/688#discussion_r3718848293
##########
dashboard/src/views/SideBar/SideBarTree/SideBarTree.tsx:
##########
@@ -286,1008 +315,1051 @@ 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 | HTMLElement>(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<boolean>(false);
- const toastId: any = useRef(null);
- const open = Boolean(expand);
- const [expandedItems, setExpandedItems] = useState<string[]>([]);
- const [tagModal, setTagModal] = useState<boolean>(false);
- const [glossaryModal, setGlossaryModal] = useState<boolean>(false);
- const { businessMetaData }: any = useAppSelector(
- (state: any) => state.businessMetaData
- );
-
- const filteredData = useMemo(() => {
- return treeData.filter((node) => {
- return (
- node.label?.toLowerCase().includes(searchTerm.toLowerCase()) ||
- (node.children &&
- node.children.some((child) =>
- child.label?.toLowerCase().includes(searchTerm.toLowerCase())
- ))
- );
+ const { savedSearchData } = useAppSelector(
+ (state) => state.savedSearch
+ );
+ const { bmguid } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const searchParams = new URLSearchParams(location.search);
+ const [expand, setExpand] = useState<null | HTMLElement>(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<boolean>(false);
+ const toastId: any = useRef(null);
+ const open = Boolean(expand);
+ const [expandedItems, setExpandedItems] = useState<string[]>([]);
+ const [tagModal, setTagModal] = useState<boolean>(false);
+ const [glossaryModal, setGlossaryModal] = useState<boolean>(false);
+ const { businessMetaData } = useAppSelector(
+ (state) => state.businessMetaData as unknown as { businessMetaData?: {
businessMetadataDefs?: EnumTypeDefData[] } }
+ );
- const highlightText = useMemo(() => {
- return (text: string) => {
- if (!searchTerm) return text;
+ const filteredData = useMemo(() => {
+ 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"));
Review Comment:
Escape regex metacharacters in searchTerm before building RegExp. Special
characters like (, [, * can throw or behave unexpectedly (SideBarBody tests
already cover special chars in search input).
##########
dashboard/src/components/EntityDisplayImage.tsx:
##########
@@ -26,75 +25,40 @@ const DisplayImage = ({
avatarDisplay,
isProcess
}: any) => {
Review Comment:
Define a typed props interface instead of any for safer builds and clearer
API.
##########
dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx:
##########
@@ -27,6 +27,7 @@
*/
import React from 'react'
+import '@testing-library/jest-dom'
Review Comment:
SideBarTree.tsx line 1327 uses count={isPopover ? 2 : 7} but no test passes
isPopover={true}.
Add a test with isPopover={true} asserting the skeleton renders 2 rows
instead of 7.
Add test: select custom filter in popover → close popover → reopen → verify
URL params and selected state persist.
##########
dashboard/src/views/SideBar/SideBarBody.tsx:
##########
@@ -308,189 +487,121 @@ const SideBarBody = (props: {
data-cy="atlas-logo"
/>
</span>
- <Paper
- sx={{
- width: "100%",
- }}
- className="sidebar-searchbar"
- >
- <InputBase
- fullWidth
- sx={{ color: "rgba(0, 0, 0, 0.7)" }}
- placeholder="Entities, Classifications, Glossaries"
- inputProps={{ "aria-label": "search" }}
- value={searchTerm}
- onChange={(e: ChangeEvent<HTMLInputElement>) => {
- setSearchTerm(e.target.value);
- }}
- data-cy="searchNode"
- />
-
- <IconButton type="submit" size="small" aria-label="search">
- <SearchIcon fontSize="inherit" />
- </IconButton>
- </Paper>
+ <SidebarSearchInput
+ searchTerm={searchTerm}
+ onChange={setSearchTerm}
+ dataCy="searchNode"
+ />
</Stack>
</DrawerHeader>
)}
<Paper
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 && {
Review Comment:
Use strict equality (open === false or !open).
##########
dashboard/src/views/SideBar/SideBarTree/__tests__/SideBarTree.test.tsx:
##########
Review Comment:
Tests only assert tree renders; they don't verify tooltip on overflow vs.
no tooltip when text fits. Mock scrollWidth / clientWidth for real branch
coverage.
##########
dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx:
##########
Review Comment:
Add assertion that fetchVersionData is dispatched on mount (version shown in
collapsed footer).
##########
dashboard/src/views/SideBar/SideBarBody.tsx:
##########
@@ -101,26 +101,80 @@ const DrawerHeader = styled("div")(({ theme }) => ({
marginBottom: "1rem",
}));
+
const SideBarBody = (props: {
- loading: boolean;
- handleOpenModal: any;
- handleOpenAboutModal: any;
+ handleOpenModal: () => void;
+ handleOpenAboutModal: () => void;
}) => {
const location = useLocation();
const routes = useRoutes(AppRoutes as RouteObject[]);
const history = useHistory();
const dispatch = useAppDispatch();
- const { loading: loader, handleOpenModal, handleOpenAboutModal } = props;
+ const { handleOpenModal, handleOpenAboutModal } = props;
const navigate = useNavigate();
- const { loading } = useSelector((state: TypeHeaderState) =>
state.typeHeader);
const { relationshipSearch = {} } = globalSessionData || {};
const [open, setOpen] = useState(true);
const [searchTerm, setSearchTerm] = useState<string>("");
+ const { data: versionData } = useAppSelector((state) =>
state.session?.versionData || {});
+ const searchParams = new URLSearchParams(location.search);
+
+ const isCustomFilterActive = searchParams.get("isCF") === "true";
+ const isGlossaryActive = !isCustomFilterActive &&
(location.pathname.includes("/glossary") || !!searchParams.get("gtype") ||
!!searchParams.get("term") || !!searchParams.get("category"));
+ const isBusinessMetadataActive = !isCustomFilterActive &&
location.pathname.includes("/administrator/businessMetadata");
+ const isClassificationActive = !isCustomFilterActive &&
(!!searchParams.get("tag") || location.pathname.includes("/tag/tagAttribute"));
+ const isRelationshipActive = !isCustomFilterActive &&
(!!searchParams.get("relationshipName") ||
location.pathname.includes("/relationshipDetailPage"));
+
+ const isEntitiesActive = !isCustomFilterActive &&
(!!searchParams.get("type") || location.pathname.includes("/detailPage"));
+
+ const modules = [
+ { id: "entities", title: "Entities", isActive: isEntitiesActive, iconUrl:
"/img/sidebar-icons/icon-entities.svg", Component: EntitiesTree, isVisible:
true },
+ { id: "classification", title: "Classifications", isActive:
isClassificationActive, iconUrl: "/img/sidebar-icons/icon-classifications.svg",
Component: ClassificationTree, isVisible: true },
+ { id: "glossary", title: "Glossary", isActive: isGlossaryActive, iconUrl:
"/img/sidebar-icons/icon-glossary.svg", Component: GlossaryTree, isVisible:
true },
+ { id: "businessMetadata", title: "Business Metadata", isActive:
isBusinessMetadataActive, iconUrl:
"/img/sidebar-icons/icon-business-metadata.svg", Component:
BusinessMetadataTree, isVisible: true },
+ { id: "relationships", title: "Relationships", isActive:
isRelationshipActive, iconUrl: "/img/sidebar-icons/icon-relationships.svg",
Component: RelationshipsTree, isVisible: !!relationshipSearch },
+ { id: "customFilters", title: "Custom Filters", isActive:
isCustomFilterActive, iconUrl: "/img/sidebar-icons/icon-custom-filters.svg",
Component: CustomFiltersTree, isVisible: true }
+ ];
const handleDrawerOpen = () => {
setOpen(!open);
};
+ const [popoverAnchor, setPopoverAnchor] = useState<HTMLButtonElement |
null>(null);
+ const [activePopover, setActivePopover] = useState<string | null>(null);
+ const [popoverMaxHeight, setPopoverMaxHeight] = useState<string>('calc(100vh
- 100px)');
+ const [isBottomHalf, setIsBottomHalf] = useState<boolean>(false);
+
+ const handlePopoverOpen = (event: React.MouseEvent<HTMLButtonElement>, id:
string) => {
Review Comment:
When clicking a different module icon while a popover is open, consider
calling handlePopoverClose() first or ensuring the previous tree unmounts
cleanly to avoid stale state.
##########
dashboard/src/views/SideBar/SideBarBody.tsx:
##########
@@ -101,26 +101,80 @@ const DrawerHeader = styled("div")(({ theme }) => ({
marginBottom: "1rem",
}));
+
const SideBarBody = (props: {
- loading: boolean;
- handleOpenModal: any;
- handleOpenAboutModal: any;
+ handleOpenModal: () => void;
+ handleOpenAboutModal: () => void;
}) => {
const location = useLocation();
const routes = useRoutes(AppRoutes as RouteObject[]);
const history = useHistory();
const dispatch = useAppDispatch();
- const { loading: loader, handleOpenModal, handleOpenAboutModal } = props;
+ const { handleOpenModal, handleOpenAboutModal } = props;
const navigate = useNavigate();
- const { loading } = useSelector((state: TypeHeaderState) =>
state.typeHeader);
const { relationshipSearch = {} } = globalSessionData || {};
const [open, setOpen] = useState(true);
const [searchTerm, setSearchTerm] = useState<string>("");
+ const { data: versionData } = useAppSelector((state) =>
state.session?.versionData || {});
+ const searchParams = new URLSearchParams(location.search);
+
+ const isCustomFilterActive = searchParams.get("isCF") === "true";
+ const isGlossaryActive = !isCustomFilterActive &&
(location.pathname.includes("/glossary") || !!searchParams.get("gtype") ||
!!searchParams.get("term") || !!searchParams.get("category"));
+ const isBusinessMetadataActive = !isCustomFilterActive &&
location.pathname.includes("/administrator/businessMetadata");
+ const isClassificationActive = !isCustomFilterActive &&
(!!searchParams.get("tag") || location.pathname.includes("/tag/tagAttribute"));
+ const isRelationshipActive = !isCustomFilterActive &&
(!!searchParams.get("relationshipName") ||
location.pathname.includes("/relationshipDetailPage"));
+
+ const isEntitiesActive = !isCustomFilterActive &&
(!!searchParams.get("type") || location.pathname.includes("/detailPage"));
+
+ const modules = [
+ { id: "entities", title: "Entities", isActive: isEntitiesActive, iconUrl:
"/img/sidebar-icons/icon-entities.svg", Component: EntitiesTree, isVisible:
true },
+ { id: "classification", title: "Classifications", isActive:
isClassificationActive, iconUrl: "/img/sidebar-icons/icon-classifications.svg",
Component: ClassificationTree, isVisible: true },
+ { id: "glossary", title: "Glossary", isActive: isGlossaryActive, iconUrl:
"/img/sidebar-icons/icon-glossary.svg", Component: GlossaryTree, isVisible:
true },
+ { id: "businessMetadata", title: "Business Metadata", isActive:
isBusinessMetadataActive, iconUrl:
"/img/sidebar-icons/icon-business-metadata.svg", Component:
BusinessMetadataTree, isVisible: true },
+ { id: "relationships", title: "Relationships", isActive:
isRelationshipActive, iconUrl: "/img/sidebar-icons/icon-relationships.svg",
Component: RelationshipsTree, isVisible: !!relationshipSearch },
+ { id: "customFilters", title: "Custom Filters", isActive:
isCustomFilterActive, iconUrl: "/img/sidebar-icons/icon-custom-filters.svg",
Component: CustomFiltersTree, isVisible: true }
+ ];
const handleDrawerOpen = () => {
setOpen(!open);
};
+ const [popoverAnchor, setPopoverAnchor] = useState<HTMLButtonElement |
null>(null);
+ const [activePopover, setActivePopover] = useState<string | null>(null);
+ const [popoverMaxHeight, setPopoverMaxHeight] = useState<string>('calc(100vh
- 100px)');
+ const [isBottomHalf, setIsBottomHalf] = useState<boolean>(false);
+
+ const handlePopoverOpen = (event: React.MouseEvent<HTMLButtonElement>, id:
string) => {
+ setPopoverAnchor(event.currentTarget);
+ setActivePopover(id);
+
+ // Calculate remaining screen height from the anchor to the bottom
+ const rect = event.currentTarget.getBoundingClientRect();
+ const spaceBelow = window.innerHeight - rect.top - 24;
+ const isBottom = spaceBelow < 350;
+ setIsBottomHalf(isBottom);
+
+ if (isBottom) {
+ const spaceAbove = rect.bottom - 24;
+ setPopoverMaxHeight(`${Math.max(250, spaceAbove)}px`);
+ } else {
+ setPopoverMaxHeight(`${Math.max(250, spaceBelow)}px`);
+ }
+ };
+
+ const handlePopoverClose = () => {
+ setPopoverAnchor(null);
+ setActivePopover(null);
+ };
+
+
+
+ const renderPopoverSearch = () => (
+ <div className="sidebar-popover-search">
+ <SidebarSearchInput searchTerm={searchTerm} onChange={setSearchTerm} />
+ </div>
+ );
+
const [position, setPosition] = useState<string |
number>(defaultDrawerWidth);
const draggerRef = useRef<HTMLDivElement>(null);
Review Comment:
draggerRef and mouse listeners exist (lines 179–205, 237–245) but no DOM
element uses ref={draggerRef}. Sidebar resize appears non-functional
(pre-existing on master, but still dead code in this file).
Either restore the dragger element (sidebar-dragger exists in SCSS) or
remove unused resize logic.
##########
dashboard/src/styles/sidebar.scss:
##########
@@ -196,6 +212,7 @@
border-bottom: "1px solid rgba(25,255,255,0.1)";
Review Comment:
.tree-item-parent-label has invalid quoted CSS value — remove quotes from
border-bottom.
check and confirm
##########
dashboard/src/views/SideBar/__tests__/SideBarBody.test.tsx:
##########
@@ -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.
Review Comment:
This only checks icons exist, not .sidebar-icon-active. Add route mocks
(e.g. ?type=..., ?isCF=true) and assert the active class/border.
also
Add test: select custom filter in popover → close popover → reopen → verify
URL params and selected state persist.
##########
dashboard/src/components/SidebarSearchInput.tsx:
##########
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ChangeEvent } from "react";
+import { Paper, InputBase, Stack } from "@mui/material";
+import ClearIcon from "@mui/icons-material/Clear";
+import { IconButton } from "@components/muiComponents";
+
+interface SidebarSearchInputProps {
+ searchTerm: string;
+ onChange: (value: string) => void;
+ dataCy?: string;
+}
+
+export const SidebarSearchInput: React.FC<SidebarSearchInputProps> = ({
+ searchTerm,
+ onChange,
+ dataCy
+}) => (
+ <Paper
+ sx={{
+ width: "100%",
+ paddingLeft: "8px",
+ display: "flex",
+ alignItems: "center"
+ }}
+ className="sidebar-searchbar"
+ >
+ <InputBase
+ fullWidth
+ sx={{ color: "rgba(0, 0, 0, 0.7)" }}
+ placeholder="Search"
+ inputProps={{ "aria-label": "search" }}
+ value={searchTerm}
+ onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
+ data-cy={dataCy}
+ endAdornment={
+ <Stack direction="row" alignItems="center" gap="4px">
+ {searchTerm.length > 0 && (
+ <IconButton
+ size="small"
+ onClick={() => onChange("")}
+ edge="end"
+ sx={{ padding: "4px" }}
+ >
+ <ClearIcon fontSize="small" sx={{ color: "rgba(0, 0, 0, 0.4)" }}
/>
+ </IconButton>
+ )}
+ <img
+ src="/img/sidebar-icons/icon-search.svg"
+ style={{
+ width: "16px",
+ height: "16px",
+ filter: "brightness(0.4)",
+ opacity: 1,
+ cursor: "pointer",
+ marginLeft: "4px"
Review Comment:
Remove cursor: "pointer" or add an onClick handler — currently the icon is
decorative only.
can you check and verify this
##########
dashboard/src/views/Layout/About.tsx:
##########
@@ -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<any>({});
- 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);
Review Comment:
after last test:
Add test for Redux error state: { data: null, loading: false, error:
'Network error' } — verify graceful UI (no skeleton stuck, no crash).
##########
dashboard/src/views/SideBar/SideBarBody.tsx:
##########
@@ -246,38 +346,117 @@ const SideBarBody = (props: {
backgroundColor: "#034858",
}}
>
- {/* Collapsed sidebar logo */}
+ {/* Collapsed sidebar logo and module icons */}
{!open && (
- <div
- style={{
- width: "100%",
- textAlign: "center",
- paddingLeft: "12px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- minHeight: "64px",
- cursor: "pointer",
- boxSizing: "border-box",
- }}
- role="button"
- tabIndex={0}
- aria-label="Atlas home — refresh dashboard"
- onClick={handleAtlasLogoClick}
- onKeyDown={handleAtlasLogoKeyDown}
- data-cy="apache-atlas-logo-collapsed"
+ <Stack
+ alignItems="center"
+ sx={{ width: "100%", flex: 1, minHeight: 0, overflowY: "auto",
overflowX: "hidden", boxSizing: "border-box", pb: "60px" }}
>
- <img
- src={apacheAtlasLogo}
- alt="Apache Atlas logo"
- style={{
- width: "29px",
- height: "auto",
- maxWidth: "100%",
- display: "block",
+ <div
+ className="collapsed-logo-container"
+ role="button"
+ tabIndex={0}
+ aria-label="Atlas home — refresh dashboard"
+ onClick={handleAtlasLogoClick}
+ onKeyDown={handleAtlasLogoKeyDown}
+ data-cy="apache-atlas-logo-collapsed"
+ >
+ <img
+ src={apacheAtlasLogo}
+ alt="Apache Atlas logo"
+ className="collapsed-logo-img"
+ />
+ </div>
+
+ {/* Module Icons for Mini Drawer */}
+ <Stack alignItems="stretch" gap="1rem" sx={{ width: "100%" }}>
+ {/* Search */}
+ <Box sx={{ display: "flex", justifyContent: "center",
borderLeft: "4px solid transparent", borderRight: "4px solid transparent",
background: "transparent" }}>
+ <Tooltip title="Search" placement="right">
+ <IconButton onClick={() => setOpen(true)} sx={{ '&:hover':
{ background: 'rgba(255, 255, 255, 0.1)' } }}>
Review Comment:
The Search icon in collapsed mode expands the sidebar (setOpen(true)), which
is good — consider adding aria-expanded for accessibility.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]