Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 47 additions & 15 deletions frontend/src/components/project/ProjectDeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ActionButton
description={t('Delete project')}
buttonStyle={buttonStyle}
onClick={() => {}}
icon="mdi:delete"
/>
);
}

const projectNamespaces =
namespaces?.filter(ns => project.namespaces.includes(ns.metadata.name)) ?? [];
Expand All @@ -41,19 +54,38 @@ export function ProjectDeleteButton({ project, buttonStyle }: ProjectDeleteButto
}

return (
<AuthVisible item={projectNamespaces[0]} authVerb="update">
<ActionButton
description={t('Delete project')}
buttonStyle={buttonStyle}
onClick={() => setOpenDialog(true)}
icon="mdi:delete"
/>
<ProjectDeleteDialog
open={openDialog}
project={project}
onClose={() => setOpenDialog(false)}
namespaces={projectNamespaces}
/>
</AuthVisible>
<>
{!authResolved && (
<ActionButton
description={t('Delete project')}
buttonStyle={buttonStyle}
onClick={() => {}}
icon="mdi:delete"
/>
)}
<AuthVisible
item={projectNamespaces[0]}
authVerb="update"
onAuthResult={() => setAuthResolved(true)}
onError={() => setAuthResolved(true)}
>
{authResolved && (
<>
<ActionButton
description={t('Delete project')}
buttonStyle={buttonStyle}
onClick={() => setOpenDialog(true)}
icon="mdi:delete"
/>
<ProjectDeleteDialog
open={openDialog}
project={project}
onClose={() => setOpenDialog(false)}
namespaces={projectNamespaces}
/>
</>
)}
</AuthVisible>
Comment on lines +58 to +88
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current authResolved gating, users who are not authorized will briefly see the placeholder button and then it will disappear once onAuthResult fires with allowed: false, causing a layout shift (and a short-lived “Delete” affordance). If the goal is to avoid layout shifts in all cases, consider reserving the space even after auth resolves but is denied (e.g., keep a disabled button with visibility: hidden/aria-hidden, or a fixed-size container) rather than removing it entirely.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

@tejhan tejhan Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The placeholder button exists to prevent layout shift for authorized users (the common case). Making it disabled would cause a visible white -> grey -> white flicker as it transitions to the real button.

For unauthorized users, there would be a brief flash of the placeholder before it disappears, but this comment suggests an empty placeholder for that case - which means these 2 goals are contradictory. Since we check auth first before making the decision of 'showing' it or not, we can't know which placeholder type to use before the auth is done.

Current approach leans toward the most common case so we see zero layout shift.

But this is something that might be a design decision to discuss. (delete button is right aligned)

</>
);
}
28 changes: 22 additions & 6 deletions frontend/src/components/project/ProjectDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ReactNode[]>([]);

// 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);
Expand Down Expand Up @@ -578,7 +586,15 @@ function ProjectDetailsContent({ project }: { project: ProjectDefinition }) {
</Typography>

{headerActions}
<DeleteButton project={project} />
{DeleteButton ? (
<DeleteButton project={project} />
) : (
<ActionButton
description={t('Delete project')}
onClick={() => {}}
icon="mdi:delete"
/>
)}
</Box>
}
>
Expand Down
Loading