Skip to content

Commit 802d238

Browse files
authored
fix(webapp): show the toast when saving project general settings (#4601)
1 parent ee85448 commit 802d238

3 files changed

Lines changed: 183 additions & 11 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 a project now keeps you on the project settings page and tells you what happened, instead of silently moving you to the tasks page or clearing the form with no explanation.

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

Lines changed: 37 additions & 11 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(
@@ -60,9 +60,21 @@ function createSchema(
6060
]);
6161
}
6262

63+
type FormAction = "rename" | "delete";
64+
65+
export function submissionFor(lastSubmission: unknown, formAction: FormAction) {
66+
return lastSubmission &&
67+
typeof lastSubmission === "object" &&
68+
"formAction" in lastSubmission &&
69+
lastSubmission.formAction === formAction
70+
? lastSubmission
71+
: undefined;
72+
}
73+
6374
const Params = z.object({
6475
organizationSlug: z.string(),
6576
projectParam: z.string(),
77+
envParam: z.string(),
6678
});
6779

6880
export const action = dashboardAction(
@@ -75,9 +87,16 @@ export const action = dashboardAction(
7587
},
7688
async ({ user, ability, request, params }) => {
7789
const userId = user.id;
78-
const { organizationSlug, projectParam } = params;
90+
const { organizationSlug, projectParam, envParam } = params;
91+
92+
const settingsPath = v3ProjectSettingsGeneralPath(
93+
{ slug: organizationSlug },
94+
{ slug: projectParam },
95+
{ slug: envParam }
96+
);
7997

8098
const formData = await request.formData();
99+
const formAction = formData.get("action") as FormAction;
81100

82101
const schema = createSchema({
83102
getSlugMatch: (slug) => {
@@ -87,7 +106,7 @@ export const action = dashboardAction(
87106
const submission = parseWithZod(formData, { schema });
88107

89108
if (submission.status !== "success") {
90-
return json(submission.reply());
109+
return json({ ...submission.reply(), formAction });
91110
}
92111

93112
const projectSettingsService = new ProjectSettingsService();
@@ -98,7 +117,10 @@ export const action = dashboardAction(
98117
);
99118

100119
if (membershipResultOrFail.isErr()) {
101-
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
120+
return json(
121+
{ ...submission.reply({ formErrors: ["Project not found"] }), formAction },
122+
{ status: 404 }
123+
);
102124
}
103125

104126
const { projectId } = membershipResultOrFail.value;
@@ -107,7 +129,7 @@ export const action = dashboardAction(
107129
case "rename": {
108130
if (!ability.can("manage", { type: "project" })) {
109131
throw await redirectWithErrorMessage(
110-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
132+
settingsPath,
111133
request,
112134
"You don't have permission to rename this project"
113135
);
@@ -126,21 +148,24 @@ export const action = dashboardAction(
126148
logger.error("Failed to rename project", {
127149
error: resultOrFail.error,
128150
});
129-
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
151+
return json(
152+
{ ...submission.reply({ formErrors: ["Failed to rename project"] }), formAction },
153+
{ status: 400 }
154+
);
130155
}
131156
}
132157
}
133158

134159
return redirectWithSuccessMessage(
135-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
160+
settingsPath,
136161
request,
137162
`Project renamed to ${submission.value.projectName}`
138163
);
139164
}
140165
case "delete": {
141166
if (!ability.can("manage", { type: "project" })) {
142167
throw await redirectWithErrorMessage(
143-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
168+
settingsPath,
144169
request,
145170
"You don't have permission to delete this project"
146171
);
@@ -157,7 +182,7 @@ export const action = dashboardAction(
157182
error: resultOrFail.error,
158183
});
159184
return redirectWithErrorMessage(
160-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
185+
settingsPath,
161186
request,
162187
`Project ${projectParam} could not be deleted`
163188
);
@@ -185,7 +210,7 @@ export default function GeneralSettingsPage() {
185210
const [renameForm, { projectName }] = useForm({
186211
id: "rename-project",
187212
// TODO: type this
188-
lastResult: lastSubmission as any,
213+
lastResult: submissionFor(lastSubmission, "rename") as any,
189214
shouldRevalidate: "onSubmit",
190215
onValidate({ formData }) {
191216
return parseWithZod(formData, {
@@ -201,7 +226,7 @@ export default function GeneralSettingsPage() {
201226
const [deleteForm, { projectSlug }] = useForm({
202227
id: "delete-project",
203228
// TODO: type this
204-
lastResult: lastSubmission as any,
229+
lastResult: submissionFor(lastSubmission, "delete") as any,
205230
shouldValidate: "onInput",
206231
shouldRevalidate: "onSubmit",
207232
onValidate({ formData }) {
@@ -250,6 +275,7 @@ export default function GeneralSettingsPage() {
250275
}}
251276
/>
252277
<FormError id={projectName.errorId}>{projectName.errors}</FormError>
278+
<FormError>{renameForm.errors}</FormError>
253279
</InputGroup>
254280
<FormButtons
255281
confirmButton={
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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 { errAsync, okAsync } from "neverthrow";
8+
import { describe, expect, it, vi } from "vitest";
9+
import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server";
10+
import {
11+
action as generalSettingsAction,
12+
submissionFor,
13+
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route";
14+
15+
vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({
16+
dashboardAction: (_options: unknown, handler: unknown) => handler,
17+
dashboardLoader: (_options: unknown, handler: unknown) => handler,
18+
}));
19+
20+
vi.mock("~/models/organization.server", () => ({
21+
resolveOrgIdFromSlug: vi.fn().mockResolvedValue("org_1"),
22+
}));
23+
24+
const renameFails = { value: false };
25+
26+
vi.mock("~/services/projectSettings.server", () => ({
27+
ProjectSettingsService: class {
28+
verifyProjectMembership() {
29+
return okAsync({ projectId: "proj_1" });
30+
}
31+
renameProject() {
32+
return renameFails.value ? errAsync({ type: "other" as const }) : okAsync(undefined);
33+
}
34+
deleteProject() {
35+
return okAsync(undefined);
36+
}
37+
},
38+
}));
39+
40+
const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general";
41+
const ORG_PATH = "/orgs/o";
42+
43+
// Mirrors the read in app/root.tsx's loader.
44+
async function rootLoaderHop(cookie: string | null) {
45+
const session = await getSession(cookie);
46+
const toastMessage = session.get("toastMessage");
47+
return { toastMessage, setCookie: await commitSession(session) };
48+
}
49+
50+
function asRequestCookie(setCookie: string) {
51+
return setCookie.split(";")[0];
52+
}
53+
54+
async function runAction(action: "rename" | "delete", allowed: boolean) {
55+
const body = new URLSearchParams(
56+
action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" }
57+
);
58+
59+
try {
60+
return (await (generalSettingsAction as any)({
61+
user: { id: "user_1" },
62+
ability: { can: () => allowed },
63+
request: new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST", body }),
64+
params: { organizationSlug: "o", projectParam: "p", envParam: "prod" },
65+
context: {},
66+
searchParams: undefined,
67+
})) as Response;
68+
} catch (thrown) {
69+
return thrown as Response;
70+
}
71+
}
72+
73+
async function toastFor(response: Response) {
74+
const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!));
75+
return hop.toastMessage?.message;
76+
}
77+
78+
describe("toast flash through a redirect chain", () => {
79+
it("is lost when the redirect target redirects again", async () => {
80+
const request = new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST" });
81+
const response = await redirectWithErrorMessage("/orgs/o/projects/p", request, "Denied");
82+
83+
const projectRootHop = await rootLoaderHop(
84+
asRequestCookie(response.headers.get("Set-Cookie")!)
85+
);
86+
expect(projectRootHop.toastMessage?.message).toBe("Denied");
87+
88+
const tasksPageHop = await rootLoaderHop(asRequestCookie(projectRootHop.setCookie));
89+
expect(tasksPageHop.toastMessage).toBeUndefined();
90+
});
91+
});
92+
93+
describe("general settings redirects target a page that renders", () => {
94+
it("sends a denied rename back to the settings page with the message", async () => {
95+
const response = await runAction("rename", false);
96+
97+
expect(response.headers.get("Location")).toBe(SETTINGS_PATH);
98+
expect(await toastFor(response)).toBe("You don't have permission to rename this project");
99+
});
100+
101+
it("sends a denied delete back to the settings page with the message", async () => {
102+
const response = await runAction("delete", false);
103+
104+
expect(response.headers.get("Location")).toBe(SETTINGS_PATH);
105+
expect(await toastFor(response)).toBe("You don't have permission to delete this project");
106+
});
107+
108+
// The deleted project's settings page is gone and no org-level page renders, so a
109+
// successful delete keeps its original destination and its message is not shown.
110+
it("leaves a successful delete pointed at the organization root", async () => {
111+
const response = await runAction("delete", true);
112+
113+
expect(response.headers.get("Location")).toBe(ORG_PATH);
114+
});
115+
});
116+
117+
describe("general settings failures reach the form", () => {
118+
it("returns a form-level error when the rename fails", async () => {
119+
renameFails.value = true;
120+
const response = await runAction("rename", true);
121+
renameFails.value = false;
122+
123+
expect(response.status).toBe(400);
124+
expect(await response.json()).toMatchObject({
125+
error: { "": ["Failed to rename project"] },
126+
});
127+
});
128+
129+
// A SubmissionResult carries no form identity, so both forms would otherwise show it.
130+
it("scopes the rename failure to the rename form", async () => {
131+
renameFails.value = true;
132+
const response = await runAction("rename", true);
133+
renameFails.value = false;
134+
135+
const result = await response.json();
136+
137+
expect(submissionFor(result, "rename")).toEqual(result);
138+
expect(submissionFor(result, "delete")).toBeUndefined();
139+
});
140+
});

0 commit comments

Comments
 (0)