Skip to content

Commit f2216b0

Browse files
committed
fix(webapp): show the toast when saving project general settings
The permission-denied and success toasts are flashed into the `__message` cookie, but the root loader consumes the flash on every hop and these redirects targeted the project root, whose index loader immediately redirects again. The message was read and cleared on a page that never rendered, so nothing was ever shown. Redirect back to the settings page the form was submitted from instead. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 45444a7 commit f2216b0

3 files changed

Lines changed: 117 additions & 6 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
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.

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

Lines changed: 13 additions & 6 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, v3ProjectPath } from "~/utils/pathBuilder";
26+
import { organizationPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder";
2727
import { useState } from "react";
2828

2929
function createSchema(
@@ -63,6 +63,7 @@ function createSchema(
6363
const Params = z.object({
6464
organizationSlug: z.string(),
6565
projectParam: z.string(),
66+
envParam: z.string(),
6667
});
6768

6869
export const action = dashboardAction(
@@ -75,7 +76,13 @@ export const action = dashboardAction(
7576
},
7677
async ({ user, ability, request, params }) => {
7778
const userId = user.id;
78-
const { organizationSlug, projectParam } = params;
79+
const { organizationSlug, projectParam, envParam } = params;
80+
81+
const settingsPath = v3ProjectSettingsGeneralPath(
82+
{ slug: organizationSlug },
83+
{ slug: projectParam },
84+
{ slug: envParam }
85+
);
7986

8087
const formData = await request.formData();
8188

@@ -107,7 +114,7 @@ export const action = dashboardAction(
107114
case "rename": {
108115
if (!ability.can("manage", { type: "project" })) {
109116
throw await redirectWithErrorMessage(
110-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
117+
settingsPath,
111118
request,
112119
"You don't have permission to rename this project"
113120
);
@@ -132,15 +139,15 @@ export const action = dashboardAction(
132139
}
133140

134141
return redirectWithSuccessMessage(
135-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
142+
settingsPath,
136143
request,
137144
`Project renamed to ${submission.value.projectName}`
138145
);
139146
}
140147
case "delete": {
141148
if (!ability.can("manage", { type: "project" })) {
142149
throw await redirectWithErrorMessage(
143-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
150+
settingsPath,
144151
request,
145152
"You don't have permission to delete this project"
146153
);
@@ -157,7 +164,7 @@ export const action = dashboardAction(
157164
error: resultOrFail.error,
158165
});
159166
return redirectWithErrorMessage(
160-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
167+
settingsPath,
161168
request,
162169
`Project ${projectParam} could not be deleted`
163170
);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// A flashed toast survives exactly one hop: the root loader reads it with `session.get`
2+
// (which deletes the flash) and commits the emptied session, so any hop that runs the root
3+
// loader spends the message — including a hop whose leaf loader only redirects again and
4+
// never renders the toast. The general settings action must therefore redirect to a page
5+
// that renders.
6+
7+
import { okAsync } from "neverthrow";
8+
import { describe, expect, it, vi } from "vitest";
9+
import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server";
10+
11+
vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({
12+
dashboardAction: (_options: unknown, handler: unknown) => handler,
13+
dashboardLoader: (_options: unknown, handler: unknown) => handler,
14+
}));
15+
16+
vi.mock("~/models/organization.server", () => ({
17+
resolveOrgIdFromSlug: vi.fn().mockResolvedValue("org_1"),
18+
}));
19+
20+
vi.mock("~/services/projectSettings.server", () => ({
21+
ProjectSettingsService: class {
22+
verifyProjectMembership() {
23+
return okAsync({ projectId: "proj_1" });
24+
}
25+
},
26+
}));
27+
28+
const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general";
29+
30+
// Mirrors the read in app/root.tsx's loader.
31+
async function rootLoaderHop(cookie: string | null) {
32+
const session = await getSession(cookie);
33+
const toastMessage = session.get("toastMessage");
34+
return { toastMessage, setCookie: await commitSession(session) };
35+
}
36+
37+
function asRequestCookie(setCookie: string) {
38+
return setCookie.split(";")[0];
39+
}
40+
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+
45+
const body = new URLSearchParams(
46+
action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" }
47+
);
48+
49+
try {
50+
await (module.action as any)({
51+
user: { id: "user_1" },
52+
ability: { can: () => false },
53+
request: new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST", body }),
54+
params: { organizationSlug: "o", projectParam: "p", envParam: "prod" },
55+
context: {},
56+
searchParams: undefined,
57+
});
58+
} catch (thrown) {
59+
return thrown as Response;
60+
}
61+
62+
throw new Error("expected the action to throw a redirect");
63+
}
64+
65+
describe("toast flash through a redirect chain", () => {
66+
it("is lost when the redirect target redirects again", async () => {
67+
const request = new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST" });
68+
const response = await redirectWithErrorMessage("/orgs/o/projects/p", request, "Denied");
69+
70+
const projectRootHop = await rootLoaderHop(
71+
asRequestCookie(response.headers.get("Set-Cookie")!)
72+
);
73+
expect(projectRootHop.toastMessage?.message).toBe("Denied");
74+
75+
const tasksPageHop = await rootLoaderHop(asRequestCookie(projectRootHop.setCookie));
76+
expect(tasksPageHop.toastMessage).toBeUndefined();
77+
});
78+
});
79+
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");
83+
84+
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");
88+
});
89+
90+
it("redirects a denied delete back to the settings page with the message", async () => {
91+
const response = await denialRedirect("delete");
92+
93+
expect(response.headers.get("Location")).toBe(SETTINGS_PATH);
94+
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");
97+
});
98+
});

0 commit comments

Comments
 (0)