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)