This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch feature/pr-1-redux-refresh-modals-header-sidebar in repository https://gitbox.apache.org/repos/asf/atlas.git
commit c4c4af64e00dc95f890c768b6dd0c88e0f013c68 Author: Prasad Pawar <[email protected]> AuthorDate: Wed May 6 14:09:04 2026 +0530 ATLAS-5285: ATLAS UI: Dashboard - Common changes that are related to refresh the page. Atlas UI dashboard changes - Base for refresh UX --- dashboard/src/components/DialogShowMoreLess.tsx | 1 + dashboard/src/components/Modal.tsx | 110 +++++++++++++++------ dashboard/src/hooks/useAsyncPending.ts | 43 ++++++++ dashboard/src/redux/reducers/reducers.ts | 2 + dashboard/src/redux/slice/dashboardRefreshSlice.ts | 39 ++++++++ dashboard/src/utils/refreshDashboardHome.ts | 30 ++++++ dashboard/src/views/Layout/Header.tsx | 23 +++-- dashboard/src/views/SideBar/SideBarBody.tsx | 71 ++++++++----- 8 files changed, 257 insertions(+), 62 deletions(-) diff --git a/dashboard/src/components/DialogShowMoreLess.tsx b/dashboard/src/components/DialogShowMoreLess.tsx index d782222e8..03492d077 100644 --- a/dashboard/src/components/DialogShowMoreLess.tsx +++ b/dashboard/src/components/DialogShowMoreLess.tsx @@ -576,6 +576,7 @@ const DialogShowMoreLess = ({ button2Label="Remove" button2Handler={handleRemove} disableButton2={removeLoader} + button2Loading={removeLoader} isDirty={true} > {relatedTerm ? ( diff --git a/dashboard/src/components/Modal.tsx b/dashboard/src/components/Modal.tsx index 75682d277..373e5fee2 100644 --- a/dashboard/src/components/Modal.tsx +++ b/dashboard/src/components/Modal.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { styled } from "@mui/material/styles"; +import { styled, type Theme } from "@mui/material/styles"; import CircularProgress from "@mui/material/CircularProgress"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; @@ -54,9 +54,13 @@ interface CustomModalProps { button2Label?: string | undefined; button2Handler: any; disableButton2?: string | object | boolean; + /** Shows spinner on primary action and blocks both footer buttons while an async action runs */ + button2Loading?: boolean; maxWidth?: any; footer?: boolean; isDirty?: boolean; + /** When true, only the first footer button is rendered (e.g. import error state). */ + hideButton2?: boolean; } export const CustomModal: React.FC<CustomModalProps> = ({ @@ -71,10 +75,18 @@ export const CustomModal: React.FC<CustomModalProps> = ({ button2Label, button2Handler, disableButton2, + button2Loading, maxWidth, isDirty, - footer + footer, + hideButton2 }) => { + const isLoading = Boolean(button2Loading); + const primaryDisabled = + isLoading || + Boolean(disableButton2) || + (isDirty !== undefined && !isDirty); + const handleClick = (event: { stopPropagation: () => void }) => { event.stopPropagation(); }; @@ -84,6 +96,7 @@ export const CustomModal: React.FC<CustomModalProps> = ({ <BootstrapDialog maxWidth={maxWidth || "sm"} aria-labelledby="customized-dialog-title" + aria-busy={isLoading} open={open} sx={{ "& .MuiDialog-paper": { @@ -124,8 +137,12 @@ export const CustomModal: React.FC<CustomModalProps> = ({ }} onClick={(e) => { e.stopPropagation(); + if (isLoading) { + return; + } onClose(); }} + disabled={isLoading} > <CloseIcon sx={{ width: "0.75em", height: "0.75em" }} /> </IconButton> @@ -145,8 +162,12 @@ export const CustomModal: React.FC<CustomModalProps> = ({ <CustomButton variant="outlined" color="primary" + disabled={isLoading} onClick={(e: Event) => { e.stopPropagation(); + if (isLoading) { + return; + } button1Handler(); }} > @@ -154,37 +175,62 @@ export const CustomModal: React.FC<CustomModalProps> = ({ </CustomButton> )} - <CustomButton - variant="contained" - color="primary" - aria-label="close" - primary={true} - disabled={ - disableButton2 || (isDirty != undefined ? !isDirty : false) - } - sx={{ - ...(disableButton2 && { - "&.Mui-disabled": { - pointerEvents: "unset", - cursor: "not-allowed" + {!hideButton2 && ( + <CustomButton + variant="contained" + color="primary" + aria-label={ + isLoading + ? "Action in progress, please wait" + : "Confirm dialog action" + } + primary={true} + disabled={primaryDisabled} + sx={{ + minWidth: isLoading ? 88 : undefined, + ...(isLoading && { + "&.Mui-disabled": { + opacity: 1, + pointerEvents: "none", + cursor: "progress", + backgroundColor: (theme: Theme) => + theme.palette.primary.main, + color: (theme: Theme) => + theme.palette.primary.contrastText + } + }), + ...(primaryDisabled && + !isLoading && { + "&.Mui-disabled": { + pointerEvents: "unset", + cursor: "not-allowed" + } + }) + }} + startIcon={ + isLoading ? ( + <CircularProgress + aria-hidden + size={18} + thickness={4} + sx={{ + color: (theme: Theme) => + theme.palette.primary.contrastText + }} + /> + ) : undefined + } + onClick={(e: Event) => { + e.stopPropagation(); + if (isLoading) { + return; } - }) - }} - startIcon={ - disableButton2 && isDirty ? ( - <CircularProgress - sx={{ color: "white", fontWeight: "600" }} - size="14px" - /> - ) : undefined - } - onClick={(e: Event) => { - e.stopPropagation(); - button2Handler(); - }} - > - {button2Label} - </CustomButton> + button2Handler(); + }} + > + {button2Label} + </CustomButton> + )} </DialogActions> </Stack> )} diff --git a/dashboard/src/hooks/useAsyncPending.ts b/dashboard/src/hooks/useAsyncPending.ts new file mode 100644 index 000000000..b058f4392 --- /dev/null +++ b/dashboard/src/hooks/useAsyncPending.ts @@ -0,0 +1,43 @@ +/* + * 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 { useCallback, useRef, useState } from "react"; + +/** + * Single-flight async guard + pending flag for primary actions (modals, forms). + * Reuse wherever a button triggers an API call and should show loading state. + */ +export const useAsyncPending = () => { + const [pending, setPending] = useState(false); + const busyRef = useRef(false); + + const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => { + if (busyRef.current) { + return undefined; + } + busyRef.current = true; + setPending(true); + try { + return await fn(); + } finally { + busyRef.current = false; + setPending(false); + } + }, []); + + return { pending, run }; +}; diff --git a/dashboard/src/redux/reducers/reducers.ts b/dashboard/src/redux/reducers/reducers.ts index 205006341..425fd9f80 100644 --- a/dashboard/src/redux/reducers/reducers.ts +++ b/dashboard/src/redux/reducers/reducers.ts @@ -32,6 +32,7 @@ import { relationshipsReducer } from "@redux/slice/typeDefSlices/typedefRelation import { metricsReducer } from "@redux/slice/metricsSlice"; import { savedSearchReducer } from "@redux/slice/savedSearchSlice"; import { drawerSliceReducer } from "@redux/slice/drawerSlice"; +import { dashboardRefreshReducer } from "@redux/slice/dashboardRefreshSlice"; const rootReducer = combineReducers({ session: sessionReducer, @@ -39,6 +40,7 @@ const rootReducer = combineReducers({ typeHeader: typeHeaderReducer, allEntityTypes: allEntityTypesReducer, metrics: metricsReducer, + dashboardRefresh: dashboardRefreshReducer, classification: classificationReducer, businessMetaData: businessMetadataReducer, glossary: glossaryReducer, diff --git a/dashboard/src/redux/slice/dashboardRefreshSlice.ts b/dashboard/src/redux/slice/dashboardRefreshSlice.ts new file mode 100644 index 000000000..cb21a5c9d --- /dev/null +++ b/dashboard/src/redux/slice/dashboardRefreshSlice.ts @@ -0,0 +1,39 @@ +/* + * 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 { createSlice } from "@reduxjs/toolkit"; + +interface DashboardRefreshState { + version: number; +} + +const initialState: DashboardRefreshState = { + version: 0, +}; + +const dashboardRefreshSlice = createSlice({ + name: "dashboardRefresh", + initialState, + reducers: { + requestDashboardRefresh: (state) => { + state.version += 1; + }, + }, +}); + +export const { requestDashboardRefresh } = dashboardRefreshSlice.actions; +export const dashboardRefreshReducer = dashboardRefreshSlice.reducer; diff --git a/dashboard/src/utils/refreshDashboardHome.ts b/dashboard/src/utils/refreshDashboardHome.ts new file mode 100644 index 000000000..1a2a6571c --- /dev/null +++ b/dashboard/src/utils/refreshDashboardHome.ts @@ -0,0 +1,30 @@ +/* + * 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 type { AppDispatch } from "@redux/store/store"; +import { requestDashboardRefresh } from "@redux/slice/dashboardRefreshSlice"; +import { fetchMetricEntity } from "@redux/slice/metricsSlice"; + +/** + * Soft-refresh dashboard home: bumps subscribers (latest entities, activity, + * QuickSearch) and refetches Redux metrics — same net calls as a full reload + * for those areas, without remounting the whole app. + */ +export const refreshDashboardHomeData = (dispatch: AppDispatch): void => { + dispatch(requestDashboardRefresh()); + dispatch(fetchMetricEntity()); +}; diff --git a/dashboard/src/views/Layout/Header.tsx b/dashboard/src/views/Layout/Header.tsx index 9e00e7eea..80fc2dc06 100644 --- a/dashboard/src/views/Layout/Header.tsx +++ b/dashboard/src/views/Layout/Header.tsx @@ -29,7 +29,13 @@ import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined"; import AccountCircleRoundedIcon from "@mui/icons-material/AccountCircleRounded"; import { useLocation, useNavigate } from "react-router-dom"; import QuickSearch from "@components/GlobalSearch/QuickSearch"; -import { Logout } from "@mui/icons-material"; +import CreateDropdown from "@components/CreateDropdown"; +import { + AdminPanelSettingsOutlined, + HelpOutlineOutlined, + Logout, + SwapHorizOutlined +} from "@mui/icons-material"; import { useAppSelector } from "@hooks/reducerHook"; import { Button, @@ -174,15 +180,17 @@ const Header: React.FC<Header> = ({ </Button> </LightTooltip> )} - {location.pathname != "/" && - location.pathname != "/search" && - location.pathname != "/!" && - (!location.pathname.includes("!") ) && ( + {location.pathname !== "/" && + location.pathname !== "/search" && ( <div style={{ display: "flex", justifyContent: "center", flex: "1" }}> <QuickSearch /> </div> )} - <div className="header-menu"> + {(location.pathname === "/" || location.pathname === "/search") && ( + <div style={{ flex: "1" }} /> + )} + <div className="header-menu" style={{ display: "flex", alignItems: "center", gap: 8 }}> + <CreateDropdown /> <LightTooltip title="Downloads"> <IconButton size="small" @@ -260,9 +268,11 @@ const Header: React.FC<Header> = ({ }} data-cy="administrator" > + <AdminPanelSettingsOutlined sx={{ marginRight: "4px" }} fontSize="inherit" /> Administration </MenuItem> <MenuItem dense onClick={handleNestedMenuClick} data-cy="help"> + <HelpOutlineOutlined sx={{ marginRight: "4px" }} fontSize="inherit" /> Help </MenuItem> <Divider /> @@ -275,6 +285,7 @@ const Header: React.FC<Header> = ({ handleClose(); }} > + <SwapHorizOutlined sx={{ marginRight: "4px" }} fontSize="inherit" /> Switch to Classic </MenuItem> <MenuItem diff --git a/dashboard/src/views/SideBar/SideBarBody.tsx b/dashboard/src/views/SideBar/SideBarBody.tsx index 65d8861e5..91b5bda09 100644 --- a/dashboard/src/views/SideBar/SideBarBody.tsx +++ b/dashboard/src/views/SideBar/SideBarBody.tsx @@ -18,9 +18,11 @@ import { styled } from "@mui/material/styles"; import { Suspense, + useCallback, useEffect, useState, ChangeEvent, + KeyboardEvent, lazy, useRef, } from "react"; @@ -50,6 +52,7 @@ 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 { refreshDashboardHomeData } from "@utils/refreshDashboardHome"; import ErrorPage from "@views/ErrorPage"; import AppRoutes from "@views/AppRoutes"; import ErrorBoundaryWithNavigate from "../../ErrorBoundary"; @@ -153,7 +156,28 @@ const SideBarBody = (props: { dispatch(fetchRootClassification()); dispatch(fetchEnumData()); dispatch(fetchMetricEntity()); - }, []); + }, [dispatch]); + + const handleAtlasLogoClick = useCallback(() => { + refreshDashboardHomeData(dispatch); + navigate( + { + pathname: "/search", + }, + { replace: true } + ); + }, [dispatch, navigate]); + + const handleAtlasLogoKeyDown = useCallback( + (e: KeyboardEvent<HTMLElement>) => { + if (e.key !== "Enter" && e.key !== " ") { + return; + } + e.preventDefault(); + handleAtlasLogoClick(); + }, + [handleAtlasLogoClick] + ); useEffect(() => { const draggerElement = draggerRef.current; @@ -236,14 +260,11 @@ const SideBarBody = (props: { cursor: "pointer", boxSizing: "border-box", }} - onClick={() => { - navigate( - { - pathname: "/search", - }, - { replace: true } - ); - }} + role="button" + tabIndex={0} + aria-label="Atlas home — refresh dashboard" + onClick={handleAtlasLogoClick} + onKeyDown={handleAtlasLogoKeyDown} data-cy="apache-atlas-logo-collapsed" > <img @@ -271,20 +292,22 @@ const SideBarBody = (props: { }} > <Stack gap="1.5rem" width="100%" marginTop="1rem"> - <img - src={atlasLogo} - alt="Atlas logo" - onClick={() => { - navigate( - { - pathname: "/search", - }, - { replace: true } - ); - }} - className="header-logo cursor-pointer" - data-cy="atlas-logo" - /> + <span + role="button" + tabIndex={0} + aria-label="Atlas home — refresh dashboard" + onClick={handleAtlasLogoClick} + onKeyDown={handleAtlasLogoKeyDown} + className="inline-block cursor-pointer" + > + <img + src={atlasLogo} + alt="" + aria-hidden + className="header-logo" + data-cy="atlas-logo" + /> + </span> <Paper sx={{ width: "100%", @@ -493,7 +516,7 @@ const SideBarBody = (props: { }), margin: "0", overflowX: "auto", - background: "#f5f5f5", + background: "#f5f7f9", padding: "0", }} >
