Skip to content

Commit 501a383

Browse files
authored
feat: expose project build settings (#2507)
This PR enables setting project build settings in the settings page: root directory, install command and trigger config file path. For most cases there should be no need to set these explicitly.
1 parent 3c199e6 commit 501a383

File tree

6 files changed

+260
-27
lines changed

6 files changed

+260
-27
lines changed

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

Lines changed: 199 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
EnvironmentParamSchema,
6363
v3ProjectSettingsPath,
6464
} from "~/utils/pathBuilder";
65-
import { useEffect, useState } from "react";
65+
import React, { useEffect, useState } from "react";
6666
import { Select, SelectItem } from "~/components/primitives/Select";
6767
import { Switch } from "~/components/primitives/Switch";
6868
import { type BranchTrackingConfig } from "~/v3/github";
@@ -77,6 +77,7 @@ import { DateTime } from "~/components/primitives/DateTime";
7777
import { TextLink } from "~/components/primitives/TextLink";
7878
import { cn } from "~/utils/cn";
7979
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
80+
import { type BuildSettings } from "~/v3/buildSettings";
8081

8182
export const meta: MetaFunction = () => {
8283
return [
@@ -120,7 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
120121
}
121122
}
122123

123-
const { gitHubApp } = resultOrFail.value;
124+
const { gitHubApp, buildSettings } = resultOrFail.value;
124125

125126
const session = await getSession(request.headers.get("Cookie"));
126127
const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true;
@@ -134,6 +135,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
134135
githubAppInstallations: gitHubApp.installations,
135136
connectedGithubRepository: gitHubApp.connectedRepository,
136137
openGitHubRepoConnectionModal,
138+
buildSettings,
137139
},
138140
{ headers }
139141
);
@@ -155,6 +157,38 @@ const UpdateGitSettingsFormSchema = z.object({
155157
.transform((val) => val === "on"),
156158
});
157159

