Skip to content

Commit 3cecb9d

Browse files
committed
feat(settings): add project settings subpaths and general settings route
Add dedicated subpaths for project settings: /general and /integrations. Introduce v3ProjectSettingsGeneralPath and v3ProjectSettingsIntegrationsPath in pathBuilder to allow linking to specific settings sections. Update GitHub integration routes and redirect logic to use the integrations subpath so installation/redirect targets point to the correct settings tab. Add a new nested route for project settings general: _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx. Implement form schema, actions, and UI scaffolding for renaming and deleting projects (with slug validation), plus server-side checks, messages, and service hooks. This prepares the app for per-tab settings and improves navigation/redirect accuracy for integrations.
1 parent 38981f5 commit 3cecb9d

File tree

13 files changed

+872
-750
lines changed

13 files changed

+872
-750
lines changed

apps/webapp/app/components/integrations/VercelOnboardingModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import {
4444
} from "~/v3/vercel/vercelProjectIntegrationSchema";
4545
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
4646
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
47-
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
47+
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
4848
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
4949
import { useEffect, useState, useCallback, useRef } from "react";
5050
import { usePostHogTracking } from "~/hooks/usePostHog";
@@ -1057,7 +1057,7 @@ export function VercelOnboardingModal({
10571057
</Callout>
10581058

10591059
{(() => {
1060-
const baseSettingsPath = v3ProjectSettingsPath(
1060+
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
10611061
{ slug: organizationSlug },
10621062
{ slug: projectSlug },
10631063
{ slug: environmentSlug }

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Cog8ToothIcon,
1212
CogIcon,
1313
ExclamationTriangleIcon,
14+
PuzzlePieceIcon,
1415
FolderIcon,
1516
FolderOpenIcon,
1617
GlobeAmericasIcon,
@@ -74,7 +75,8 @@ import {
7475
v3LogsPath,
7576
v3ProjectAlertsPath,
7677
v3ProjectPath,
77-
v3ProjectSettingsPath,
78+
v3ProjectSettingsGeneralPath,
79+
v3ProjectSettingsIntegrationsPath,
7880
v3QueuesPath,
7981
v3RunsPath,
8082
v3SchedulesPath,
@@ -589,13 +591,34 @@ export function SideMenu({
589591
data-action="limits"
590592
isCollapsed={isCollapsed}
591593
/>
594+
</SideMenuSection>
595+
596+
<SideMenuSection
597+
title="Project settings"
598+
isSideMenuCollapsed={isCollapsed}
599+
itemSpacingClassName="space-y-0"
600+
initialCollapsed={getSectionCollapsed(
601+
user.dashboardPreferences.sideMenu,
602+
"project-settings"
603+
)}
604+
onCollapseToggle={handleSectionToggle("project-settings")}
605+
>
592606
<SideMenuItem
593-
name="Project settings"
607+
name="General"
594608
icon={Cog8ToothIcon}
595609
activeIconColor="text-text-bright"
596610
inactiveIconColor="text-text-dimmed"
597-
to={v3ProjectSettingsPath(organization, project, environment)}
598-
data-action="project-settings"
611+
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
612+
data-action="project-settings-general"
613+
isCollapsed={isCollapsed}
614+
/>
615+
<SideMenuItem
616+
name="Integrations"
617+
icon={PuzzlePieceIcon}
618+
activeIconColor="text-text-bright"
619+
inactiveIconColor="text-text-dimmed"
620+
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
621+
data-action="project-settings-integrations"
599622
isCollapsed={isCollapsed}
600623
/>
601624
</SideMenuSection>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from "zod";
22

33
// Valid section IDs that can have their collapsed state toggled
4-
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
4+
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
55

66
// Inferred type from the schema
77
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import {
7676
EnvironmentParamSchema,
7777
docsPath,
7878
v3DeploymentPath,
79-
v3ProjectSettingsPath,
79+
v3ProjectSettingsIntegrationsPath,
8080
} from "~/utils/pathBuilder";
8181
import { createSearchParams } from "~/utils/searchParams";
8282
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
@@ -377,7 +377,7 @@ export default function Page() {
377377
<LinkButton
378378
variant="minimal/small"
379379
LeadingIcon={CogIcon}
380-
to={v3ProjectSettingsPath(organization, project, environment)}
380+
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
381381
/>
382382
</div>
383383
)}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { conform, useForm } from "@conform-to/react";
2+
import { parse } from "@conform-to/zod";
3+
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
4+
import { Form, useActionData, useNavigation } from "@remix-run/react";
5+
import { type ActionFunction, json } from "@remix-run/server-runtime";
6+
import { z } from "zod";
7+
import { InlineCode } from "~/components/code/InlineCode";
8+
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
9+
import { Button } from "~/components/primitives/Buttons";
10+
import { ClipboardField } from "~/components/primitives/ClipboardField";
11+
import { Fieldset } from "~/components/primitives/Fieldset";
12+
import { FormButtons } from "~/components/primitives/FormButtons";
13+
import { FormError } from "~/components/primitives/FormError";
14+
import { Header2 } from "~/components/primitives/Headers";
15+
import { Hint } from "~/components/primitives/Hint";
16+
import { Input } from "~/components/primitives/Input";
17+
import { InputGroup } from "~/components/primitives/InputGroup";
18+
import { Label } from "~/components/primitives/Label";
19+
import { SpinnerWhite } from "~/components/primitives/Spinner";
20+
import { useProject } from "~/hooks/useProject";
21+
import {
22+
redirectWithErrorMessage,
23+
redirectWithSuccessMessage,
24+
} from "~/models/message.server";
25+
import { ProjectSettingsService } from "~/services/projectSettings.server";
26+
import { logger } from "~/services/logger.server";
27+
import { requireUserId } from "~/services/session.server";
28+
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
29+
import { useState } from "react";
30+
31+
function createSchema(
32+
constraints: {
33+
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
34+
} = {}
35+
) {
36+
return z.discriminatedUnion("action", [
37+
z.object({
38+
action: z.literal("rename"),
39+
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
40+
}),
41+
z.object({
42+
action: z.literal("delete"),
43+
projectSlug: z.string().superRefine((slug, ctx) => {
44+
if (constraints.getSlugMatch === undefined) {
45+
ctx.addIssue({
46+
code: z.ZodIssueCode.custom,
47+
message: conform.VALIDATION_UNDEFINED,
48+
});
49+
} else {
50+
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
51+
if (isMatch) {
52+
return;
53+
}
54+
55+
ctx.addIssue({
56+
code: z.ZodIssueCode.custom,
57+
message: `The slug must match ${projectSlug}`,
58+
});
59+
}
60+
}),
61+
}),
62+
]);
63+
}
64+
65+
export const action: ActionFunction = async ({ request, params }) => {
66+
const userId = await requireUserId(request);
67+
const { organizationSlug, projectParam } = params;
68+
if (!organizationSlug || !projectParam) {
69+
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
70+
}
71+
72+
const formData = await request.formData();
73+
74+
const schema = createSchema({
75+
getSlugMatch: (slug) => {
76+
return { isMatch: slug === projectParam, projectSlug: projectParam };
77+
},
78+
});
79+
const submission = parse(formData, { schema });
80+
81+
if (!submission.value || submission.intent !== "submit") {
82+
return json(submission);
83+
}
84+
85+
const projectSettingsService = new ProjectSettingsService();
86+
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
87+
organizationSlug,
88+
projectParam,
89+
userId
90+
);
91+
92+
if (membershipResultOrFail.isErr()) {
93+
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
94+
}
95+
96+
const { projectId } = membershipResultOrFail.value;
97+
98+
switch (submission.value.action) {
99+
case "rename": {
100+
const resultOrFail = await projectSettingsService.renameProject(
101+
projectId,
102+
submission.value.projectName
103+
);
104+
105+
if (resultOrFail.isErr()) {
106+
switch (resultOrFail.error.type) {
107+
case "other":
108+
default: {
109+
resultOrFail.error.type satisfies "other";
110+
111+
logger.error("Failed to rename project", {
112+
error: resultOrFail.error,
113+
});
114+
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
115+
}
116+
}
117+
}
118+
119+
return redirectWithSuccessMessage(
120+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
121+
request,
122+
`Project renamed to ${submission.value.projectName}`
123+
);
124+
}
125+
case "delete": {
126+
const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId);
127+
128+
if (resultOrFail.isErr()) {
129+
switch (resultOrFail.error.type) {
130+
case "other":
131+
default: {
132+
resultOrFail.error.type satisfies "other";
133+
134+
logger.error("Failed to delete project", {
135+
error: resultOrFail.error,
136+
});
137+
return redirectWithErrorMessage(
138+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
139+
request,
140+
`Project ${projectParam} could not be deleted`
141+
);
142+
}
143+
}
144+
}
145+
146+
return redirectWithSuccessMessage(
147+
organizationPath({ slug: organizationSlug }),
148+
request,
149+
"Project deleted"
150+
);
151+
}
152+
}
153+
};
154+
155+
export default function GeneralSettingsPage() {
156+
const project = useProject();
157+
const lastSubmission = useActionData();
158+
const navigation = useNavigation();
159+
160+
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
161+
162+
const [renameForm, { projectName }] = useForm({
163+
id: "rename-project",
164+
// TODO: type this
165+
lastSubmission: lastSubmission as any,
166+
shouldRevalidate: "onSubmit",
167+
onValidate({ formData }) {
168+
return parse(formData, {
169+
schema: createSchema(),
170+
});
171+
},
172+
});
173+
174+
const isRenameLoading =
175+
navigation.formData?.get("action") === "rename" &&
176+
(navigation.state === "submitting" || navigation.state === "loading");
177+
178+
const [deleteForm, { projectSlug }] = useForm({
179+
id: "delete-project",
180+
// TODO: type this
181+
lastSubmission: lastSubmission as any,
182+
shouldValidate: "onInput",
183+
shouldRevalidate: "onSubmit",
184+
onValidate({ formData }) {
185+
return parse(formData, {
186+
schema: createSchema({
187+
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
188+
}),
189+
});
190+
},
191+
});
192+
193+
const isDeleteLoading =
194+
navigation.formData?.get("action") === "delete" &&
195+
(navigation.state === "submitting" || navigation.state === "loading");
196+
197+
const [deleteInputValue, setDeleteInputValue] = useState("");
198+
199+
return (
200+
<MainHorizontallyCenteredContainer className="md:mt-6">
201+
<div className="flex flex-col gap-6">
202+
<div>
203+
<Header2 spacing>General</Header2>
204+
<div className="w-full rounded-sm border border-grid-dimmed p-4">
205+
<Fieldset className="mb-5">
206+
<InputGroup fullWidth>
207+
<Label>Project ref</Label>
208+
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
209+
<Hint>
210+
This goes in your{" "}
211+
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
212+
</Hint>
213+
</InputGroup>
214+
</Fieldset>
215+
<Form method="post" {...renameForm.props}>
216+
<Fieldset>
217+
<InputGroup fullWidth>
218+
<Label htmlFor={projectName.id}>Project name</Label>
219+
<Input
220+
{...conform.input(projectName, { type: "text" })}
221+
defaultValue={project.name}
222+
placeholder="Project name"
223+
icon={FolderIcon}
224+
autoFocus
225+
onChange={(e) => {
226+
setHasRenameFormChanges(e.target.value !== project.name);
227+
}}
228+
/>
229+
<FormError id={projectName.errorId}>{projectName.error}</FormError>
230+
</InputGroup>
231+
<FormButtons
232+
confirmButton={
233+
<Button
234+
type="submit"
235+
name="action"
236+
value="rename"
237+
variant={"secondary/small"}
238+
disabled={isRenameLoading || !hasRenameFormChanges}
239+
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
240+
>
241+
Save
242+
</Button>
243+
}
244+
/>
245+
</Fieldset>
246+
</Form>
247+
</div>
248+
</div>
249+
250+
<div>
251+
<Header2 spacing>Danger zone</Header2>
252+
<div className="w-full rounded-sm border border-rose-500/40 p-4">
253+
<Form method="post" {...deleteForm.props}>
254+
<Fieldset>
255+
<InputGroup fullWidth>
256+
<Label htmlFor={projectSlug.id}>Delete project</Label>
257+
<Input
258+
{...conform.input(projectSlug, { type: "text" })}
259+
placeholder="Your project slug"
260+
icon={ExclamationTriangleIcon}
261+
onChange={(e) => setDeleteInputValue(e.target.value)}
262+
/>
263+
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
264+
<FormError>{deleteForm.error}</FormError>
265+
<Hint>
266+
This change is irreversible, so please be certain. Type in the Project slug
267+
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
268+
Delete.
269+
</Hint>
270+
</InputGroup>
271+
<FormButtons
272+
confirmButton={
273+
<Button
274+
type="submit"
275+
name="action"
276+
value="delete"
277+
variant={"danger/small"}
278+
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
279+
leadingIconClassName="text-white"
280+
disabled={isDeleteLoading || deleteInputValue !== project.slug}
281+
>
282+
Delete
283+
</Button>
284+
}
285+
/>
286+
</Fieldset>
287+
</Form>
288+
</div>
289+
</div>
290+
</div>
291+
</MainHorizontallyCenteredContainer>
292+
);
293+
}

0 commit comments

Comments
 (0)