-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(webapp): show the toast when saving project general settings #4601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+183
−11
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f2216b0
fix(webapp): show the toast when saving project general settings
claude b81bf39
fix(webapp): show the toast after a successful project delete
claude 9453add
fix(webapp): keep the delete destination and drop the deletion claim
claude 748e963
fix(webapp): surface project rename failures on the form
claude 617296a
fix(webapp): scope the settings form error to the submitted form
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: fix | ||
| --- | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| // 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 { errAsync, okAsync } from "neverthrow"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { commitSession, getSession, redirectWithErrorMessage } from "~/models/message.server"; | ||
| 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, | ||
| dashboardLoader: (_options: unknown, handler: unknown) => handler, | ||
| })); | ||
|
|
||
| vi.mock("~/models/organization.server", () => ({ | ||
| 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); | ||
| } | ||
| }, | ||
| })); | ||
|
carderne marked this conversation as resolved.
|
||
|
|
||
| const SETTINGS_PATH = "/orgs/o/projects/p/env/prod/settings/general"; | ||
| const ORG_PATH = "/orgs/o"; | ||
|
|
||
| // 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 runAction(action: "rename" | "delete", allowed: boolean) { | ||
| const body = new URLSearchParams( | ||
| action === "rename" ? { action, projectName: "New name" } : { action, projectSlug: "p" } | ||
| ); | ||
|
|
||
| try { | ||
| return (await (generalSettingsAction as any)({ | ||
| user: { id: "user_1" }, | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
| 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", () => { | ||
| 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 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); | ||
| expect(await toastFor(response)).toBe("You don't have permission to rename this project"); | ||
| }); | ||
|
|
||
| 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"); | ||
| }); | ||
|
|
||
| // 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_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"] }, | ||
| }); | ||
| }); | ||
|
|
||
| // 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(); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.