160+
const UpdateBuildSettingsFormSchema = z.object({
161+
action: z.literal("update-build-settings"),
162+
triggerConfigFilePath: z
163+
.string()
164+
.trim()
165+
.optional()
166+
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
167+
.refine((val) => !val || val.length <= 255, {
168+
message: "Config file path must not exceed 255 characters",
169+
}),
170+
installDirectory: z
171+
.string()
172+
.trim()
173+
.optional()
174+
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
175+
.refine((val) => !val || val.length <= 255, {
176+
message: "Install directory must not exceed 255 characters",
177+
}),
178+
installCommand: z
179+
.string()
180+
.trim()
181+
.optional()
182+
.refine((val) => !val || !val.includes("\n"), {
183+
message: "Install command must be a single line",
184+
})
185+
.refine((val) => !val || val.length <= 500, {
186+
message: "Install command must not exceed 500 characters",
187+
}),
188+
});
189+
190+
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
191+
158192
export function createSchema(
159193
constraints: {
160194
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
@@ -188,6 +222,7 @@ export function createSchema(
188222
}),
189223
ConnectGitHubRepoFormSchema,
190224
UpdateGitSettingsFormSchema,
225+
UpdateBuildSettingsFormSchema,
191226
z.object({
192227
action: z.literal("disconnect-repo"),
193228
}),
@@ -376,6 +411,31 @@ export const action: ActionFunction = async ({ request, params }) => {
376411
success: true,
377412
});
378413
}
414+
case "update-build-settings": {
415+
const { installDirectory, installCommand, triggerConfigFilePath } = submission.value;
416+
417+
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
418+
installDirectory: installDirectory || undefined,
419+
installCommand: installCommand || undefined,
420+
triggerConfigFilePath: triggerConfigFilePath || undefined,
421+
});
422+
423+
if (resultOrFail.isErr()) {
424+
switch (resultOrFail.error.type) {
425+
case "other":
426+
default: {
427+
resultOrFail.error.type satisfies "other";
428+
429+
logger.error("Failed to update build settings", {
430+
error: resultOrFail.error,
431+
});
432+
return redirectBackWithErrorMessage(request, "Failed to update build settings");
433+
}
434+
}
435+
}
436+
437+
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
438+
}
379439
default: {
380440
submission.value satisfies never;
381441
return redirectBackWithErrorMessage(request, "Failed to process request");
@@ -389,6 +449,7 @@ export default function Page() {
389449
connectedGithubRepository,
390450
githubAppEnabled,
391451
openGitHubRepoConnectionModal,
452+
buildSettings,
392453
} = useTypedLoaderData<typeof loader>();
393454
const project = useProject();
394455
const organization = useOrganization();
@@ -511,22 +572,31 @@ export default function Page() {
511572
</div>
512573

513574
{githubAppEnabled && (
514-
<div>
515-
<Header2 spacing>Git settings</Header2>
516-
<div className="w-full rounded-sm border border-grid-dimmed p-4">
517-
{connectedGithubRepository ? (
518-
<ConnectedGitHubRepoForm connectedGitHubRepo={connectedGithubRepository} />
519-
) : (
520-
<GitHubConnectionPrompt
521-
gitHubAppInstallations={githubAppInstallations ?? []}
522-
organizationSlug={organization.slug}
523-
projectSlug={project.slug}
524-
environmentSlug={environment.slug}
525-
openGitHubRepoConnectionModal={openGitHubRepoConnectionModal}
526-
/>
527-
)}
575+
<React.Fragment>
576+
<div>
577+
<Header2 spacing>Git settings</Header2>
578+
<div className="w-full rounded-sm border border-grid-dimmed p-4">
579+
{connectedGithubRepository ? (
580+
<ConnectedGitHubRepoForm connectedGitHubRepo={connectedGithubRepository} />
581+
) : (
582+
<GitHubConnectionPrompt
583+
gitHubAppInstallations={githubAppInstallations ?? []}
584+
organizationSlug={organization.slug}
585+
projectSlug={project.slug}
586+
environmentSlug={environment.slug}
587+
openGitHubRepoConnectionModal={openGitHubRepoConnectionModal}
588+
/>
589+
)}
590+
</div>
528591
</div>
529-
</div>
592+
593+
<div>
594+
<Header2 spacing>Build settings</Header2>
595+
<div className="w-full rounded-sm border border-grid-dimmed p-4">
596+
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
597+
</div>
598+
</div>
599+
</React.Fragment>
530600
)}
531601

532602
<div>
@@ -1033,3 +1103,115 @@ function ConnectedGitHubRepoForm({
10331103
</>
10341104
);
10351105
}
1106+
1107+
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
1108+
const lastSubmission = useActionData() as any;
1109+
const navigation = useNavigation();
1110+
1111+
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
1112+
const [buildSettingsValues, setBuildSettingsValues] = useState({
1113+
installDirectory: buildSettings?.installDirectory || "",
1114+
installCommand: buildSettings?.installCommand || "",
1115+
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
1116+
});
1117+
1118+
useEffect(() => {
1119+
const hasChanges =
1120+
buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") ||
1121+
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
1122+
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "");
1123+
setHasBuildSettingsChanges(hasChanges);
1124+
}, [buildSettingsValues, buildSettings]);
1125+
1126+
const [buildSettingsForm, fields] = useForm({
1127+
id: "update-build-settings",
1128+
lastSubmission: lastSubmission,
1129+
shouldRevalidate: "onSubmit",
1130+
onValidate({ formData }) {
1131+
return parse(formData, {
1132+
schema: UpdateBuildSettingsFormSchema,
1133+
});
1134+
},
1135+
});
1136+
1137+
const isBuildSettingsLoading =
1138+
navigation.formData?.get("action") === "update-build-settings" &&
1139+
(navigation.state === "submitting" || navigation.state === "loading");
1140+
1141+
return (
1142+
<Form method="post" {...buildSettingsForm.props}>
1143+
<Fieldset>
1144+
<InputGroup fullWidth>
1145+
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
1146+
<Input
1147+
{...conform.input(fields.triggerConfigFilePath, { type: "text" })}
1148+
defaultValue={buildSettings?.triggerConfigFilePath || ""}
1149+
placeholder="trigger.config.ts"
1150+
onChange={(e) => {
1151+
setBuildSettingsValues((prev) => ({
1152+
...prev,
1153+
triggerConfigFilePath: e.target.value,
1154+
}));
1155+
}}
1156+
/>
1157+
<Hint>
1158+
Path to your Trigger configuration file, relative to the root directory of your repo.
1159+
</Hint>
1160+
<FormError id={fields.triggerConfigFilePath.errorId}>
1161+
{fields.triggerConfigFilePath.error}
1162+
</FormError>
1163+
</InputGroup>
1164+
1165+
<InputGroup fullWidth>
1166+
<Label htmlFor={fields.installCommand.id}>Install command</Label>
1167+
<Input
1168+
{...conform.input(fields.installCommand, { type: "text" })}
1169+
defaultValue={buildSettings?.installCommand || ""}
1170+
placeholder="e.g., `npm install`, or `bun install`"
1171+
onChange={(e) => {
1172+
setBuildSettingsValues((prev) => ({
1173+
...prev,
1174+
installCommand: e.target.value,
1175+
}));
1176+
}}
1177+
/>
1178+
<Hint>Command to install your project dependencies. Auto-detected by default.</Hint>
1179+
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
1180+
</InputGroup>
1181+
<InputGroup fullWidth>
1182+
<Label htmlFor={fields.installDirectory.id}>Install directory</Label>
1183+
<Input
1184+
{...conform.input(fields.installDirectory, { type: "text" })}
1185+
defaultValue={buildSettings?.installDirectory || ""}
1186+
placeholder=""
1187+
onChange={(e) => {
1188+
setBuildSettingsValues((prev) => ({
1189+
...prev,
1190+
installDirectory: e.target.value,
1191+
}));
1192+
}}
1193+
/>
1194+
<Hint>The directory where the install command is run in. Auto-detected by default.</Hint>
1195+
<FormError id={fields.installDirectory.errorId}>
1196+
{fields.installDirectory.error}
1197+
</FormError>
1198+
</InputGroup>
1199+
<FormError>{buildSettingsForm.error}</FormError>
1200+
<FormButtons
1201+
confirmButton={
1202+
<Button
1203+
type="submit"
1204+
name="action"
1205+
value="update-build-settings"
1206+
variant="secondary/small"
1207+
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
1208+
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
1209+
>
1210+
Save
1211+
</Button>
1212+
}
1213+
/>
1214+
</Fieldset>
1215+
</Form>
1216+
);
1217+
}

