2
0
Эх сурвалжийг харах

updated build settings form on app creation (#3392)

ianedwards 2 жил өмнө
parent
commit
ceca08b81d

+ 0 - 4
dashboard/src/components/porter/ControlledInput.tsx

@@ -12,7 +12,6 @@ import Tooltip from "./Tooltip";
 export const ControlledInput = React.forwardRef<
   HTMLInputElement,
   {
-    id: string; // id is the id attribute of the input
     name: string; // name is the name attribute of the input
     label?: string; // label is used to render a label above the input. If not provided, no label is rendered
     type: React.HTMLInputTypeAttribute; // type is the type attribute of the input (text, password, etc.)
@@ -30,7 +29,6 @@ export const ControlledInput = React.forwardRef<
 >(
   (
     {
-      id,
       name,
       label,
       type,
@@ -52,7 +50,6 @@ export const ControlledInput = React.forwardRef<
         <Block width={width}>
           {label && <Label>{label}</Label>}
           <StyledInput
-            id={id}
             name={name}
             type={type}
             autoComplete={autoComplete}
@@ -78,7 +75,6 @@ export const ControlledInput = React.forwardRef<
       <Block width={width}>
         {label && <Label>{label}</Label>}
         <StyledInput
-          id={id}
           name={name}
           type={type}
           autoComplete={autoComplete}

+ 6 - 7
dashboard/src/lib/porter-apps/index.ts

@@ -40,7 +40,7 @@ export type SourceOptions = z.infer<typeof sourceValidator>;
 // porterAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
 export const porterAppValidator = z.object({
   name: z.string(),
-  services: z.record(z.string(), serviceValidator),
+  services: serviceValidator.array(),
   env: z.record(z.string(), z.string()),
   build: buildValidator,
   predeploy: serviceValidator.optional(),
@@ -65,11 +65,8 @@ export function porterClientAppFromProto(
   proto: PorterApp,
   buildpacks?: Buildpack[]
 ): ClientPorterApp {
-  const services = Object.fromEntries(
-    Object.entries(proto.services).map(([name, service]) => [
-      name,
-      deserializeService(serializedServiceFromProto(service)),
-    ])
+  const services = Object.entries(proto.services).map(([name, service]) =>
+    deserializeService(serializedServiceFromProto({ name, service }))
   );
 
   const { name, env, build, predeploy, image } = proto;
@@ -108,7 +105,9 @@ export function porterClientAppFromProto(
           },
         }),
     ...(predeploy && {
-      predeploy: deserializeService(serializedServiceFromProto(predeploy)),
+      predeploy: deserializeService(
+        serializedServiceFromProto({ name: "predeploy", service: predeploy })
+      ),
     }),
     image,
   };

+ 30 - 14
dashboard/src/lib/porter-apps/services.ts

@@ -9,6 +9,7 @@ import {
   deserializeHealthCheck,
   serializeAutoscaling,
   serializeHealth,
+  domainsValidator,
 } from "./values";
 import { Service, ServiceType } from "@porter-dev/api-contracts";
 
@@ -67,6 +68,9 @@ export const ServiceField = {
 // serviceValidator is the validator for a ClientService
 // This is used to validate a service when creating or updating an app
 export const serviceValidator = z.object({
+  expanded: z.boolean().default(false).optional(),
+  canDelete: z.boolean().default(true).optional(),
+  name: serviceStringValidator,
   run: serviceStringValidator,
   instances: serviceNumberValidator,
   port: serviceNumberValidator,
@@ -75,17 +79,14 @@ export const serviceValidator = z.object({
   config: z.discriminatedUnion("type", [
     z.object({
       type: z.literal("web"),
-      autoscaling: autoscalingValidator.optional(),
-      domains: z.array(
-        z.object({
-          name: serviceStringValidator,
-        })
-      ),
-      healthCheck: healthcheckValidator.optional(),
+      autoscaling: autoscalingValidator,
+      ingressEnabled: z.boolean().default(false).optional(),
+      domains: domainsValidator,
+      healthCheck: healthcheckValidator,
     }),
     z.object({
       type: z.literal("worker"),
-      autoscaling: autoscalingValidator.optional(),
+      autoscaling: autoscalingValidator,
     }),
     z.object({
       type: z.literal("job"),
@@ -100,6 +101,7 @@ export type ClientService = z.infer<typeof serviceValidator>;
 // SerializedService is just the values of a Service without any override information
 // This is used as an intermediate step to convert a ClientService to a protobuf Service
 export type SerializedService = {
+  name: string;
   run: string;
   instances: number;
   port: number;
@@ -111,12 +113,12 @@ export type SerializedService = {
         domains: {
           name: string;
         }[];
-        autoscaling?: SerializedAutoscaling;
-        healthCheck?: SerializedHealthcheck;
+        autoscaling: SerializedAutoscaling;
+        healthCheck: SerializedHealthcheck;
       }
     | {
         type: "worker";
-        autoscaling?: SerializedAutoscaling;
+        autoscaling: SerializedAutoscaling;
       }
     | {
         type: "job";
@@ -132,6 +134,7 @@ export function serializeService(service: ClientService): SerializedService {
   return match(service.config)
     .with({ type: "web" }, (config) =>
       Object.freeze({
+        name: service.name.value,
         run: service.run.value,
         instances: service.instances.value,
         port: service.port.value,
@@ -151,6 +154,7 @@ export function serializeService(service: ClientService): SerializedService {
     )
     .with({ type: "worker" }, (config) =>
       Object.freeze({
+        name: service.name.value,
         run: service.run.value,
         instances: service.instances.value,
         port: service.port.value,
@@ -166,6 +170,7 @@ export function serializeService(service: ClientService): SerializedService {
     )
     .with({ type: "job" }, (config) =>
       Object.freeze({
+        name: service.name.value,
         run: service.run.value,
         instances: service.instances.value,
         port: service.port.value,
@@ -188,6 +193,7 @@ export function deserializeService(
   override?: SerializedService
 ): ClientService {
   const baseService = {
+    name: ServiceField.string(service.name, override?.name),
     run: ServiceField.string(service.run, override?.run),
     instances: ServiceField.number(service.instances, override?.instances),
     port: ServiceField.number(service.port, override?.port),
@@ -322,9 +328,13 @@ export function serviceProto(service: SerializedService): Service {
 
 // serializedServiceFromProto converts a protobuf Service to a SerializedService
 // This is used as an intermediate step to convert a protobuf Service to a ClientService
-export function serializedServiceFromProto(
-  service: Service
-): SerializedService {
+export function serializedServiceFromProto({
+  service,
+  name,
+}: {
+  service: Service;
+  name: string;
+}): SerializedService {
   const config = service.config;
   if (!config.case) {
     throw new Error("No case found on service config");
@@ -333,20 +343,26 @@ export function serializedServiceFromProto(
   return match(config)
     .with({ case: "webConfig" }, ({ value }) => ({
       ...service,
+      name,
       config: {
         type: "web" as const,
+        autoscaling: value.autoscaling ? value.autoscaling : { enabled: false },
+        healthCheck: value.healthCheck ? value.healthCheck : { enabled: false },
         ...value,
       },
     }))
     .with({ case: "workerConfig" }, ({ value }) => ({
       ...service,
+      name,
       config: {
         type: "worker" as const,
+        autoscaling: value.autoscaling ? value.autoscaling : { enabled: false },
         ...value,
       },
     }))
     .with({ case: "jobConfig" }, ({ value }) => ({
       ...service,
+      name,
       config: {
         type: "job" as const,
         ...value,

+ 57 - 57
dashboard/src/lib/porter-apps/values.ts

@@ -9,104 +9,104 @@ import {
 // Autoscaling
 export const autoscalingValidator = z.object({
   enabled: serviceBooleanValidator,
-  minInstances: serviceNumberValidator,
-  maxInstances: serviceNumberValidator,
-  cpuThresholdPercent: serviceNumberValidator,
-  memoryThresholdPercent: serviceNumberValidator,
+  minInstances: serviceNumberValidator.optional(),
+  maxInstances: serviceNumberValidator.optional(),
+  cpuThresholdPercent: serviceNumberValidator.optional(),
+  memoryThresholdPercent: serviceNumberValidator.optional(),
 });
 export type ClientAutoscaling = z.infer<typeof autoscalingValidator>;
 export type SerializedAutoscaling = {
   enabled: boolean;
-  minInstances: number;
-  maxInstances: number;
-  cpuThresholdPercent: number;
-  memoryThresholdPercent: number;
+  minInstances?: number;
+  maxInstances?: number;
+  cpuThresholdPercent?: number;
+  memoryThresholdPercent?: number;
 };
 
 export function serializeAutoscaling({
   autoscaling,
 }: {
-  autoscaling?: ClientAutoscaling;
-}): SerializedAutoscaling | undefined {
-  return (
-    autoscaling && {
-      enabled: autoscaling.enabled.value,
-      minInstances: autoscaling.minInstances.value,
-      maxInstances: autoscaling.maxInstances.value,
-      cpuThresholdPercent: autoscaling.cpuThresholdPercent.value,
-      memoryThresholdPercent: autoscaling.memoryThresholdPercent.value,
-    }
-  );
+  autoscaling: ClientAutoscaling;
+}): SerializedAutoscaling {
+  return {
+    enabled: autoscaling.enabled.value,
+    minInstances: autoscaling.minInstances?.value,
+    maxInstances: autoscaling.maxInstances?.value,
+    cpuThresholdPercent: autoscaling.cpuThresholdPercent?.value,
+    memoryThresholdPercent: autoscaling.memoryThresholdPercent?.value,
+  };
 }
 
 export function deserializeAutoscaling({
   autoscaling,
   override,
 }: {
-  autoscaling?: SerializedAutoscaling;
+  autoscaling: SerializedAutoscaling;
   override?: SerializedAutoscaling;
-}): ClientAutoscaling | undefined {
-  if (!autoscaling) {
-    return undefined;
-  }
-
+}): ClientAutoscaling {
   return {
     enabled: ServiceField.boolean(autoscaling.enabled, override?.enabled),
-    minInstances: ServiceField.number(
-      autoscaling.minInstances,
-      override?.minInstances
-    ),
-    maxInstances: ServiceField.number(
-      autoscaling.maxInstances,
-      override?.maxInstances
-    ),
-    cpuThresholdPercent: ServiceField.number(
-      autoscaling.cpuThresholdPercent,
-      override?.cpuThresholdPercent
-    ),
-    memoryThresholdPercent: ServiceField.number(
-      autoscaling.memoryThresholdPercent,
-      override?.memoryThresholdPercent
-    ),
+    minInstances: autoscaling.minInstances
+      ? ServiceField.number(autoscaling.minInstances, override?.minInstances)
+      : undefined,
+    maxInstances: autoscaling.maxInstances
+      ? ServiceField.number(autoscaling.maxInstances, override?.maxInstances)
+      : undefined,
+    cpuThresholdPercent: autoscaling.cpuThresholdPercent
+      ? ServiceField.number(
+          autoscaling.cpuThresholdPercent,
+          override?.cpuThresholdPercent
+        )
+      : undefined,
+    memoryThresholdPercent: autoscaling.memoryThresholdPercent
+      ? ServiceField.number(
+          autoscaling.memoryThresholdPercent,
+          override?.memoryThresholdPercent
+        )
+      : undefined,
   };
 }
 
 // Health Check
 export const healthcheckValidator = z.object({
   enabled: serviceBooleanValidator,
-  httpPath: serviceStringValidator,
+  httpPath: serviceStringValidator.optional(),
 });
 export type ClientHealthCheck = z.infer<typeof healthcheckValidator>;
 export type SerializedHealthcheck = {
   enabled: boolean;
-  httpPath: string;
+  httpPath?: string;
 };
 
 export function serializeHealth({
   health,
 }: {
-  health?: ClientHealthCheck;
-}): SerializedHealthcheck | undefined {
-  return (
-    health && {
-      enabled: health.enabled.value,
-      httpPath: health.httpPath.value,
-    }
-  );
+  health: ClientHealthCheck;
+}): SerializedHealthcheck {
+  return {
+    enabled: health.enabled.value,
+    httpPath: health.httpPath?.value,
+  };
 }
 export function deserializeHealthCheck({
   health,
   override,
 }: {
-  health?: SerializedHealthcheck;
+  health: SerializedHealthcheck;
   override?: SerializedHealthcheck;
 }) {
-  if (!health) {
-    return undefined;
-  }
-
   return {
     enabled: ServiceField.boolean(health.enabled, override?.enabled),
-    httpPath: ServiceField.string(health.httpPath, override?.httpPath),
+    httpPath: health.httpPath
+      ? ServiceField.string(health.httpPath, override?.httpPath)
+      : undefined,
   };
 }
+
+// Domains
+export const domainsValidator = z.array(
+  z.object({
+    name: serviceStringValidator,
+  })
+);
+export type ClientDomains = z.infer<typeof domainsValidator>;

+ 18 - 20
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -1,6 +1,7 @@
 import React, { useContext, useEffect } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import web from "assets/web.png";
+import AnimateHeight from "react-animate-height";
 
 import styled from "styled-components";
 import { useForm, Controller, FormProvider } from "react-hook-form";
@@ -16,6 +17,8 @@ import { PorterAppFormData } from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
+import RepoSettings from "./RepoSettings";
+import ImageSettings from "./ImageSettings";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -53,6 +56,7 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
   const name = watch("app.name");
   const source = watch("source");
   const build = watch("app.build");
+  const services = watch("app.services") ?? [];
 
   useEffect(() => {
     if (name) {
@@ -92,9 +96,7 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
                   </Text>
                   <Spacer y={0.5} />
                   <ControlledInput
-                    id={"name"}
                     placeholder="ex: academic-sophon"
-                    autoComplete="off"
                     type="text"
                     {...register("app.name")}
                   />
@@ -126,24 +128,20 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
                       />
                     )}
                   />
-                  {/* todo(ianedwards): add back in the following as form comes together */}
-                  {/* {source?.type ? (
-                    source.type === "github" ? (
-                      <RepoSettings
-                        build={build}
-                        source={source}
-                        projectId={currentProject.id}
-                      />
-                    ) : (
-                      <div></div>
-                    )
-                  ) : null} */}
-                </>,
-                <>
-                  <ErrorText hasError={true}>
-                    Deploying apps in this flow is not supported for your
-                    project. Contact support@porter.run for more information.
-                  </ErrorText>
+                  <AnimateHeight height={source ? "auto" : 0}>
+                    <Spacer y={1} />
+                    {source?.type ? (
+                      source.type === "github" ? (
+                        <RepoSettings
+                          build={build}
+                          source={source}
+                          projectId={currentProject.id}
+                        />
+                      ) : (
+                        <ImageSettings />
+                      )
+                    ) : null}
+                  </AnimateHeight>
                 </>,
                 <>
                   <Button

+ 88 - 0
dashboard/src/main/home/app-dashboard/create-app/ImageSettings.tsx

@@ -0,0 +1,88 @@
+import React from "react";
+import styled from "styled-components";
+
+import { pushFiltered } from "shared/routing";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import ImageSelector from "components/image-selector/ImageSelector";
+import { Controller, useFormContext } from "react-hook-form";
+import { ClientPorterApp, PorterAppFormData } from "lib/porter-apps";
+
+const ImageSettings: React.FC = ({}) => {
+  const { control } = useFormContext<PorterAppFormData>();
+
+  return (
+    <StyledSourceBox>
+
+      <Subtitle>
+        Specify the container image you would like to connect to this template.
+        <Spacer inline width="5px" />
+        <Link
+          hasunderline
+          onClick={() =>
+            pushFiltered({ location, history }, "/integrations/registry", [
+              "project_id",
+            ])
+          }
+        >
+          Manage Docker registries
+        </Link>
+      </Subtitle>
+      <DarkMatter antiHeight="-4px" />
+      {/* // todo(ianedwards): rewrite image selector to be more easily controllable by form */}
+      <Controller
+        name="app.image"
+        control={control}
+        render={({ field: { onChange, value } }) => (
+          <ImageSelector
+            selectedTag={value?.tag || ""}
+            selectedImageUrl={value?.repository || ""}
+            setSelectedImageUrl={(imageUrl) => {
+              onChange((prev: ClientPorterApp["image"]) => ({
+                ...prev,
+                repository: imageUrl,
+              }));
+            }}
+            setSelectedTag={(tag) => {
+              onChange((prev: ClientPorterApp["image"]) => ({
+                ...prev,
+                tag,
+              }));
+            }}
+            forceExpanded={true}
+          />
+        )}
+      />
+
+      <br />
+    </StyledSourceBox>
+  );
+};
+
+export default ImageSettings;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  font-size: 13px;
+  margin-top: 6px;
+  margin-bottom: 25px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;

+ 356 - 0
dashboard/src/main/home/app-dashboard/create-app/RepoSettings.tsx

@@ -0,0 +1,356 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { Controller, useFormContext } from "react-hook-form";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import styled from "styled-components";
+import Input from "components/porter/Input";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Select from "components/porter/Select";
+import AnimateHeight from "react-animate-height";
+import { z } from "zod";
+import {
+  BuildOptions,
+  PorterAppFormData,
+  SourceOptions,
+} from "lib/porter-apps";
+import { BuildMethod } from "../types/porterApp";
+import RepositorySelector from "../build-settings/RepositorySelector";
+import BranchSelector from "../build-settings/BranchSelector";
+import BuildpackSettings from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
+
+type Props = {
+  projectId: number;
+  source: SourceOptions & { type: "github" };
+  build: BuildOptions;
+};
+
+const branchContentsSchema = z
+  .object({
+    path: z.string(),
+    type: z.enum(["file", "dir"]),
+  })
+  .array();
+
+type BranchContents = z.infer<typeof branchContentsSchema>;
+
+const RepoSettings: React.FC<Props> = ({ projectId, source, build }) => {
+  const {
+    watch,
+    control,
+    register,
+    setValue,
+  } = useFormContext<PorterAppFormData>();
+  const [buildView, setBuildView] = useState<BuildMethod>("buildpacks");
+  const [showSettings, setShowSettings] = useState<boolean>(false);
+
+  const repoIsSet = useMemo(() => source.git_repo_name !== "", [
+    source.git_repo_name,
+  ]);
+  const branchIsSet = useMemo(() => source.git_branch !== "", [
+    source.git_branch,
+  ]);
+
+  const { data: branchContents } = useQuery<BranchContents>(
+    ["getBranchContents", projectId, source.git_branch, source.git_repo_name],
+    async () => {
+      const res = await api.getBranchContents(
+        "<token>",
+        { dir: build.context || "./" },
+        {
+          project_id: projectId,
+          git_repo_id: source.git_repo_id,
+          kind: "github",
+          owner: source.git_repo_name.split("/")[0],
+          name: source.git_repo_name.split("/")[1],
+          branch: source.git_branch,
+        }
+      );
+
+      return branchContentsSchema.parse(res.data);
+    },
+    {
+      enabled: repoIsSet && branchIsSet,
+    }
+  );
+
+  useEffect(() => {
+    if (!branchContents) {
+      return;
+    }
+
+    const hasDockerfile = branchContents.some((item) =>
+      item.path.includes("Dockerfile")
+    );
+    setBuildView(hasDockerfile ? "docker" : "buildpacks");
+  }, [branchContents]);
+
+  return (
+    <div>
+      <Text size={16}>Build settings</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">Specify your GitHub repository.</Text>
+      <Spacer y={0.5} />
+
+      {!source.git_repo_name && (
+        <Controller
+          name="source.git_repo_name"
+          control={control}
+          render={({ field: { onChange } }) => (
+            <>
+              <ExpandedWrapper>
+                <RepositorySelector
+                  readOnly={false}
+                  updatePorterApp={(pa) => {
+                    onChange(pa.repo_name);
+                    setValue(
+                      "source.git_repo_id",
+                      pa.git_repo_id ? pa.git_repo_id : 0
+                    );
+                  }}
+                  git_repo_name={source.git_repo_name}
+                />
+              </ExpandedWrapper>
+              <DarkMatter antiHeight="-4px" />
+              <Spacer y={0.3} />
+            </>
+          )}
+        />
+      )}
+
+      {!!source.git_repo_name && (
+        <>
+          <Input
+            disabled={true}
+            label="GitHub repository:"
+            width="100%"
+            value={source.git_repo_name}
+            setValue={() => {}}
+            placeholder=""
+          />
+          <BackButton
+            width="135px"
+            onClick={() => {
+              setValue("source", {
+                type: "github",
+                git_repo_name: "",
+                git_branch: "",
+                git_repo_id: 0,
+                porter_yaml_path: "./porter.yaml",
+              });
+
+              setValue("app.build.context", "./");
+            }}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select repo
+          </BackButton>
+          <Spacer y={0.5} />
+          <Spacer y={0.5} />
+          <Text color="helper">Specify your GitHub branch.</Text>
+          <Spacer y={0.5} />
+          {!source.git_branch && (
+            <Controller
+              name="source.git_branch"
+              control={control}
+              render={({ field: { onChange } }) => (
+                <ExpandedWrapper>
+                  <BranchSelector
+                    setBranch={(branch: string) => onChange(branch)}
+                    repo_name={source.git_repo_name}
+                    git_repo_id={source.git_repo_id}
+                  />
+                </ExpandedWrapper>
+              )}
+            />
+          )}
+          {!!source.git_branch && (
+            <>
+              <Input
+                disabled={true}
+                label="GitHub branch:"
+                type="text"
+                width="100%"
+                value={source.git_branch}
+                setValue={() => {}}
+                placeholder=""
+              />
+              <BackButton
+                width="145px"
+                onClick={() => {
+                  setValue("source", {
+                    ...source,
+                    git_branch: "",
+                    porter_yaml_path: "./porter.yaml",
+                  });
+
+                  setValue("app.build.context", "./");
+                }}
+              >
+                <i className="material-icons">keyboard_backspace</i>
+                Select branch
+              </BackButton>
+              <Spacer y={1} />
+              <Text color="helper">Specify your application root path.</Text>
+              <Spacer y={0.5} />
+              <ControlledInput
+                placeholder="ex: ./"
+                width="100%"
+                type="text"
+                {...register("app.build.context")}
+              />
+              <Spacer y={1} />
+              <StyledAdvancedBuildSettings
+                showSettings={showSettings}
+                isCurrent={true}
+                onClick={() => {
+                  setShowSettings(!showSettings);
+                }}
+              >
+                {buildView == "docker" ? (
+                  <AdvancedBuildTitle>
+                    <i className="material-icons dropdown">arrow_drop_down</i>
+                    Configure Dockerfile settings
+                  </AdvancedBuildTitle>
+                ) : (
+                  <AdvancedBuildTitle>
+                    <i className="material-icons dropdown">arrow_drop_down</i>
+                    Configure buildpack settings
+                  </AdvancedBuildTitle>
+                )}
+              </StyledAdvancedBuildSettings>
+
+              <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
+                <StyledSourceBox>
+                  <Select
+                    value={buildView}
+                    width="300px"
+                    options={[
+                      { value: "docker", label: "Docker" },
+                      { value: "buildpacks", label: "Buildpacks" },
+                    ]}
+                    setValue={(option: string) =>
+                      setBuildView(option as BuildMethod)
+                    }
+                    label="Build method"
+                  />
+                  {buildView === "docker" ? (
+                    <>
+                      <Spacer y={0.5} />
+                      <Text color="helper">
+                        Dockerfile path (absolute path)
+                      </Text>
+                      <Spacer y={0.5} />
+                      <ControlledInput
+                        width="300px"
+                        placeholder="ex: ./Dockerfile"
+                        type="text"
+                        {...register("app.build.dockerfile")}
+                      />
+                      <Spacer y={0.5} />
+                    </>
+                  ) : (
+                    <BuildpackSettings
+                      projectId={projectId}
+                      build={build}
+                      source={source}
+                    />
+                  )}
+                </StyledSourceBox>
+              </AnimateHeight>
+            </>
+          )}
+        </>
+      )}
+    </div>
+  );
+};
+
+export default RepoSettings;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  max-height: 275px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const StyledAdvancedBuildSettings = styled.div`
+  color: ${({ showSettings }) => (showSettings ? "white" : "#aaaabb")};
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+    color: white;
+  }
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-radius: 5px;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  border-bottom-left-radius: ${({ showSettings }) => showSettings && "0px"};
+  border-bottom-right-radius: ${({ showSettings }) => showSettings && "0px"};
+  .dropdown {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showSettings: boolean; isCurrent: boolean }) =>
+      props.showSettings ? "" : "rotate(-90deg)"};
+  }
+`;
+
+const AdvancedBuildTitle = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 25px 35px 25px;
+  position: relative;
+  font-size: 13px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  border-top: 0px;
+  border-top-left-radius: 0px;
+  border-top-right-radius: 0px;
+`;

+ 148 - 0
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/AddCustomBuildpack.tsx

@@ -0,0 +1,148 @@
+import InputRow from "components/form-components/InputRow";
+import { Buildpack } from "main/home/app-dashboard/types/buildpack";
+import React, { useState } from "react";
+import styled, { keyframes } from "styled-components";
+
+function isValidURL(url: string): boolean {
+  const pattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(:\d{2,5})?([\/\w.-]*)*\/?$/i;
+  return pattern.test(url);
+}
+
+const AddCustomBuildpack: React.FC<{
+  onAdd: (buildpack: Buildpack) => void;
+}> = ({ onAdd }) => {
+  const [buildpackUrl, setBuildpackUrl] = useState("");
+  const [error, setError] = useState(false);
+
+  const handleAddCustomBuildpack = () => {
+    if (buildpackUrl === "" || !isValidURL(buildpackUrl)) {
+      setError(true);
+      return;
+    }
+    setBuildpackUrl("");
+    onAdd({
+      buildpack: buildpackUrl,
+      name: buildpackUrl,
+      config: {},
+    });
+  };
+
+  return (
+    <StyledCard marginBottom="0px">
+      <ContentContainer>
+        <EventInformation>
+          <BuildpackInputContainer>
+            GitHub or ZIP URL
+            <BuildpackUrlInput
+              placeholder="https://github.com/custom/buildpack"
+              type="input"
+              value={buildpackUrl}
+              isRequired
+              setValue={(newUrl) => {
+                setError(false);
+                setBuildpackUrl(newUrl as string);
+              }}
+            />
+            <ErrorText hasError={error}>Please enter a valid url</ErrorText>
+          </BuildpackInputContainer>
+        </EventInformation>
+      </ContentContainer>
+      <ActionContainer>
+        <ActionButton onClick={() => handleAddCustomBuildpack()}>
+          <span className="material-icons-outlined">add</span>
+        </ActionButton>
+      </ActionContainer>
+    </StyledCard>
+  );
+};
+
+export default AddCustomBuildpack;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div<{ marginBottom?: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #494b4f;
+  background: ${({ theme }) => theme.fg};
+  margin-bottom: ${(props) => props.marginBottom || "30px"};
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const BuildpackInputContainer = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  padding-left: 15px;
+`;
+
+const BuildpackUrlInput = styled(InputRow)`
+  width: auto;
+  min-width: 300px;
+  max-width: 600px;
+  margin: unset;
+  margin-left: 10px;
+  display: inline-block;
+`;
+
+const ErrorText = styled.span`
+  color: red;
+  margin-left: 10px;
+  display: ${(props: { hasError: boolean }) =>
+    props.hasError ? "inline-block" : "none"};
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+  > span {
+    font-size: 20px;
+  }
+`;

+ 157 - 0
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackCard.tsx

@@ -0,0 +1,157 @@
+import React from "react";
+import { DeviconsNameList } from "assets/devicons-name-list";
+import styled, { keyframes } from "styled-components";
+import { Draggable } from "react-beautiful-dnd";
+import { Buildpack } from "main/home/app-dashboard/types/buildpack";
+
+interface Props {
+  buildpack: Buildpack;
+  action: "add" | "remove";
+  onClickFn: (buildpack: string) => void;
+  index: number;
+  draggable: boolean;
+}
+
+const BuildpackCard: React.FC<Props> = ({
+  buildpack,
+  action,
+  onClickFn,
+  index,
+  draggable,
+}) => {
+  const [languageName] = buildpack.name?.split("/").reverse();
+
+  const devicon = DeviconsNameList.find(
+    (devicon) => languageName.toLowerCase() === devicon.name
+  );
+
+  const icon = `devicon-${devicon?.name}-plain colored`;
+
+  return draggable ? (
+    <Draggable draggableId={buildpack.name} index={index} key={buildpack.name}>
+      {(provided) => (
+        <StyledCard
+          marginBottom="5px"
+          {...provided.draggableProps}
+          {...provided.dragHandleProps}
+          ref={provided.innerRef}
+          key={buildpack.name}
+        >
+          <ContentContainer>
+            <Icon disableMarginRight={devicon == null} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            <ActionButton onClick={() => onClickFn(buildpack.buildpack)}>
+              <span className="material-icons">
+                {action === "remove" ? "delete" : "add"}
+              </span>
+            </ActionButton>
+          </ActionContainer>
+        </StyledCard>
+      )}
+    </Draggable>
+  ) : (
+    <StyledCard marginBottom="5px" key={buildpack.name}>
+      <ContentContainer>
+        <Icon disableMarginRight={devicon == null} className={icon} />
+        <EventInformation>
+          <EventName>{buildpack?.name}</EventName>
+        </EventInformation>
+      </ContentContainer>
+      <ActionContainer>
+        <ActionButton onClick={() => onClickFn(buildpack.buildpack)}>
+          <span className="material-icons">
+            {action === "remove" ? "delete" : "add"}
+          </span>
+        </ActionButton>
+      </ActionContainer>
+    </StyledCard>
+  );
+};
+
+export default BuildpackCard;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div<{ marginBottom?: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #494b4f;
+  background: ${({ theme }) => theme.fg};
+  margin-bottom: ${(props) => props.marginBottom || "30px"};
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+  > span {
+    font-size: 20px;
+  }
+`;

+ 151 - 0
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackConfigurationModal.tsx

@@ -0,0 +1,151 @@
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React from "react";
+import BuildpackList from "./BuildpackList";
+import AddCustomBuildpackComponent from "./AddCustomBuildpack";
+import Icon from "components/porter/Icon";
+import Button from "components/porter/Button";
+import Modal from "components/porter/Modal";
+import styled from "styled-components";
+import Select from "components/porter/Select";
+import stars from "assets/stars-white.svg";
+import { Buildpack } from "main/home/app-dashboard/types/buildpack";
+import { Controller, useFieldArray, useFormContext } from "react-hook-form";
+import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
+
+type Props = {
+  build: BuildOptions;
+  closeModal: () => void;
+  sortedStackOptions: { value: string; label: string }[];
+  availableBuildpacks: Buildpack[];
+  setAvailableBuildpacks: (buildpacks: Buildpack[]) => void;
+  isDetectingBuildpacks: boolean;
+  detectBuildpacksError: string;
+  detectAndSetBuildPacks: () => void;
+};
+
+const BuildpackConfigurationModal: React.FC<Props> = ({
+  build,
+  closeModal,
+  sortedStackOptions,
+  availableBuildpacks,
+  setAvailableBuildpacks,
+  isDetectingBuildpacks,
+  detectBuildpacksError,
+  detectAndSetBuildPacks,
+}) => {
+  const { control } = useFormContext<PorterAppFormData>();
+  const { append } = useFieldArray({
+    control,
+    name: "app.build.buildpacks",
+  });
+
+  return (
+    <Modal closeModal={closeModal}>
+      <Text size={16}>Buildpack Configuration</Text>
+      <Spacer y={1} />
+      <Scrollable>
+        <Text>Builder:</Text>
+        {!build.builder && (
+          <>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              No builder detected. Click 'Detect buildpacks' below to scan your
+              repository for available builders and buildpacks.
+            </Text>
+          </>
+        )}
+        {!!build.builder && (
+          <Controller
+            control={control}
+            name="app.build.builder"
+            render={({ field: { onChange } }) => (
+              <>
+                <Spacer y={0.5} />
+                <Select
+                  value={build.builder}
+                  width="300px"
+                  options={sortedStackOptions}
+                  setValue={(val) => {
+                    onChange(val);
+                  }}
+                />
+              </>
+            )}
+          />
+        )}
+        <BuildpackList
+          build={build}
+          availableBuildpacks={availableBuildpacks}
+          setAvailableBuildpacks={setAvailableBuildpacks}
+          showAvailableBuildpacks={true}
+          isDetectingBuildpacks={isDetectingBuildpacks}
+          detectBuildpacksError={detectBuildpacksError}
+          droppableId={"modal"}
+        />
+        <Spacer y={0.5} />
+        <Text>Custom buildpacks</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Text>
+        <Spacer y={1} />
+        <AddCustomBuildpackComponent
+          onAdd={(bp) => {
+            append(bp);
+          }}
+        />
+        <Spacer y={2} />
+      </Scrollable>
+      <Footer>
+        <Shade />
+        <FooterButtons>
+          <Button onClick={() => detectAndSetBuildPacks()}>
+            <Icon src={stars} height="15px" />
+            <Spacer inline x={0.5} />
+            Detect buildpacks
+          </Button>
+          <Button onClick={closeModal} width={"75px"}>
+            Close
+          </Button>
+        </FooterButtons>
+      </Footer>
+    </Modal>
+  );
+};
+export default BuildpackConfigurationModal;
+
+const Scrollable = styled.div`
+  overflow-y: auto;
+  padding: 0 25px;
+  width: calc(100% + 50px);
+  margin-left: -25px;
+  max-height: calc(100vh - 300px);
+`;
+
+const FooterButtons = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;
+
+const Footer = styled.div`
+  position: relative;
+  width: calc(100% + 50px);
+  margin-left: -25px;
+  padding: 0 25px;
+  border-bottom-left-radius: 10px;
+  border-bottom-right-radius: 10px;
+  background: ${({ theme }) => theme.fg};
+  margin-bottom: -30px;
+  padding-bottom: 30px;
+`;
+
+const Shade = styled.div`
+  position: absolute;
+  top: -50px;
+  left: 0;
+  height: 50px;
+  width: 100%;
+  background: linear-gradient(to bottom, #00000000, ${({ theme }) => theme.fg});
+`;

+ 145 - 0
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackList.tsx

@@ -0,0 +1,145 @@
+import React from "react";
+import BuildpackCard from "./BuildpackCard";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Loading from "components/Loading";
+import Error from "components/porter/Error";
+import { Droppable, DragDropContext } from "react-beautiful-dnd";
+import { Buildpack } from "main/home/app-dashboard/types/buildpack";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
+
+interface Props {
+  build: BuildOptions;
+  availableBuildpacks: Buildpack[];
+  setAvailableBuildpacks: (buildpacks: Buildpack[]) => void;
+  showAvailableBuildpacks: boolean;
+  isDetectingBuildpacks: boolean;
+  detectBuildpacksError: string;
+  droppableId: string;
+}
+const BuildpackList: React.FC<Props> = ({
+  build,
+  availableBuildpacks,
+  setAvailableBuildpacks,
+  showAvailableBuildpacks,
+  isDetectingBuildpacks,
+  detectBuildpacksError,
+  droppableId,
+}) => {
+  const { control } = useFormContext<PorterAppFormData>();
+  const { remove, append, swap } = useFieldArray({
+    control,
+    name: "app.build.buildpacks",
+  });
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    const bpIdx = build.buildpacks.findIndex(
+      (bp) => bp.buildpack === buildpackToRemove
+    );
+    const buildpack = build.buildpacks[bpIdx];
+    if (bpIdx !== -1) {
+      remove(bpIdx);
+      if (buildpack) {
+        setAvailableBuildpacks([...availableBuildpacks, buildpack]);
+      }
+    }
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    const buildpackAdded = build.buildpacks.some(
+      (bp) => bp.buildpack === buildpackToAdd
+    );
+    const buildpack = availableBuildpacks.find(
+      (bp) => bp.buildpack === buildpackToAdd
+    );
+    if (!buildpackAdded && buildpack) {
+      append(buildpack);
+      setAvailableBuildpacks(
+        availableBuildpacks.filter((bp) => bp.buildpack !== buildpackToAdd)
+      );
+    }
+  };
+
+  const onDragEnd = (result: any) => {
+    if (!result.destination) {
+      return;
+    }
+
+    const items = Array.from(build.buildpacks);
+    const [reorderedItem] = items.splice(result.source.index, 1);
+    items.splice(result.destination.index, 0, reorderedItem);
+    swap(result.source.index, result.destination.index);
+  };
+
+  const renderAvailableBuildpacks = () => {
+    if (isDetectingBuildpacks) {
+      return <Loading />;
+    }
+
+    if (detectBuildpacksError) {
+      return <Error message={detectBuildpacksError} />;
+    }
+
+    if (availableBuildpacks.length > 0) {
+      return availableBuildpacks.map((buildpack, index) => {
+        return (
+          <BuildpackCard
+            buildpack={buildpack}
+            action={"add"}
+            onClickFn={handleAddBuildpack}
+            index={index}
+            draggable={false}
+            key={`${buildpack.name}-${index}-available`}
+          />
+        );
+      });
+    }
+
+    return <Text color="helper">No available buildpacks detected.</Text>;
+  };
+
+  return (
+    <DragDropContext onDragEnd={onDragEnd}>
+      {showAvailableBuildpacks && (
+        <>
+          <Spacer y={0.5} />
+          <Text>Selected buildpacks:</Text>
+          <Spacer y={0.5} />
+        </>
+      )}
+      {build.buildpacks.length !== 0 && (
+        <Droppable droppableId={droppableId}>
+          {(provided) => (
+            <div {...provided.droppableProps} ref={provided.innerRef}>
+              {build.buildpacks.map((buildpack, index) => (
+                <BuildpackCard
+                  buildpack={buildpack}
+                  action={"remove"}
+                  onClickFn={handleRemoveBuildpack}
+                  index={index}
+                  draggable={true}
+                  key={`${buildpack.name}-${index}-selected`}
+                />
+              ))}
+              {provided.placeholder}
+            </div>
+          )}
+        </Droppable>
+      )}
+      {build.buildpacks.length === 0 && (
+        <Text color="helper">No buildpacks selected.</Text>
+      )}
+      {showAvailableBuildpacks && (
+        <>
+          <Spacer y={0.5} />
+          <Text>Available buildpacks:</Text>
+          <Spacer y={0.5} />
+          {renderAvailableBuildpacks()}
+        </>
+      )}
+    </DragDropContext>
+  );
+};
+
+export default BuildpackList;

+ 247 - 0
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -0,0 +1,247 @@
+import React, { useEffect, useMemo, useState } from "react";
+import styled, { keyframes } from "styled-components";
+import Helper from "components/form-components/Helper";
+import Error from "components/porter/Error";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import {
+  BUILDPACK_TO_NAME,
+  Buildpack,
+  DEFAULT_BUILDER_NAME,
+  DEFAULT_HEROKU_STACK,
+  DetectedBuildpack,
+  detectedBuildpackSchema,
+} from "main/home/app-dashboard/types/buildpack";
+import { z } from "zod";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
+import BuildpackList from "./BuildpackList";
+import BuildpackConfigurationModal from "./BuildpackConfigurationModal";
+import { useFieldArray, useFormContext } from "react-hook-form";
+import { BuildOptions, PorterAppFormData, SourceOptions } from "lib/porter-apps";
+
+type Props = {
+  projectId: number;
+  build: BuildOptions;
+  source: SourceOptions & { type: "github" };
+  autoDetectionDisabled?: boolean;
+};
+
+const BuildpackSettings: React.FC<Props> = ({
+  projectId,
+  build,
+  source,
+  autoDetectionDisabled,
+}) => {
+  const [stackOptions, setStackOptions] = useState<
+    { label: string; value: string }[]
+  >([]);
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+  const { control, setValue } = useFormContext<PorterAppFormData>();
+  const { replace } = useFieldArray({
+    control,
+    name: "app.build.buildpacks",
+  });
+
+  const { data, status, refetch } = useQuery(
+    [
+      "detectBuildpacks",
+      projectId,
+      source.git_repo_name,
+      source.git_branch,
+      build.context,
+    ],
+    async () => {
+      const detectBuildPackRes = await api.detectBuildpack<DetectedBuildpack[]>(
+        "<token>",
+        {
+          dir: build.context || ".",
+        },
+        {
+          project_id: projectId,
+          git_repo_id: source.git_repo_id,
+          kind: "github",
+          owner: source.git_repo_name.split("/")[0],
+          name: source.git_repo_name.split("/")[1],
+          branch: source.git_branch,
+        }
+      );
+
+      console.log("detectBuildPackRes", detectBuildPackRes);
+
+      const detectedBuildpacks = z
+        .array(detectedBuildpackSchema)
+        .parseAsync(detectBuildPackRes.data);
+
+      return detectedBuildpacks;
+    },
+    {
+      enabled: !autoDetectionDisabled,
+    }
+  );
+
+  console.log("data", data);
+  console.log("status", status);
+
+  const errorMessage = useMemo(
+    () =>
+      status === "error"
+        ? `Unable to detect buildpacks at path: ${build.context}. Please make sure your repo, branch, and application root path are all set correctly and attempt to detect again.`
+        : "",
+    [build.context]
+  );
+
+  useEffect(() => {
+    if (autoDetectionDisabled) {
+      // in this case, we are not detecting buildpacks, so we just populate based on the DB
+      if (build.builder) {
+        setValue("app.build.builder", build.builder);
+        setStackOptions([{ label: build.builder, value: build.builder }]);
+      }
+      if (build.buildpacks.length) {
+        const bps = build.buildpacks.map((bp) => ({
+          ...bp,
+          name: BUILDPACK_TO_NAME[bp.buildpack] ?? bp,
+        }));
+        replace(bps);
+      }
+    } else {
+      if (!data) {
+        return;
+      }
+
+      if (data.length === 0) {
+        return;
+      }
+      setStackOptions(
+        data
+          .flatMap((builder) => {
+            return builder.builders.map((stack) => ({
+              label: `${builder.name} - ${stack}`,
+              value: stack.toLowerCase(),
+            }));
+          })
+          .sort((a, b) => {
+            if (a.label < b.label) {
+              return -1;
+            }
+            if (a.label > b.label) {
+              return 1;
+            }
+            return 0;
+          })
+      );
+
+      const defaultBuilder =
+        data.find(
+          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
+        ) ?? data[0];
+
+      const allBuildpacks = defaultBuilder.others.concat(
+        defaultBuilder.detected
+      );
+
+      let detectedBuilder: string;
+      if (
+        defaultBuilder.builders.length &&
+        defaultBuilder.builders.includes(DEFAULT_HEROKU_STACK)
+      ) {
+        setValue("app.build.builder", DEFAULT_HEROKU_STACK);
+        detectedBuilder = DEFAULT_HEROKU_STACK;
+      } else {
+        setValue("app.build.builder", defaultBuilder.builders[0]);
+        detectedBuilder = defaultBuilder.builders[0];
+      }
+
+      if (!autoDetectionDisabled) {
+        setValue("app.build.builder", detectedBuilder);
+        replace(defaultBuilder.detected);
+        setAvailableBuildpacks(defaultBuilder.others);
+      } else {
+        setValue("app.build.builder", detectedBuilder);
+        setAvailableBuildpacks(
+          allBuildpacks.filter(
+            (bp) => !build.buildpacks.some((b) => b.buildpack === bp.buildpack)
+          )
+        );
+      }
+    }
+  }, [data]);
+
+  return (
+    <BuildpackConfigurationContainer>
+      {build.buildpacks.length > 0 && (
+        <>
+          <Helper>
+            The following buildpacks were automatically detected. You can also
+            manually add, remove, or re-order buildpacks here.
+          </Helper>
+          <BuildpackList
+            build={build}
+            availableBuildpacks={availableBuildpacks}
+            setAvailableBuildpacks={setAvailableBuildpacks}
+            showAvailableBuildpacks={false}
+            isDetectingBuildpacks={status === "loading"}
+            detectBuildpacksError={errorMessage}
+            droppableId={"non-modal"}
+          />
+        </>
+      )}
+      {!autoDetectionDisabled && status === "error" && (
+        <>
+          <Spacer y={1} />
+          <Error
+            message={`Unable to detect buildpacks at path: ${build.context}. Please make sure your repo, branch, and application root path are all set correctly and attempt to detect again.`}
+          />
+        </>
+      )}
+      <Spacer y={1} />
+      <Button
+        onClick={() => {
+          setIsModalOpen(true);
+        }}
+      >
+        <I className="material-icons">add</I> Add / detect buildpacks
+      </Button>
+      {isModalOpen && (
+        <BuildpackConfigurationModal
+          build={build}
+          closeModal={() => setIsModalOpen(false)}
+          sortedStackOptions={stackOptions}
+          availableBuildpacks={availableBuildpacks}
+          setAvailableBuildpacks={setAvailableBuildpacks}
+          isDetectingBuildpacks={status === "loading"}
+          detectBuildpacksError={errorMessage}
+          detectAndSetBuildPacks={refetch}
+        />
+      )}
+    </BuildpackConfigurationContainer>
+  );
+};
+
+export default BuildpackSettings;
+
+const I = styled.i`
+  color: white;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  margin-right: 5px;
+  justify-content: center;
+`;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;

+ 1 - 0
go.work.sum

@@ -253,6 +253,7 @@ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
+github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ=
 github.com/nats-io/nats.go v1.9.1 h1:ik3HbLhZ0YABLto7iX80pZLPw/6dx3T+++MZJwLnMrQ=
 github.com/nats-io/nkeys v0.1.0 h1:qMd4+pRHgdr1nAClu+2h/2a5F2TmKcCzjCDazVgRoX4=