Skip to content

Guard the active project from deletion and fix the post-delete flow - #2555

Merged
Arman-Beykmohammadi merged 5 commits into
mainfrom
fix/active-project-delete-guard
Aug 21, 2026
Merged

Guard the active project from deletion and fix the post-delete flow#2555
Arman-Beykmohammadi merged 5 commits into
mainfrom
fix/active-project-delete-guard

Conversation

@Arman-Beykmohammadi

Copy link
Copy Markdown
Collaborator

Purpose

The projects list showed a trash can on every card, including the card for the project you were currently working in — the one named in the sidebar, whose id goes out as the X-Project-Id header on every request. Nothing signalled that deleting it was a bad idea, and doing it left the app scoped to a project that no longer existed.

Issue #2415 reports the downstream half of the same problem: after deleting a project you stayed on its detail page, which rendered an empty shell with no title, and only manual navigation got you out.

What Changed

  • The active project's trash can is now disabled — dimmed, cursor: not-allowed, with a tooltip explaining why: "Active project — cannot be deleted. Switch to another project first." EntityCard gained a deleteDisabledReason prop for this; previously the delete button was all-or-nothing (rendered or absent), which is why ModelCard has to hide it entirely for protected models. The four other EntityCard call sites are unchanged. The trash can and the detail-page Delete FAB also gained accessible names, which they were missing.
  • The same guard on the detail page's Delete FAB. The Edit FAB is untouched — editing the active project stays possible.
  • confirmDelete re-checks before the DELETE. The active project can resolve after the card rendered (no cookie yet, or a single project being auto-selected mid-flight), so a click can slip through a not-yet-disabled trash can.
  • Deleting a project now lands somewhere sensible. The detail body unmounts on the same render as the successful DELETE, so the tabs below don't fail one by one while the navigation is in flight; router.replace is used instead of push so Back can't return to the deleted URL.
  • The sidebar and switcher drop the project without a reload. The detail page never called useActiveProject().refresh(), so a deleted project stayed in the switcher until a full page reload. router.refresh() would not have fixed this — that state is seeded by the root layout, which router.refresh() does not re-run.
  • A deleted project's URL renders the not-found state instead of a blank page.
  • projectCreated onboarding progress is derived from the live project list rather than a stored bit that was set once and never unset.
  • Failed deletes on the list page now surface. The failure path only did console.error, so the dialog closed, the card stayed, and the user got no signal.

Additional Context

Closes #2415.

Three things worth flagging for review:

Projects return 410, not 404. crud/project.pyget_item_detail_check_and_raise_if_deleted raises ItemDeletedException, which app/main.py:597 turns into 410 with can_restore: true (confirmed against a running backend). So this deliberately does not use the repo's notFoundIfEntityMissing helper, which is 404-only — that would miss the exact case the issue is about. The 410 path leads to error.tsx, which offers Restore, and that is a trap: unenroll_all_project_members hard-deletes every project_membership row and restore does not re-enroll anyone, so a restored project would be invisible to everyone including whoever restored it. "Gone" and "missing" get the same honest state.

DetailNotFoundState was firing a cross-project resolve for the project itself. A project isn't scoped to a project, so project isn't in the backend's RESOLVABLE_ENTITY_TABLES and GET /resolve rejects it — which made the page say "We could not check other projects right now. Try again in a moment.", a transient-failure message for a permanent condition. This adds a frontend mirror of that set and skips the resolve when it doesn't apply. It touches a shared component, so it fixes the same misleading copy for any other non-resolvable entity landing on a detail not-found page.

Two acceptance criteria from the issue are not implemented, on purpose:

  • "The zero-projects-after-delete case shows a post-delete state" is structurally prevented rather than built. With the guard in place, /projects cannot reach zero through the UI: ActiveProjectContext auto-selects when there is exactly one project, the switcher never clears the selection, so the last project is always the active one and always guarded. A "you just deleted your last project" state would be dead code from day one. The guard tests are the evidence.
  • The genuinely reachable zero-projects case — an admin removes you from every project — is out of scope. It already has its own component (NoProjectAccess), but /projects currently shows the first-run "No project yet" card instead. Changing that copy costs an E2E update (mocked-states.spec.ts:38 asserts it) for a case unrelated to deletion.

Also note the guard is UI-only. Capability.Project has no DELETE — both delete paths gate on UPDATE — and routers/project.py only 404s on missing, so the backend does not reject deleting the active project. A second tab, the API, the SDK, or another member still can; the recovery path (refresh() clearing the active-project cookie) is what covers that.

Testing

npm run format, npm run type-check and npm run lint are clean (0 errors; the 61 pre-existing warnings are all in files this PR does not touch). npx jest passes 212/212 suites, 1970 tests.

New and updated tests:

  • components/common/__tests__/EntityCard.test.tsx — the enabled trash can has an accessible name and fires onDelete; with a reason it is disabled, carries the reason as its tooltip, and swallows clicks; the title still clears the top-right slot.
  • projects/components/__tests__/ProjectsClientWrapper.test.tsx — new file, the list page had no test at all. The active project's trash can is disabled with its reason while the others are enabled; confirmDelete refuses a target that became active while the dialog was open; a failed delete surfaces.
  • projects/[identifier]/__tests__/client-wrapper.test.tsx — moved off getAllByRole index lookups onto the new aria-labels; covers the FAB guard, the Edit FAB staying enabled, and the detail body unmounting before the navigation lands.
  • utils/__tests__/entity-error-handler.test.ts — the resolvable-table set, including that project is not in it.

