From f2216b09f1bb3e70387771f2ca44e63a6c59cbae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:58:58 +0000 Subject: [PATCH 1/5] 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 --- .server-changes/fix-project-settings-toast.md | 6 ++ .../route.tsx | 19 ++-- .../test/projectSettingsToastRedirect.test.ts | 98 +++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 .server-changes/fix-project-settings-toast.md create mode 100644 apps/webapp/test/projectSettingsToastRedirect.test.ts diff --git a/.server-changes/fix-project-settings-toast.md b/.server-changes/fix-project-settings-toast.md new file mode 100644 index 0000000000..86e242715b --- /dev/null +++ b/.server-changes/fix-project-settings-toast.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +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. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index ccc61fc5ac..b77a48ada4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -23,7 +23,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; -import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { organizationPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; import { useState } from "react"; function createSchema( @@ -63,6 +63,7 @@ function createSchema( const Params = z.object({ organizationSlug: z.string(), projectParam: z.string(), + envParam: z.string(), }); export const action = dashboardAction( @@ -75,7 +76,13 @@ export const action = dashboardAction( }, async ({ user, ability, request, params }) => { const userId = user.id; - const { organizationSlug, projectParam } = params; + const { organizationSlug, projectParam, envParam } = params; + + const settingsPath = v3ProjectSettingsGeneralPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); const formData = await request.formData(); @@ -107,7 +114,7 @@ export const action = dashboardAction( case "rename": { if (!ability.can("manage", { type: "project" })) { throw await redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + settingsPath, request, "You don't have permission to rename this project" ); @@ -132,7 +139,7 @@ export const action = dashboardAction( } return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + settingsPath, request, `Project renamed to ${submission.value.projectName}` ); @@ -140,7 +147,7 @@ export const action = dashboardAction( case "delete": { if (!ability.can("manage", { type: "project" })) { throw await redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + settingsPath, request, "You don't have permission to delete this project" ); @@ -157,7 +164,7 @@ export const action = dashboardAction( error: resultOrFail.error, }); return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + settingsPath, request, `Project ${projectParam} could not be deleted` ); diff --git a/apps/webapp/test/projectSettingsToastRedirect.test.ts b/apps/webapp/test/projectSettingsToastRedirect.test.ts new file mode 100644 index 0000000000..1293e0dbf5 --- /dev/null +++ b/apps/webapp/test/projectSettingsToastRedirect.test.ts @@ -0,0 +1,98 @@ +// A flashed toast survives exactly one hop: the root loader reads it with `session.get` +// (which deletes the flash) and commits the emptied session, so any hop that runs the root +// loader spends the message — including a hop whose leaf loader only redirects again and +// never renders the toast. The general settings action must therefore redirect to a page +// that renders. + +import { okAsync } from "neverthrow"; +import { describe, expect, it, vi } from "vitest"; +import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server"; + +vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({ + dashboardAction: (_options: unknown, handler: unknown) => handler, + dashboardLoader: (_options: unknown, handler: unknown) => handler, +})); + +vi.mock("~/models/organization.server", () => ({ + resolveOrgIdFromSlug: vi.fn().mockResolvedValue("org_1"), +})); + +vi.mock("~/services/projectSettings.server", () => ({ + ProjectSettingsService: class { + verifyProjectMembership() { + return okAsync({ projectId: "proj_1" }); + } + }, +})); + +const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general"; + +// Mirrors the read in app/root.tsx's loader. +async function rootLoaderHop(cookie: string | null) { + const session = await getSession(cookie); + const toastMessage = session.get("toastMessage"); + return { toastMessage, setCookie: await commitSession(session) }; +} + +function asRequestCookie(setCookie: string) { + return setCookie.split(";")[0]; +} + +async function denialRedirect(action: "rename" | "delete") { + const module = + await import("~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route"); + + const body = new URLSearchParams( + action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" } + ); + + try { + await (module.action as any)({ + user: { id: "user_1" }, + ability: { can: () => false }, + request: new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST", body }), + params: { organizationSlug: "o", projectParam: "p", envParam: "prod" }, + context: {}, + searchParams: undefined, + }); + } catch (thrown) { + return thrown as Response; + } + + throw new Error("expected the action to throw a redirect"); +} + +describe("toast flash through a redirect chain", () => { + it("is lost when the redirect target redirects again", async () => { + const request = new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST" }); + const response = await redirectWithErrorMessage("/orgs/o/projects/p", request, "Denied"); + + const projectRootHop = await rootLoaderHop( + asRequestCookie(response.headers.get("Set-Cookie")!) + ); + expect(projectRootHop.toastMessage?.message).toBe("Denied"); + + const tasksPageHop = await rootLoaderHop(asRequestCookie(projectRootHop.setCookie)); + expect(tasksPageHop.toastMessage).toBeUndefined(); + }); +}); + +describe("general settings permission denial", () => { + it("redirects a denied rename back to the settings page with the message", async () => { + const response = await denialRedirect("rename"); + + expect(response.headers.get("Location")).toBe(SETTINGS_PATH); + + const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!)); + expect(hop.toastMessage?.message).toBe("You don't have permission to rename this project"); + }); + + it("redirects a denied delete back to the settings page with the message", async () => { + const response = await denialRedirect("delete"); + + expect(response.headers.get("Location")).toBe(SETTINGS_PATH); + + const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!)); + expect(hop.toastMessage?.message).toBe("You don't have permission to delete this project"); + }); +}); From b81bf39e4c85b0c38986d16061fcadebba70afd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:18:03 +0000 Subject: [PATCH 2/5] fix(webapp): show the toast after a successful project delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .server-changes/fix-project-settings-toast.md | 2 +- .../route.tsx | 4 +- .../test/projectSettingsToastRedirect.test.ts | 44 +++++++++++-------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/.server-changes/fix-project-settings-toast.md b/.server-changes/fix-project-settings-toast.md index 86e242715b..74b046114b 100644 --- a/.server-changes/fix-project-settings-toast.md +++ b/.server-changes/fix-project-settings-toast.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -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. +Renaming or deleting a project now shows a message explaining what happened, instead of silently moving you to the tasks page with no feedback. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index b77a48ada4..3bda6ec9df 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -23,7 +23,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; -import { organizationPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; +import { organizationSettingsPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; import { useState } from "react"; function createSchema( @@ -173,7 +173,7 @@ export const action = dashboardAction( } return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), + organizationSettingsPath({ slug: organizationSlug }), request, "Project deleted" ); diff --git a/apps/webapp/test/projectSettingsToastRedirect.test.ts b/apps/webapp/test/projectSettingsToastRedirect.test.ts index 1293e0dbf5..f8ed8335f1 100644 --- a/apps/webapp/test/projectSettingsToastRedirect.test.ts +++ b/apps/webapp/test/projectSettingsToastRedirect.test.ts @@ -7,6 +7,7 @@ import { okAsync } from "neverthrow"; import { describe, expect, it, vi } from "vitest"; import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server"; +import { action as generalSettingsAction } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route"; vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({ dashboardAction: (_options: unknown, handler: unknown) => handler, @@ -22,10 +23,14 @@ vi.mock("~/services/projectSettings.server", () => ({ verifyProjectMembership() { return okAsync({ projectId: "proj_1" }); } + deleteProject() { + return okAsync(undefined); + } }, })); const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general"; +const ORG_SETTINGS_PATH = "/orgs/o/settings"; // Mirrors the read in app/root.tsx's loader. async function rootLoaderHop(cookie: string | null) { @@ -38,28 +43,28 @@ function asRequestCookie(setCookie: string) { return setCookie.split(";")[0]; } -async function denialRedirect(action: "rename" | "delete") { - const module = - await import("~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route"); - +async function runAction(action: "rename" | "delete", allowed: boolean) { const body = new URLSearchParams( action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" } ); try { - await (module.action as any)({ + return (await (generalSettingsAction as any)({ user: { id: "user_1" }, - ability: { can: () => false }, + ability: { can: () => allowed }, request: new Request(`https://app.example.com${SETTINGS_PATH}`, { method: "POST", body }), params: { organizationSlug: "o", projectParam: "p", envParam: "prod" }, context: {}, searchParams: undefined, - }); + })) as Response; } catch (thrown) { return thrown as Response; } +} - throw new Error("expected the action to throw a redirect"); +async function toastFor(response: Response) { + const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!)); + return hop.toastMessage?.message; } describe("toast flash through a redirect chain", () => { @@ -77,22 +82,25 @@ describe("toast flash through a redirect chain", () => { }); }); -describe("general settings permission denial", () => { - it("redirects a denied rename back to the settings page with the message", async () => { - const response = await denialRedirect("rename"); +describe("general settings redirects target a page that renders", () => { + it("sends a denied rename back to the settings page with the message", async () => { + const response = await runAction("rename", false); expect(response.headers.get("Location")).toBe(SETTINGS_PATH); - - const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!)); - expect(hop.toastMessage?.message).toBe("You don't have permission to rename this project"); + expect(await toastFor(response)).toBe("You don't have permission to rename this project"); }); - it("redirects a denied delete back to the settings page with the message", async () => { - const response = await denialRedirect("delete"); + it("sends a denied delete back to the settings page with the message", async () => { + const response = await runAction("delete", false); expect(response.headers.get("Location")).toBe(SETTINGS_PATH); + expect(await toastFor(response)).toBe("You don't have permission to delete this project"); + }); + + it("sends a successful delete to the organization settings page with the message", async () => { + const response = await runAction("delete", true); - const hop = await rootLoaderHop(asRequestCookie(response.headers.get("Set-Cookie")!)); - expect(hop.toastMessage?.message).toBe("You don't have permission to delete this project"); + expect(response.headers.get("Location")).toBe(ORG_SETTINGS_PATH); + expect(await toastFor(response)).toBe("Project deleted"); }); }); From 9453add8aab292be8cb19763a5bdfe1ba54c9fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:30:44 +0000 Subject: [PATCH 3/5] fix(webapp): keep the delete destination and drop the deletion claim No org-level page both renders and makes sense to land on after deleting a project, so moving the destination to reach the toast was a worse trade than the missing confirmation. Restore the org root and reword the release note so it only promises what renaming actually delivers. Co-Authored-By: Claude --- .server-changes/fix-project-settings-toast.md | 2 +- .../route.tsx | 4 ++-- apps/webapp/test/projectSettingsToastRedirect.test.ts | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.server-changes/fix-project-settings-toast.md b/.server-changes/fix-project-settings-toast.md index 74b046114b..93f3d8ed86 100644 --- a/.server-changes/fix-project-settings-toast.md +++ b/.server-changes/fix-project-settings-toast.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -Renaming or deleting a project now shows a message explaining what happened, instead of silently moving you to the tasks page with no feedback. +Renaming 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. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index 3bda6ec9df..b77a48ada4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -23,7 +23,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; -import { organizationSettingsPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; +import { organizationPath, v3ProjectSettingsGeneralPath } from "~/utils/pathBuilder"; import { useState } from "react"; function createSchema( @@ -173,7 +173,7 @@ export const action = dashboardAction( } return redirectWithSuccessMessage( - organizationSettingsPath({ slug: organizationSlug }), + organizationPath({ slug: organizationSlug }), request, "Project deleted" ); diff --git a/apps/webapp/test/projectSettingsToastRedirect.test.ts b/apps/webapp/test/projectSettingsToastRedirect.test.ts index f8ed8335f1..3b0b0a2d56 100644 --- a/apps/webapp/test/projectSettingsToastRedirect.test.ts +++ b/apps/webapp/test/projectSettingsToastRedirect.test.ts @@ -30,7 +30,7 @@ vi.mock("~/services/projectSettings.server", () => ({ })); const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general"; -const ORG_SETTINGS_PATH = "/orgs/o/settings"; +const ORG_PATH = "/orgs/o"; // Mirrors the read in app/root.tsx's loader. async function rootLoaderHop(cookie: string | null) { @@ -97,10 +97,11 @@ describe("general settings redirects target a page that renders", () => { expect(await toastFor(response)).toBe("You don't have permission to delete this project"); }); - it("sends a successful delete to the organization settings page with the message", async () => { + // The deleted project's settings page is gone and no org-level page renders, so a + // successful delete keeps its original destination and its message is not shown. + it("leaves a successful delete pointed at the organization root", async () => { const response = await runAction("delete", true); - expect(response.headers.get("Location")).toBe(ORG_SETTINGS_PATH); - expect(await toastFor(response)).toBe("Project deleted"); + expect(response.headers.get("Location")).toBe(ORG_PATH); }); }); From 748e9634e18739727231fdc8e7a584d4b5eb3773 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:46:30 +0000 Subject: [PATCH 4/5] fix(webapp): surface project rename failures on the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two json({ errors: { body } }) returns aren't a conform SubmissionResult, so conform reset the form and discarded them — the last way to fail this form with nothing on screen. Return submission.reply({ formErrors }) and render the rename form's form-level errors, matching the delete form on the same page. Co-Authored-By: Claude --- .server-changes/fix-project-settings-toast.md | 2 +- .../route.tsx | 7 +++++-- .../test/projectSettingsToastRedirect.test.ts | 20 ++++++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.server-changes/fix-project-settings-toast.md b/.server-changes/fix-project-settings-toast.md index 93f3d8ed86..0619c4aa01 100644 --- a/.server-changes/fix-project-settings-toast.md +++ b/.server-changes/fix-project-settings-toast.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -Renaming 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. +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. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index b77a48ada4..6f3d3e1daf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -105,7 +105,7 @@ export const action = dashboardAction( ); if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + return json(submission.reply({ formErrors: ["Project not found"] }), { status: 404 }); } const { projectId } = membershipResultOrFail.value; @@ -133,7 +133,9 @@ export const action = dashboardAction( logger.error("Failed to rename project", { error: resultOrFail.error, }); - return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + return json(submission.reply({ formErrors: ["Failed to rename project"] }), { + status: 400, + }); } } } @@ -257,6 +259,7 @@ export default function GeneralSettingsPage() { }} /> {projectName.errors} + {renameForm.errors} ({ resolveOrgIdFromSlug: vi.fn().mockResolvedValue("org_1"), })); +const renameFails = { value: false }; + vi.mock("~/services/projectSettings.server", () => ({ ProjectSettingsService: class { verifyProjectMembership() { return okAsync({ projectId: "proj_1" }); } + renameProject() { + return renameFails.value ? errAsync({ type: "other" as const }) : okAsync(undefined); + } deleteProject() { return okAsync(undefined); } @@ -105,3 +110,16 @@ describe("general settings redirects target a page that renders", () => { expect(response.headers.get("Location")).toBe(ORG_PATH); }); }); + +describe("general settings failures reach the form", () => { + it("returns a form-level error when the rename fails", async () => { + renameFails.value = true; + const response = await runAction("rename", true); + renameFails.value = false; + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { "": ["Failed to rename project"] }, + }); + }); +}); From 617296ada1d1478c795d8a603b017e35de44aff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:05:19 +0000 Subject: [PATCH 5/5] fix(webapp): scope the settings form error to the submitted form Both conform forms on the project general settings page read the same action result, and a SubmissionResult carries no form identity, so a failed rename rendered its form-level error under the delete box too. Echo the submitted intent back as `formAction` and gate each form's lastResult on it, matching the test task page. Co-Authored-By: Claude --- .../route.tsx | 30 ++++++++++++++----- .../test/projectSettingsToastRedirect.test.ts | 17 ++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index 6f3d3e1daf..a26287ab46 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -60,6 +60,17 @@ function createSchema( ]); } +type FormAction = "rename" | "delete"; + +export function submissionFor(lastSubmission: unknown, formAction: FormAction) { + return lastSubmission && + typeof lastSubmission === "object" && + "formAction" in lastSubmission && + lastSubmission.formAction === formAction + ? lastSubmission + : undefined; +} + const Params = z.object({ organizationSlug: z.string(), projectParam: z.string(), @@ -85,6 +96,7 @@ export const action = dashboardAction( ); const formData = await request.formData(); + const formAction = formData.get("action") as FormAction; const schema = createSchema({ getSlugMatch: (slug) => { @@ -94,7 +106,7 @@ export const action = dashboardAction( const submission = parseWithZod(formData, { schema }); if (submission.status !== "success") { - return json(submission.reply()); + return json({ ...submission.reply(), formAction }); } const projectSettingsService = new ProjectSettingsService(); @@ -105,7 +117,10 @@ export const action = dashboardAction( ); if (membershipResultOrFail.isErr()) { - return json(submission.reply({ formErrors: ["Project not found"] }), { status: 404 }); + return json( + { ...submission.reply({ formErrors: ["Project not found"] }), formAction }, + { status: 404 } + ); } const { projectId } = membershipResultOrFail.value; @@ -133,9 +148,10 @@ export const action = dashboardAction( logger.error("Failed to rename project", { error: resultOrFail.error, }); - return json(submission.reply({ formErrors: ["Failed to rename project"] }), { - status: 400, - }); + return json( + { ...submission.reply({ formErrors: ["Failed to rename project"] }), formAction }, + { status: 400 } + ); } } } @@ -194,7 +210,7 @@ export default function GeneralSettingsPage() { const [renameForm, { projectName }] = useForm({ id: "rename-project", // TODO: type this - lastResult: lastSubmission as any, + lastResult: submissionFor(lastSubmission, "rename") as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parseWithZod(formData, { @@ -210,7 +226,7 @@ export default function GeneralSettingsPage() { const [deleteForm, { projectSlug }] = useForm({ id: "delete-project", // TODO: type this - lastResult: lastSubmission as any, + lastResult: submissionFor(lastSubmission, "delete") as any, shouldValidate: "onInput", shouldRevalidate: "onSubmit", onValidate({ formData }) { diff --git a/apps/webapp/test/projectSettingsToastRedirect.test.ts b/apps/webapp/test/projectSettingsToastRedirect.test.ts index 79c32be3e2..49e772b50c 100644 --- a/apps/webapp/test/projectSettingsToastRedirect.test.ts +++ b/apps/webapp/test/projectSettingsToastRedirect.test.ts @@ -7,7 +7,10 @@ import { errAsync, okAsync } from "neverthrow"; import { describe, expect, it, vi } from "vitest"; import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server"; -import { action as generalSettingsAction } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route"; +import { + action as generalSettingsAction, + submissionFor, +} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route"; vi.mock("~/services/routeBuilders/dashboardBuilder", () => ({ dashboardAction: (_options: unknown, handler: unknown) => handler, @@ -122,4 +125,16 @@ describe("general settings failures reach the form", () => { error: { "": ["Failed to rename project"] }, }); }); + + // A SubmissionResult carries no form identity, so both forms would otherwise show it. + it("scopes the rename failure to the rename form", async () => { + renameFails.value = true; + const response = await runAction("rename", true); + renameFails.value = false; + + const result = await response.json(); + + expect(submissionFor(result, "rename")).toEqual(result); + expect(submissionFor(result, "delete")).toBeUndefined(); + }); });