apps/webapp/app/services/projectSettings.server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DeleteProjectService } from "~/services/deleteProject.server";
44
import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github";
55
import { checkGitHubBranchExists } from "~/services/gitHub.server";
66
import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow";
7+
import { BuildSettings } from "~/v3/buildSettings";
78

89
export class ProjectSettingsService {
910
#prismaClient: PrismaClient;
@@ -244,6 +245,23 @@ export class ProjectSettingsService {
244245
.andThen(updateConnectedRepo);
245246
}
246247

248+
updateBuildSettings(projectId: string, buildSettings: BuildSettings) {
249+
return fromPromise(
250+
this.#prismaClient.project.update({
251+
where: {
252+
id: projectId,
253+
},
254+
data: {
255+
buildSettings: buildSettings,
256+
},
257+
}),
258+
(error) => ({
259+
type: "other" as const,
260+
cause: error,
261+
})
262+
);
263+
}
264+
247265
verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) {
248266
const findProject = () =>
249267
fromPromise(

apps/webapp/app/services/projectSettingsPresenter.server.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BranchTrackingConfigSchema } from "~/v3/github";
44
import { env } from "~/env.server";
55
import { findProjectBySlug } from "~/models/project.server";
66
import { err, fromPromise, ok, okAsync } from "neverthrow";
7+
import { BuildSettingsSchema } from "~/v3/buildSettings";
78

89
export class ProjectSettingsPresenter {
910
#prismaClient: PrismaClient;
@@ -15,16 +16,6 @@ export class ProjectSettingsPresenter {
1516
getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) {
1617
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
1718

18-
if (!githubAppEnabled) {
19-
return okAsync({
20-
gitHubApp: {
21-
enabled: false,
22-
connectedRepository: undefined,
23-
installations: undefined,
24-
},
25-
});
26-
}
27-
2819
const getProject = () =>
2920
fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({
3021
type: "other" as const,
@@ -36,6 +27,28 @@ export class ProjectSettingsPresenter {
3627
return ok(project);
3728
});
3829

30+
if (!githubAppEnabled) {
31+
return getProject().andThen((project) => {
32+
if (!project) {
33+
return err({ type: "project_not_found" as const });
34+
}
35+
36+
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
37+
const buildSettings = buildSettingsOrFailure.success
38+
? buildSettingsOrFailure.data
39+
: undefined;
40+
41+
return ok({
42+
gitHubApp: {
43+
enabled: false,
44+
connectedRepository: undefined,
45+
installations: undefined,
46+
},
47+
buildSettings,
48+
});
49+
});
50+
}
51+
3952
const findConnectedGithubRepository = (projectId: string) =>
4053
fromPromise(
4154
this.#prismaClient.connectedGithubRepository.findFirst({
@@ -119,6 +132,11 @@ export class ProjectSettingsPresenter {
119132

120133
return getProject().andThen((project) =>
121134
findConnectedGithubRepository(project.id).andThen((connectedGithubRepository) => {
135+
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
136+
const buildSettings = buildSettingsOrFailure.success
137+
? buildSettingsOrFailure.data
138+
: undefined;
139+
122140
if (connectedGithubRepository) {
123141
return okAsync({
124142
gitHubApp: {
@@ -128,6 +146,7 @@ export class ProjectSettingsPresenter {
128146
// a project can have only a single connected repository
129147
installations: undefined,
130148
},
149+
buildSettings,
131150
});
132151
}
133152

@@ -138,6 +157,7 @@ export class ProjectSettingsPresenter {
138157
connectedRepository: undefined,
139158
installations: githubAppInstallations,
140159
},
160+
buildSettings,
141161
};
142162
});
143163
})

0 commit comments

Comments
 (0)