From 82dbdc5caee26e1c7e657b78d238e4befdcd5ad0 Mon Sep 17 00:00:00 2001 From: tejhan-diallo Date: Thu, 12 Mar 2026 04:42:56 -0400 Subject: [PATCH] headlamp: upstreamable: frontend: add placeholder buttons to prevent layout shift during project loading --- .../project/ProjectDeleteButton.tsx | 62 ++++++++++++++----- .../src/components/project/ProjectDetails.tsx | 28 +++++++-- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/project/ProjectDeleteButton.tsx b/frontend/src/components/project/ProjectDeleteButton.tsx index 36c29e297..95357ffbb 100644 --- a/frontend/src/components/project/ProjectDeleteButton.tsx +++ b/frontend/src/components/project/ProjectDeleteButton.tsx @@ -30,7 +30,20 @@ interface ProjectDeleteButtonProps { export function ProjectDeleteButton({ project, buttonStyle }: ProjectDeleteButtonProps) { const { t } = useTranslation(); const [openDialog, setOpenDialog] = useState(false); - const [namespaces] = Namespace.useList({ clusters: project.clusters }); + const [authResolved, setAuthResolved] = useState(false); + const [namespaces, error] = Namespace.useList({ clusters: project.clusters }); + + // While namespaces are loading, show a placeholder to avoid layout shift mid-load + if (!namespaces && !error) { + return ( + {}} + icon="mdi:delete" + /> + ); + } const projectNamespaces = namespaces?.filter(ns => project.namespaces.includes(ns.metadata.name)) ?? []; @@ -41,19 +54,38 @@ export function ProjectDeleteButton({ project, buttonStyle }: ProjectDeleteButto } return ( - - setOpenDialog(true)} - icon="mdi:delete" - /> - setOpenDialog(false)} - namespaces={projectNamespaces} - /> - + <> + {!authResolved && ( + {}} + icon="mdi:delete" + /> + )} + setAuthResolved(true)} + onError={() => setAuthResolved(true)} + > + {authResolved && ( + <> + setOpenDialog(true)} + icon="mdi:delete" + /> + setOpenDialog(false)} + namespaces={projectNamespaces} + /> + + )} + + ); } diff --git a/frontend/src/components/project/ProjectDetails.tsx b/frontend/src/components/project/ProjectDetails.tsx index 7a3901bce..1e1cf8603 100644 --- a/frontend/src/components/project/ProjectDetails.tsx +++ b/frontend/src/components/project/ProjectDetails.tsx @@ -34,6 +34,7 @@ import { } from '../../redux/projectsSlice'; import { Activity } from '../activity/Activity'; import { ButtonStyle, EditButton, EditorDialog, Loader, StatusLabel } from '../common'; +import ActionButton from '../common/ActionButton'; import Link from '../common/Link'; import ResourceTable from '../common/Resource/ResourceTable'; import SectionBox from '../common/SectionBox'; @@ -415,27 +416,34 @@ function ProjectDetailsContent({ project }: { project: ProjectDefinition }) { const registeredHeaderActions = useTypedSelector(state => state.projects.headerActions); const [DeleteButton, setDeleteButton] = useState< - (p: { project: ProjectDefinition; buttonStyle?: ButtonStyle }) => ReactNode - >(() => ProjectDeleteButton); + ((p: { project: ProjectDefinition; buttonStyle?: ButtonStyle }) => ReactNode) | null + >(() => (customDeleteButton ? null : ProjectDeleteButton)); const [headerActions, setHeaderActions] = useState([]); // Load custom delete button useEffect(() => { - if (!customDeleteButton) return; + if (!customDeleteButton) { + setDeleteButton(() => ProjectDeleteButton); + return; + } let isCurrent = true; if (customDeleteButton.isEnabled) { + setDeleteButton(null); customDeleteButton .isEnabled({ project }) .then(isEnabled => { - if (isEnabled && isCurrent) { - setDeleteButton(() => customDeleteButton.component); + if (isCurrent) { + setDeleteButton(() => (isEnabled ? customDeleteButton.component : ProjectDeleteButton)); } }) .catch(e => { console.log(`Failed to check if custom delete button is ready`, e); + if (isCurrent) { + setDeleteButton(() => ProjectDeleteButton); + } }); } else { setDeleteButton(() => customDeleteButton.component); @@ -578,7 +586,15 @@ function ProjectDetailsContent({ project }: { project: ProjectDefinition }) { {headerActions} - + {DeleteButton ? ( + + ) : ( + {}} + icon="mdi:delete" + /> + )} } >