Verified end-to-end against a real backend and frontend (Playwright driving the app, not mocks):

Check Result
Active project trash can disabled, rgba(0, 0, 0, 0.26) vs rgb(0, 95, 130) for the others, cursor: not-allowed, pointer-events: none
Tooltip on it "Active project — cannot be deleted. Switch to another project first."
Clicking it no dialog opens
Active project detail page Delete FAB disabled with the same tooltip; Edit FAB enabled
Deleting a non-active project lands on /projects; gone from the list; gone from the sidebar switcher without a reload
Back after the delete does not return to the deleted URL
Deleted project's URL "Project not found" with a Back to Projects action; no Restore offer
GET /projects/<deleted> 410 (the reason for the isNotFoundApiError check)

To try it manually: with two or more projects, open /projects and hover the trash can on the project named in the sidebar, then delete a different one from its detail page and watch the sidebar switcher.

@Arman-Beykmohammadi Arman-Beykmohammadi added Frontend Projects Issues related to functionality around projects labels Aug 21, 2026

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overall this looks solid: the UI guard is consistent across list + detail, the post-delete flow avoids the empty shell, and the 410/not-found handling + cross-project resolve skip make the error states much more honest.

Found 2 issues (0 critical, 1 improvement, 1 nit).

The projects list let you delete the project you were working in — the one
scoping every request. EntityCard grows a deleteDisabledReason prop that renders
the trash can disabled, dimmed and not-allowed with the reason as its tooltip,
instead of the all-or-nothing hide it had before. The list page passes it for the
active project and re-checks in confirmDelete, since the active project can
resolve after the card rendered.

Signed-off-by: Arman Beykmohammadi <arman.beykmohammadi@rhesis.ai>
Deleting a project left you on its detail page, an empty shell with no title.
The delete now unmounts the detail body straight away, refreshes
ActiveProjectProvider so the sidebar and switcher drop the project without a
reload, and uses router.replace so Back can't return to the deleted URL. That URL
now renders the not-found state: projects come back as 410, not 404, so this
checks both — the repo's 404-only helper would miss it, and the 410 path offers a
Restore that produces a project nobody can see, because deletion hard-drops every
membership row. The Delete FAB is disabled on the active project for the same
reason as the list page.

Closes #2415

Signed-off-by: Arman Beykmohammadi <arman.beykmohammadi@rhesis.ai>
projectCreated was set once and never unset, so a project removed by another
member or the API left the checklist claiming a step with no project behind it.
It's now derived from the live list rather than the stored bit — deliberately not
written back, because mergeProgress ORs local with remote and an unset would be
resurrected on the next load.

DetailNotFoundState also fired a cross-project resolve for entities the backend
won't resolve. A project isn't scoped to a project, so GET /resolve rejects it and
the page said "we could not check other projects right now, try again in a
moment" — a transient-failure message for a permanent condition. Mirror the
backend's resolvable-table set and skip the resolve when it doesn't apply.

Signed-off-by: Arman Beykmohammadi <arman.beykmohammadi@rhesis.ai>
The projects list had no test file at all. Asserts the trash can is disabled with
its reason on the active project and enabled elsewhere, that confirmDelete refuses
a target that became active while the dialog was open, and that a failed delete
surfaces. The detail-page test moves off getAllByRole index lookups onto the new
aria-labels, and covers the FAB guard plus the detail body unmounting before the
navigation lands.

Signed-off-by: Arman Beykmohammadi <arman.beykmohammadi@rhesis.ai>
@Arman-Beykmohammadi
Arman-Beykmohammadi force-pushed the fix/active-project-delete-guard branch from a0c2466 to cd1e999 Compare August 21, 2026 12:34

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overall this looks solid and the tests/readability improvements are great.

Improvement (blocking): The detail page still needs the same mid-flight guard you added on the list page. If activeProject resolves after the delete modal is opened, handleDeleteConfirm can still delete the now-active project. Suggest re-checking isActiveProject (or comparing against latest activeProject?.id) inside handleDeleteConfirm before calling deleteProject, and ideally show ACTIVE_PROJECT_DELETE_BLOCKED so the modal doesn’t just disappear.

Nit/UX: On the list page, when the mid-flight guard triggers (deleteTarget became active), we currently just close the modal; a toast explaining why would avoid confusion.

The Delete FAB was guarded at render time only. With no active-project cookie and
a single project, ActiveProjectContext auto-selects it client-side after mount —
so the FAB could be enabled when the dialog opened and confirming would delete
the project scoping the app. Re-check before the DELETE, as the list page does.

Both guard paths now say why nothing happened instead of letting the dialog
vanish silently.

Signed-off-by: Arman Beykmohammadi <arman.beykmohammadi@rhesis.ai>

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good. The mid-flight delete guards now surface ACTIVE_PROJECT_DELETE_BLOCKED on both list + detail flows, and the post-delete navigation/unmounting avoids the empty-shell state. Ship it.

@Arman-Beykmohammadi
Arman-Beykmohammadi merged commit ba486ce into main Aug 21, 2026
22 checks passed
@Arman-Beykmohammadi
Arman-Beykmohammadi deleted the fix/active-project-delete-guard branch August 21, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Frontend Projects Issues related to functionality around projects

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-delete flow for projects is unclear — you stay on an empty project page

1 participant