Skip to content

Commit b81bf39

Browse files
committed
fix(webapp): show the toast after a successful project delete
The delete success path redirected to the org root, whose index loader has no rendering branch — every path throws a redirect — so "Project deleted" was spent on a non-rendering hop like the others. Send it to the organization settings page, the nearest ancestor that renders. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f2216b0 commit b81bf39

3 files changed

Lines changed: 29 additions & 21 deletions

File tree

.server-changes/fix-project-settings-toast.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: fix
44
---
55

6-
Renaming or deleting a project now keeps you on the project settings page and shows a message explaining the result, instead of silently moving you to the tasks page.
6+
Renaming or deleting a project now shows a message explaining what happened, instead of silently moving you to the tasks page with no feedback.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server";
2323
import { ProjectSettingsService } from "~/services/projectSettings.server";
2424
import { logger } from "~/services/logger.server";
2525
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
26-
import { organizationPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
26+
import { organizationSettingsPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
2727
import { useState } from "react";
2828

2929
function createSchema(
@@ -173,7 +173,7 @@ export const action = dashboardAction(
173173
}
174174

175175
return redirectWithSuccessMessage(
176-
organizationPath({ slug: organizationSlug }),
176+
organizationSettingsPath({ slug: organizationSlug }),
177177
request,
178178
"Project deleted"
179179
);

apps/webapp/test/projectSettingsToastRedirect.test.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { okAsync } from "neverthrow";
88
import { describe, expect, it, vi } from "vitest";
99
import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server";
10+
import { action as generalSettingsAction } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route";
1011

1112
vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({
1213
dashboardAction: (_options: unknown, handler: unknown) => handler,
@@ -22,10 +23,14 @@ vi.mock("~/services/projectSettings.server", () => ({
2223
verifyProjectMembership() {
2324
return okAsync({ projectId: "proj_1" });
2425
}
26+
deleteProject() {
27+
return okAsync(undefined);
28+
}
2529
},
2630
}));
2731

2832
const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general";
33+
const ORG_SETTINGS_PATH = "/orgs/o/settings";
2934

3035
// Mirrors the read in app/root.tsx's loader.
3136
async function rootLoaderHop(cookie: string | null) {
@@ -38,28 +43,28 @@ function asRequestCookie(setCookie: string) {
3843
return setCookie.split(";")[0];
3944
}
4045

41-
async function denialRedirect(action: "rename" | "delete") {
42-
const module =
43-
await import("~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route");
44-
46+
async function runAction(action: "rename" | "delete", allowed: boolean) {
4547
const body = new URLSearchParams(
4648
action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" }
4749
);
4850

4951
try {
50-
await (module.action as any)({
52+
return (await (generalSettingsAction as any)({
5153
user: { id: "user_1" },
52-
ability: { can: () => false },
54+
ability: { can: () => allowed },
5355
request: new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST", body }),
5456
params: { organizationSlug: "o", projectParam: "p", envParam: "prod" },
5557
context: {},
5658
searchParams: undefined,
57-
});
59+
})) as Response;
5860
} catch (thrown) {
5961
return thrown as Response;
6062
}
63+
}
6164

62-
throw new Error("expected the action to throw a redirect");
65+
async function toastFor(response: Response) {
66+
const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!));
67+
return hop.toastMessage?.message;
6368
}
6469

6570
describe("toast flash through a redirect chain", () => {
@@ -77,22 +82,25 @@ describe("toast flash through a redirect chain", () => {
7782
});
7883
});
7984

80-
describe("general settings permission denial", () => {
81-
it("redirects a denied rename back to the settings page with the message", async () => {
82-
const response = await denialRedirect("rename");
85+
describe("general settings redirects target a page that renders", () => {
86+
it("sends a denied rename back to the settings page with the message", async () => {
87+
const response = await runAction("rename", false);
8388

8489
expect(response.headers.get("Location")).toBe(SETTINGS_PATH);
85-
86-
const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!));
87-
expect(hop.toastMessage?.message).toBe("You don't have permission to rename this project");
90+
expect(await toastFor(response)).toBe("You don't have permission to rename this project");
8891
});
8992

90-
it("redirects a denied delete back to the settings page with the message", async () => {
91-
const response = await denialRedirect("delete");
93+
it("sends a denied delete back to the settings page with the message", async () => {
94+
const response = await runAction("delete", false);
9295

9396
expect(response.headers.get("Location")).toBe(SETTINGS_PATH);
97+
expect(await toastFor(response)).toBe("You don't have permission to delete this project");
98+
});
99+
100+
it("sends a successful delete to the organization settings page with the message", async () => {
101+
const response = await runAction("delete", true);
94102

95-
const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!));
96-
expect(hop.toastMessage?.message).toBe("You don't have permission to delete this project");
103+
expect(response.headers.get("Location")).toBe(ORG_SETTINGS_PATH);
104+
expect(await toastFor(response)).toBe("Project deleted");
97105
});
98106
});

0 commit comments

Comments
 (0)