소스 검색

Merge pull request #2241 from porter-dev/nico/por-601-fix-buildpack-settings-getting-blocked

[POR-601] Fix buildpack settings getting blocked
abelanger5 3 년 전
부모
커밋
1e87bb3e56

+ 11 - 1
api/server/handlers/release/update_git_action_config.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"gorm.io/gorm"
+	"helm.sh/helm/v3/pkg/release"
 )
 
 type UpdateGitActionConfigHandler struct {
@@ -27,7 +28,7 @@ func NewUpdateGitActionConfigHandler(
 }
 
 func (c *UpdateGitActionConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	release, _ := r.Context().Value(types.ReleaseScope).(*models.Release)
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 
 	request := &types.UpdateGitActionConfigRequest{}
 
@@ -35,6 +36,15 @@ func (c *UpdateGitActionConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
+	// look up the release in the database; if not found, do not populate Porter fields
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	actionConfig, err := c.Repo().GitActionConfig().ReadGitActionConfig(release.GitActionConfig.ID)
 
 	if err != nil {

+ 1 - 1
api/types/release.go

@@ -197,7 +197,7 @@ type PatchUpdateReleaseTags struct {
 type PartialGitActionConfig struct {
 	// The branch to use for the git repository
 	// required: true
-	GitBranch string `json:"branch" form:"required"`
+	GitBranch string `json:"git_branch" form:"required"`
 }
 
 type UpdateGitActionConfigRequest struct {

+ 60 - 11
dashboard/src/components/repo-selector/BranchList.tsx

@@ -113,9 +113,12 @@ const BranchList: React.FC<Props> = ({
         >
           <img src={branch_icon} alt={"branch icon"} />
           {branch}
-          {currentBranch === branch && (
-            <i className="material-icons-outlined">check</i>
-          )}
+          <div>
+            <span>{actionConfig.git_branch === branch ? "Current" : ""}</span>
+            {currentBranch === branch && (
+              <i className="material-icons-outlined">check</i>
+            )}
+          </div>
         </BranchName>
       );
     });
@@ -129,6 +132,19 @@ const BranchList: React.FC<Props> = ({
         prompt={"Search branches..."}
       />
       <BranchListWrapper>
+        {actionConfig.git_branch && actionConfig.git_branch !== currentBranch && (
+          <WarningRow lastItem={false} disabled>
+            <i className="material-icons-round">warning</i>
+            <span>
+              You have unsaved changes. Please click save to commit your
+              changes.
+              <p>
+                Current Branch: <b>{actionConfig.git_branch}</b>. New branch:{" "}
+                <b>{currentBranch}</b>
+              </p>
+            </span>
+          </WarningRow>
+        )}
         <ExpandedWrapper>{renderBranchList()}</ExpandedWrapper>
       </BranchListWrapper>
     </>
@@ -137,21 +153,20 @@ const BranchList: React.FC<Props> = ({
 
 export default BranchList;
 
-const BranchName = styled.div`
+const BranchName = styled.div<{ lastItem: boolean; disabled?: boolean }>`
   display: flex;
   width: 100%;
   font-size: 13px;
   border-bottom: 1px solid
-    ${(props: { lastItem: boolean }) =>
-      props.lastItem ? "#00000000" : "#606166"};
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
-  cursor: pointer;
+  cursor: ${(props) => (props.disabled ? "default" : "pointer")};
   background: #ffffff11;
   :hover {
-    background: #ffffff22;
+    background: ${(props) => (props.disabled ? "#ffffff11" : "#ffffff22")};
   }
 
   > img {
@@ -160,12 +175,45 @@ const BranchName = styled.div`
     margin-left: 12px;
     margin-right: 12px;
   }
+  > div {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+
+    > span {
+      text-transform: capitalize;
+
+      :last-child {
+        margin-right: 15px;
+      }
+    }
+
+    > i {
+      margin-left: 10px;
+      margin-right: 15px;
+      font-size: 18px;
+      color: #03b503;
+    }
+  }
+`;
+
+const WarningRow = styled(BranchName)`
+  background: #3d3f43;
+  color: #f4ca42;
+  position: sticky;
+  top: 0;
+  animation: fadeIn 0.5s ease-in-out;
 
   > i {
-    margin-left: auto;
-    margin-right: 15px;
     font-size: 18px;
-    color: #03b503;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+
+  p {
+    margin: 0px;
+    margin-top: 5px;
+    color: #ffffff;
   }
 `;
 
@@ -183,6 +231,7 @@ const BranchListWrapper = styled.div`
   border: 1px solid #ffffff55;
   border-radius: 3px;
   overflow-y: auto;
+  position: relative;
 `;
 
 const ExpandedWrapper = styled.div`

+ 0 - 994
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -1,994 +0,0 @@
-import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
-import KeyValueArray from "components/form-components/KeyValueArray";
-import SelectRow from "components/form-components/SelectRow";
-import Loading from "components/Loading";
-import MultiSaveButton from "components/MultiSaveButton";
-import _, { differenceBy, unionBy } from "lodash";
-import React, {
-  forwardRef,
-  useContext,
-  useEffect,
-  useImperativeHandle,
-  useMemo,
-  useRef,
-  useState,
-} from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import {
-  BuildConfig,
-  ChartTypeWithExtendedConfig,
-  FullActionConfigType,
-} from "shared/types";
-import styled, { keyframes } from "styled-components";
-import yaml from "js-yaml";
-import { AxiosError } from "axios";
-import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
-import { DeviconsNameList } from "assets/devicons-name-list";
-import Selector from "components/Selector";
-import BranchList from "components/repo-selector/BranchList";
-import Banner from "components/Banner";
-
-type Buildpack = {
-  name: string;
-  buildpack: string;
-  config: {
-    [key: string]: string;
-  };
-};
-
-type DetectedBuildpack = {
-  name: string;
-  builders: string[];
-  detected: Buildpack[];
-  others: Buildpack[];
-};
-
-type DetectBuildpackResponse = DetectedBuildpack[];
-
-type UpdateBuildconfigResponse = {
-  CreatedAt: string;
-  DeletedAt: { Time: string; Valid: boolean };
-  Time: string;
-  Valid: boolean;
-  ID: number;
-  UpdatedAt: string;
-  builder: string;
-  buildpacks: string;
-  config: string;
-  name: string;
-};
-
-type Props = {
-  chart: ChartTypeWithExtendedConfig;
-  isPreviousVersion: boolean;
-};
-
-const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
-  const { currentCluster, currentProject, setCurrentError } = useContext(
-    Context
-  );
-
-  const [envVariables, setEnvVariables] = useState(
-    chart.config?.container?.env?.build || null
-  );
-  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
-  const [reRunError, setReRunError] = useState<{
-    title: string;
-    description: string;
-  }>(null);
-  const [buttonStatus, setButtonStatus] = useState<
-    "loading" | "successful" | string
-  >("");
-
-  const [currentBranch, setCurrentBranch] = useState(
-    () => chart?.git_action_config?.git_branch
-  );
-
-  const buildpackConfigRef = useRef<{
-    isLoading: boolean;
-    getBuildConfig: () => BuildConfig;
-  }>(null);
-
-  const saveNewBranch = async (newBranch: string) => {
-    if (!newBranch?.length) {
-      return;
-    }
-
-    if (newBranch === chart?.git_action_config?.git_branch) {
-      return;
-    }
-
-    const newGitActionConfig: FullActionConfigType = {
-      ...chart.git_action_config,
-      git_branch: newBranch,
-    };
-
-    try {
-      api.updateGitActionConfig(
-        "<token>",
-        {
-          git_action_config: newGitActionConfig,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          release_name: chart.name,
-          namespace: chart.namespace,
-        }
-      );
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const saveBuildConfig = async (config: BuildConfig) => {
-    console.log({ config });
-    if (config === null) {
-      return;
-    }
-
-    try {
-      await api.updateBuildConfig<UpdateBuildconfigResponse>(
-        "<token>",
-        { ...config },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          namespace: chart.namespace,
-          release_name: chart.name,
-        }
-      );
-    } catch (err) {
-      throw err;
-    }
-  };
-
-  const saveEnvVariables = async (envs: { [key: string]: string }) => {
-    let values = { ...chart.config };
-    if (envs === null) {
-      return;
-    }
-
-    values.container.env.build = { ...envs };
-    const valuesYaml = yaml.dump({ ...values });
-    try {
-      await api.upgradeChartValues(
-        "<token>",
-        {
-          values: valuesYaml,
-        },
-        {
-          id: currentProject.id,
-          namespace: chart.namespace,
-          name: chart.name,
-          cluster_id: currentCluster.id,
-        }
-      );
-    } catch (error) {
-      throw error;
-    }
-  };
-
-  const triggerWorkflow = async () => {
-    try {
-      await api.reRunGHWorkflow(
-        "",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          git_installation_id: chart.git_action_config?.git_repo_id,
-          owner: chart.git_action_config?.git_repo?.split("/")[0],
-          name: chart.git_action_config?.git_repo?.split("/")[1],
-          branch: chart.git_action_config?.git_branch,
-          release_name: chart.name,
-        }
-      );
-    } catch (error) {
-      if (!error?.response) {
-        throw error;
-      }
-
-      let tmpError: AxiosError = error;
-
-      /**
-       * @smell
-       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
-       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
-       */
-
-      if (tmpError.response.status === 400) {
-        // setReRunError({
-        //   title: "No previous run found",
-        //   description:
-        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
-        // });
-        setCurrentError(
-          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 409) {
-        // setReRunError({
-        //   title: "The workflow is still running",
-        //   description:
-        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
-        // });
-
-        if (typeof tmpError.response.data === "string") {
-          setRunningWorkflowURL(tmpError.response.data);
-        }
-        setCurrentError(
-          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
-            tmpError.response.data
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 404) {
-        let description = "No action file matching this deployment was found.";
-        if (typeof tmpError.response.data === "string") {
-          const filename = tmpError.response.data;
-          description = description.concat(
-            `Please check that the file "${filename}" exists in your repository.`
-          );
-        }
-        // setReRunError({
-        //   title: "The action doesn't seem to exist",
-        //   description,
-        // });
-
-        setCurrentError(description);
-        return;
-      }
-      throw error;
-    }
-  };
-
-  const clearButtonStatus = (time: number = 800) => {
-    setTimeout(() => {
-      setButtonStatus("");
-    }, time);
-  };
-
-  const getBuildConfig = () => {
-    if (buildpackConfigRef.current?.isLoading) {
-      return null;
-    }
-    return buildpackConfigRef.current?.getBuildConfig() || null;
-  };
-
-  const handleSave = async () => {
-    setButtonStatus("loading");
-
-    const buildConfig = getBuildConfig();
-
-    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
-      setButtonStatus("Can't save until buildpack config is loaded.");
-      clearButtonStatus(1500);
-      return;
-    }
-
-    try {
-      await saveBuildConfig(buildConfig);
-      await saveNewBranch(currentBranch);
-      await saveEnvVariables(envVariables);
-      setButtonStatus("successful");
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      setCurrentError(error);
-    } finally {
-      clearButtonStatus();
-    }
-  };
-
-  const handleSaveAndReDeploy = async () => {
-    setButtonStatus("loading");
-
-    const buildConfig = getBuildConfig();
-
-    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
-      setButtonStatus("Can't save until buildpack config is loaded.");
-      clearButtonStatus();
-      return;
-    }
-
-    try {
-      await saveBuildConfig(buildConfig);
-      await saveNewBranch(currentBranch);
-      await saveEnvVariables(envVariables);
-      await triggerWorkflow();
-      setButtonStatus("successful");
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      setCurrentError(error);
-    } finally {
-      clearButtonStatus();
-    }
-  };
-
-  const currentActionConfig = useMemo(() => {
-    const actionConf = chart.git_action_config;
-    if (actionConf && actionConf.gitlab_integration_id) {
-      return {
-        kind: "gitlab",
-        ...actionConf,
-      } as FullActionConfigType;
-    }
-
-    return {
-      kind: "github",
-      ...actionConf,
-    } as FullActionConfigType;
-  }, [chart]);
-
-  return (
-    <Wrapper>
-      {isPreviousVersion ? (
-        <DisabledOverlay>
-          Build config is disabled when reviewing past versions. Please go to
-          the current revision to update your app build configuration.
-        </DisabledOverlay>
-      ) : null}
-      <StyledSettingsSection blurContent={isPreviousVersion}>
-        {/* {reRunError !== null ? (
-        <AlertCard>
-          <AlertCardIcon className="material-icons">error</AlertCardIcon>
-          <AlertCardContent className="content">
-            <AlertCardTitle className="title">
-              {reRunError.title}
-            </AlertCardTitle>
-            {reRunError.description}
-            {runningWorkflowURL.length ? (
-              <>
-                {" "}
-                To go to the workflow{" "}
-                <DynamicLink to={runningWorkflowURL} target="_blank">
-                  click here
-                </DynamicLink>
-              </>
-            ) : null}
-          </AlertCardContent>
-          <AlertCardAction
-            onClick={() => {
-              setReRunError(null);
-              setRunningWorkflowURL("");
-            }}
-          >
-            <span className="material-icons">close</span>
-          </AlertCardAction>
-        </AlertCard>
-      ) : null} */}
-        <Heading isAtTop>Build Environment Variables</Heading>
-        <KeyValueArray
-          values={envVariables}
-          envLoader
-          externalValues={{
-            namespace: chart.namespace,
-            clusterId: currentCluster.id,
-          }}
-          setValues={(values) => {
-            setEnvVariables(values);
-          }}
-        ></KeyValueArray>
-
-        <Heading>Select Default Branch</Heading>
-        <Helper>
-          Change the default branch the deployments will be made from.
-        </Helper>
-        <Banner type="warning">
-          You must also update the deploy branch in your GitHub Action file.
-        </Banner>
-        <BranchList
-          actionConfig={currentActionConfig}
-          setBranch={setCurrentBranch}
-          currentBranch={currentBranch}
-        />
-
-        {!chart.git_action_config.dockerfile_path ? (
-          <>
-            <Heading>Buildpack Settings</Heading>
-            <BuildpackConfigSection
-              ref={buildpackConfigRef}
-              currentChart={chart}
-              actionConfig={currentActionConfig}
-            />
-          </>
-        ) : null}
-        <SaveButtonWrapper>
-          <MultiSaveButton
-            options={[
-              {
-                text: "Save",
-                onClick: handleSave,
-                description:
-                  "Save the build settings to be used in the next workflow run",
-              },
-              {
-                text: "Save and Redeploy",
-                onClick: handleSaveAndReDeploy,
-                description:
-                  "Immediately trigger a workflow run with updated build settings",
-              },
-            ]}
-            disabled={false}
-            makeFlush={true}
-            clearPosition={true}
-            statusPosition="left"
-            expandTo="left"
-            saveText=""
-            status={buttonStatus}
-          ></MultiSaveButton>
-        </SaveButtonWrapper>
-      </StyledSettingsSection>
-    </Wrapper>
-  );
-};
-
-export default BuildSettingsTab;
-
-const BuildpackConfigSection = forwardRef<
-  {
-    isLoading: boolean;
-    getBuildConfig: () => BuildConfig;
-  },
-  {
-    actionConfig: FullActionConfigType;
-    currentChart: ChartTypeWithExtendedConfig;
-  }
->(({ actionConfig, currentChart }, ref) => {
-  const { currentProject } = useContext(Context);
-
-  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
-  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
-
-  const [stacks, setStacks] = useState<string[]>(null);
-  const [selectedStack, setSelectedStack] = useState<string>(null);
-
-  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
-  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
-    []
-  );
-
-  const state = useRef<null | {
-    [builder: string]: {
-      stack: string;
-      selectedBuildpacks: Buildpack[];
-      availableBuildpacks: Buildpack[];
-    };
-  }>(null);
-
-  const populateState = (
-    builder: string,
-    stack: string,
-    availableBuildpacks: Buildpack[] = [],
-    selectedBuildpacks: Buildpack[] = []
-  ) => {
-    state.current = {
-      ...state.current,
-      [builder]: {
-        stack: stack,
-        availableBuildpacks: availableBuildpacks,
-        selectedBuildpacks: selectedBuildpacks,
-      },
-    };
-  };
-
-  const populateBuildpacks = (
-    userBuildpacks: string[],
-    detectedBuildpacks: Buildpack[]
-  ) => {
-    const customBuildpackFactory = (name: string): Buildpack => ({
-      name: name,
-      buildpack: name,
-      config: null,
-    });
-
-    return userBuildpacks.map(
-      (ub) =>
-        detectedBuildpacks.find((db) => db.buildpack === ub) ||
-        customBuildpackFactory(ub)
-    );
-  };
-
-  const detectBuildpack = () => {
-    if (actionConfig.kind === "gitlab") {
-      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
-        "<token>",
-        { dir: actionConfig.folder_path || "." },
-        {
-          project_id: currentProject.id,
-          integration_id: actionConfig.gitlab_integration_id,
-
-          repo_owner: actionConfig.git_repo.split("/")[0],
-          repo_name: actionConfig.git_repo.split("/")[1],
-          branch: actionConfig.git_branch,
-        }
-      );
-    }
-
-    return api.detectBuildpack<DetectBuildpackResponse>(
-      "<token>",
-      {
-        dir: actionConfig.folder_path || ".",
-      },
-      {
-        project_id: currentProject.id,
-        git_repo_id: actionConfig.git_repo_id,
-        kind: "github",
-        owner: actionConfig.git_repo.split("/")[0],
-        name: actionConfig.git_repo.split("/")[1],
-        branch: actionConfig.git_branch,
-      }
-    );
-  };
-
-  useEffect(() => {
-    const currentBuildConfig = currentChart?.build_config;
-
-    if (!currentBuildConfig) {
-      return;
-    }
-    detectBuildpack()
-      .then(({ data }) => {
-        const builders = data;
-
-        const defaultBuilder = builders.find((builder) =>
-          builder.builders.find((stack) => stack === currentBuildConfig.builder)
-        );
-
-        const nonSelectedBuilder = builders.find(
-          (builder) =>
-            !builder.builders.find(
-              (stack) => stack === currentBuildConfig.builder
-            )
-        );
-
-        const fullDetectedBuildpacks = [
-          ...defaultBuilder.detected,
-          ...defaultBuilder.others,
-        ];
-
-        const userSelectedBuildpacks = populateBuildpacks(
-          currentBuildConfig.buildpacks,
-          fullDetectedBuildpacks
-        ).filter((b) => b.buildpack);
-
-        const availableBuildpacks = differenceBy(
-          fullDetectedBuildpacks,
-          userSelectedBuildpacks,
-          "buildpack"
-        );
-
-        const defaultStack = defaultBuilder.builders.find((stack) => {
-          return stack === currentBuildConfig.builder;
-        });
-
-        populateState(
-          defaultBuilder.name.toLowerCase(),
-          defaultStack,
-          userSelectedBuildpacks,
-          availableBuildpacks
-        );
-
-        populateState(
-          nonSelectedBuilder.name.toLowerCase(),
-          nonSelectedBuilder.builders[0],
-          nonSelectedBuilder.others,
-          nonSelectedBuilder.detected
-        );
-
-        setBuilders(builders);
-        setSelectedBuilder(defaultBuilder.name.toLowerCase());
-
-        setStacks(defaultBuilder.builders);
-        setSelectedStack(defaultStack);
-        if (!Array.isArray(userSelectedBuildpacks)) {
-          setSelectedBuildpacks([]);
-        } else {
-          setSelectedBuildpacks(userSelectedBuildpacks);
-        }
-        if (!Array.isArray(availableBuildpacks)) {
-          setAvailableBuildpacks([]);
-        } else {
-          setAvailableBuildpacks(availableBuildpacks);
-        }
-      })
-      .catch((err) => {
-        console.error(err);
-      });
-  }, [currentProject, actionConfig, currentChart]);
-
-  useImperativeHandle(
-    ref,
-    () => {
-      const isLoading = !stackOptions?.length || !builderOptions?.length;
-      return {
-        isLoading,
-        getBuildConfig: () => {
-          let buildConfig: BuildConfig = {} as BuildConfig;
-
-          buildConfig.builder = selectedStack;
-          buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
-            return buildpack.buildpack;
-          });
-          return buildConfig;
-        },
-      };
-    },
-    [selectedBuilder, selectedBuildpacks, selectedStack]
-  );
-
-  useEffect(() => {
-    populateState(
-      selectedBuilder,
-      selectedStack,
-      availableBuildpacks,
-      selectedBuildpacks
-    );
-  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
-
-  const builderOptions = useMemo(() => {
-    if (!Array.isArray(builders)) {
-      return;
-    }
-
-    return builders.map((builder) => ({
-      label: builder.name,
-      value: builder.name.toLowerCase(),
-    }));
-  }, [builders]);
-
-  const stackOptions = useMemo(() => {
-    if (!Array.isArray(stacks)) {
-      return;
-    }
-
-    return stacks.map((stack) => ({
-      label: stack,
-      value: stack.toLowerCase(),
-    }));
-  }, [stacks]);
-
-  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
-    setSelectedBuildpacks((selectedBuildpacks) => [
-      ...selectedBuildpacks,
-      buildpack,
-    ]);
-  };
-
-  const handleSelectBuilder = (builderName: string) => {
-    const builder = builders.find(
-      (b) => b.name.toLowerCase() === builderName.toLowerCase()
-    );
-
-    setBuilders(builders);
-    setStacks(builder.builders);
-
-    const currState = state.current;
-    if (currState[builderName]) {
-      const stateBuilder = currState[builderName];
-      setSelectedBuilder(builderName);
-      setSelectedStack(stateBuilder.stack);
-      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
-      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
-      return;
-    }
-  };
-
-  const renderBuildpacksList = (
-    buildpacks: Buildpack[],
-    action: "remove" | "add"
-  ) => {
-    if (!buildpacks.length && action === "remove") {
-      return (
-        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
-      );
-    }
-
-    if (!buildpacks.length && action === "add") {
-      return (
-        <StyledCard>
-          No additional buildpacks are available. You can add a custom buildpack
-          below.
-        </StyledCard>
-      );
-    }
-
-    return buildpacks?.map((buildpack, i) => {
-      const [languageName] = buildpack.name?.split("/").reverse();
-
-      const devicon = DeviconsNameList.find(
-        (devicon) => languageName.toLowerCase() === devicon.name
-      );
-
-      const icon = `devicon-${devicon?.name}-plain colored`;
-
-      let disableIcon = false;
-      if (!devicon) {
-        disableIcon = true;
-      }
-
-      return (
-        <StyledCard key={i}>
-          <ContentContainer>
-            <Icon disableMarginRight={disableIcon} className={icon} />
-            <EventInformation>
-              <EventName>{buildpack?.name}</EventName>
-            </EventInformation>
-          </ContentContainer>
-          <ActionContainer>
-            {action === "add" && (
-              <DeleteButton
-                onClick={() => handleAddBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons-outlined">add</span>
-              </DeleteButton>
-            )}
-            {action === "remove" && (
-              <DeleteButton
-                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
-              >
-                <span className="material-icons">delete</span>
-              </DeleteButton>
-            )}
-          </ActionContainer>
-        </StyledCard>
-      );
-    });
-  };
-
-  const handleRemoveBuildpack = (buildpackToRemove: string) => {
-    setSelectedBuildpacks((selBuildpacks) => {
-      const tmpSelectedBuildpacks = [...selBuildpacks];
-
-      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToRemove
-      );
-      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
-
-      setAvailableBuildpacks((availableBuildpacks) => [
-        ...availableBuildpacks,
-        buildpack,
-      ]);
-
-      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
-
-      return [...tmpSelectedBuildpacks];
-    });
-  };
-
-  const handleAddBuildpack = (buildpackToAdd: string) => {
-    setAvailableBuildpacks((avBuildpacks) => {
-      const tmpAvailableBuildpacks = [...avBuildpacks];
-      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
-        (buildpack) => buildpack.buildpack === buildpackToAdd
-      );
-      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
-
-      setSelectedBuildpacks((selectedBuildpacks) => [
-        ...selectedBuildpacks,
-        buildpack,
-      ]);
-
-      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
-      return [...tmpAvailableBuildpacks];
-    });
-  };
-
-  if (!stackOptions?.length || !builderOptions?.length) {
-    return <Loading />;
-  }
-
-  return (
-    <BuildpackConfigurationContainer>
-      <>
-        <SelectRow
-          value={selectedBuilder}
-          width="100%"
-          options={builderOptions}
-          setActiveValue={(option) => handleSelectBuilder(option)}
-          label="Select a builder"
-        />
-        <SelectRow
-          value={selectedStack}
-          width="100%"
-          options={stackOptions}
-          setActiveValue={(option) => setSelectedStack(option)}
-          label="Select your stack"
-        />
-        <Helper>
-          The following buildpacks were automatically detected. You can also
-          manually add/remove buildpacks.
-        </Helper>
-        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
-        <Helper>Available buildpacks:</Helper>
-        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
-        <Helper>
-          You may also add buildpacks by directly providing their GitHub links
-          or links to ZIP files that contain the buildpack source code.
-        </Helper>
-        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
-      </>
-    </BuildpackConfigurationContainer>
-  );
-});
-
-const DisabledOverlay = styled.div`
-  position: absolute;
-  width: 100%;
-  height: inherit;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #00000099;
-  z-index: 1000;
-  border-radius: 8px;
-  padding: 0 35px;
-  text-align: center;
-`;
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const SaveButtonWrapper = styled.div`
-  width: 100%;
-  margin-top: 30px;
-  display: flex;
-  justify-content: flex-end;
-`;
-
-const BuildpackConfigurationContainer = styled.div`
-  animation: ${fadeIn} 0.75s;
-`;
-
-const Wrapper = styled.div`
-  position: relative;
-  width: 100%;
-  margin-bottom: 65px;
-  height: 100%;
-`;
-
-const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
-  width: 100%;
-  background: #ffffff11;
-  padding: 0 35px;
-  padding-top: 35px;
-  padding-bottom: 15px;
-  position: relative;
-  border-radius: 8px;
-  height: calc(100% - 55px);
-  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
-`;
-
-const StyledCard = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #ffffff00;
-  background: #ffffff08;
-  margin-bottom: 5px;
-  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 DeleteButton = 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;
-  }
-`;
-
-const AlertCard = styled.div`
-  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
-  border-radius: 4px;
-  box-shadow: none;
-  font-weight: 400;
-  font-size: 0.875rem;
-  line-height: 1.43;
-  letter-spacing: 0.01071em;
-  border: 1px solid rgb(229, 115, 115);
-  display: flex;
-  padding: 6px 16px;
-  color: rgb(244, 199, 199);
-  margin-top: 20px;
-  position: relative;
-`;
-
-const AlertCardIcon = styled.span`
-  color: rgb(239, 83, 80);
-  margin-right: 12px;
-  padding: 7px 0px;
-  display: flex;
-  font-size: 22px;
-  opacity: 0.9;
-`;
-
-const AlertCardTitle = styled.div`
-  margin: -2px 0px 0.35em;
-  font-size: 1rem;
-  line-height: 1.5;
-  letter-spacing: 0.00938em;
-  font-weight: 500;
-`;
-
-const AlertCardContent = styled.div`
-  padding: 8px 0px;
-`;
-
-const AlertCardAction = styled.button`
-  position: absolute;
-  right: 5px;
-  top: 5px;
-  border: none;
-  background-color: unset;
-  color: white;
-  :hover {
-    cursor: pointer;
-  }
-`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -23,7 +23,7 @@ import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
 import IncidentsTab from "./incidents/IncidentsTab";
-import BuildSettingsTab from "./BuildSettingsTab";
+import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
 import { useStackEnvGroups } from "./useStackEnvGroups";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -27,7 +27,7 @@ import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal"
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
-import BuildSettingsTab from "./BuildSettingsTab";
+import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { useStackEnvGroups } from "./useStackEnvGroups";
 
 const readableDate = (s: string) => {

+ 479 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx

@@ -0,0 +1,479 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import MultiSaveButton from "components/MultiSaveButton";
+import _ from "lodash";
+import React, { useContext, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import yaml from "js-yaml";
+import { AxiosError } from "axios";
+import BranchList from "components/repo-selector/BranchList";
+import Banner from "components/Banner";
+import { UpdateBuildconfigResponse } from "./types";
+import BuildpackConfigSection from "./_BuildpackConfigSection";
+
+type Props = {
+  chart: ChartTypeWithExtendedConfig;
+  isPreviousVersion: boolean;
+};
+
+const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [envVariables, setEnvVariables] = useState(
+    chart.config?.container?.env?.build || null
+  );
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+  const [reRunError, setReRunError] = useState<{
+    title: string;
+    description: string;
+  }>(null);
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const [currentBranch, setCurrentBranch] = useState(
+    () => chart?.git_action_config?.git_branch
+  );
+
+  const buildpackConfigRef = useRef<{
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  }>(null);
+
+  const saveNewBranch = async (newBranch: string) => {
+    if (!newBranch?.length) {
+      return;
+    }
+
+    if (newBranch === chart?.git_action_config?.git_branch) {
+      return;
+    }
+
+    const newGitActionConfig: FullActionConfigType = {
+      ...chart.git_action_config,
+      git_branch: newBranch,
+    };
+
+    try {
+      api.updateGitActionConfig(
+        "<token>",
+        {
+          git_action_config: newGitActionConfig,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          release_name: chart.name,
+          namespace: chart.namespace,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const saveBuildConfig = async (config: BuildConfig) => {
+    console.log({ config });
+    if (config === null) {
+      return;
+    }
+
+    try {
+      await api.updateBuildConfig<UpdateBuildconfigResponse>(
+        "<token>",
+        { ...config },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      );
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  const saveEnvVariables = async (envs: { [key: string]: string }) => {
+    let values = { ...chart.config };
+    if (envs === null) {
+      return;
+    }
+
+    values.container.env.build = { ...envs };
+    const valuesYaml = yaml.dump({ ...values });
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+        },
+        {
+          id: currentProject.id,
+          namespace: chart.namespace,
+          name: chart.name,
+          cluster_id: currentCluster.id,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: chart.git_action_config?.git_repo_id,
+          owner: chart.git_action_config?.git_repo?.split("/")[0],
+          name: chart.git_action_config?.git_repo?.split("/")[1],
+          branch: chart.git_action_config?.git_branch,
+          release_name: chart.name,
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+
+  const clearButtonStatus = (time: number = 800) => {
+    setTimeout(() => {
+      setButtonStatus("");
+    }, time);
+  };
+
+  const getBuildConfig = () => {
+    if (buildpackConfigRef.current?.isLoading) {
+      return null;
+    }
+    return buildpackConfigRef.current?.getBuildConfig() || null;
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus(1500);
+      return;
+    }
+
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
+      await saveEnvVariables(envVariables);
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+
+    const buildConfig = getBuildConfig();
+
+    if (!buildConfig && !chart.git_action_config.dockerfile_path) {
+      setButtonStatus("Can't save until buildpack config is loaded.");
+      clearButtonStatus();
+      return;
+    }
+
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
+      await saveEnvVariables(envVariables);
+      await triggerWorkflow();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  const currentActionConfig = useMemo(() => {
+    const actionConf = chart.git_action_config;
+    if (actionConf && actionConf.gitlab_integration_id) {
+      return {
+        kind: "gitlab",
+        ...actionConf,
+      } as FullActionConfigType;
+    }
+
+    return {
+      kind: "github",
+      ...actionConf,
+    } as FullActionConfigType;
+  }, [chart]);
+
+  return (
+    <Wrapper>
+      {isPreviousVersion ? (
+        <DisabledOverlay>
+          Build config is disabled when reviewing past versions. Please go to
+          the current revision to update your app build configuration.
+        </DisabledOverlay>
+      ) : null}
+      <StyledSettingsSection blurContent={isPreviousVersion}>
+        {/* {reRunError !== null ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              {reRunError.title}
+            </AlertCardTitle>
+            {reRunError.description}
+            {runningWorkflowURL.length ? (
+              <>
+                {" "}
+                To go to the workflow{" "}
+                <DynamicLink to={runningWorkflowURL} target="_blank">
+                  click here
+                </DynamicLink>
+              </>
+            ) : null}
+          </AlertCardContent>
+          <AlertCardAction
+            onClick={() => {
+              setReRunError(null);
+              setRunningWorkflowURL("");
+            }}
+          >
+            <span className="material-icons">close</span>
+          </AlertCardAction>
+        </AlertCard>
+      ) : null} */}
+        <Heading isAtTop>Build Environment Variables</Heading>
+        <KeyValueArray
+          values={envVariables}
+          envLoader
+          externalValues={{
+            namespace: chart.namespace,
+            clusterId: currentCluster.id,
+          }}
+          setValues={(values) => {
+            setEnvVariables(values);
+          }}
+        ></KeyValueArray>
+
+        <Heading>Select Default Branch</Heading>
+        <Helper>
+          Change the default branch the deployments will be made from.
+        </Helper>
+        <Banner>
+          You must also update the deploy branch in your GitHub Action file.
+        </Banner>
+        <BranchList
+          actionConfig={currentActionConfig}
+          setBranch={setCurrentBranch}
+          currentBranch={currentBranch}
+        />
+
+        {!chart.git_action_config.dockerfile_path ? (
+          <>
+            <Heading>Buildpack Settings</Heading>
+            <BuildpackConfigSection
+              ref={buildpackConfigRef}
+              currentChart={chart}
+              actionConfig={currentActionConfig}
+            />
+          </>
+        ) : null}
+        <SaveButtonWrapper>
+          <MultiSaveButton
+            options={[
+              {
+                text: "Save",
+                onClick: handleSave,
+                description:
+                  "Save the build settings to be used in the next workflow run",
+              },
+              {
+                text: "Save and Redeploy",
+                onClick: handleSaveAndReDeploy,
+                description:
+                  "Immediately trigger a workflow run with updated build settings",
+              },
+            ]}
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="left"
+            expandTo="left"
+            saveText=""
+            status={buttonStatus}
+          ></MultiSaveButton>
+        </SaveButtonWrapper>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default BuildSettingsTab;
+
+const DisabledOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: inherit;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #00000099;
+  z-index: 1000;
+  border-radius: 8px;
+  padding: 0 35px;
+  text-align: center;
+`;
+
+const SaveButtonWrapper = styled.div`
+  width: 100%;
+  margin-top: 30px;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-top: 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  height: calc(100% - 55px);
+  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
+`;
+
+const AlertCard = styled.div`
+  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+  border-radius: 4px;
+  box-shadow: none;
+  font-weight: 400;
+  font-size: 0.875rem;
+  line-height: 1.43;
+  letter-spacing: 0.01071em;
+  border: 1px solid rgb(229, 115, 115);
+  display: flex;
+  padding: 6px 16px;
+  color: rgb(244, 199, 199);
+  margin-top: 20px;
+  position: relative;
+`;
+
+const AlertCardIcon = styled.span`
+  color: rgb(239, 83, 80);
+  margin-right: 12px;
+  padding: 7px 0px;
+  display: flex;
+  font-size: 22px;
+  opacity: 0.9;
+`;
+
+const AlertCardTitle = styled.div`
+  margin: -2px 0px 0.35em;
+  font-size: 1rem;
+  line-height: 1.5;
+  letter-spacing: 0.00938em;
+  font-weight: 500;
+`;
+
+const AlertCardContent = styled.div`
+  padding: 8px 0px;
+`;
+
+const AlertCardAction = styled.button`
+  position: absolute;
+  right: 5px;
+  top: 5px;
+  border: none;
+  background-color: unset;
+  color: white;
+  :hover {
+    cursor: pointer;
+  }
+`;

+ 550 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/_BuildpackConfigSection.tsx

@@ -0,0 +1,550 @@
+import { DeviconsNameList } from "assets/devicons-name-list";
+import Helper from "components/form-components/Helper";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
+import { differenceBy } from "lodash";
+import React, {
+  forwardRef,
+  useContext,
+  useEffect,
+  useImperativeHandle,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import { Buildpack, DetectBuildpackResponse, DetectedBuildpack } from "./types";
+
+const BuildpackConfigSection = forwardRef<
+  {
+    isLoading: boolean;
+    getBuildConfig: () => BuildConfig;
+  },
+  {
+    actionConfig: FullActionConfigType;
+    currentChart: ChartTypeWithExtendedConfig;
+  }
+>(({ actionConfig, currentChart }, ref) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState(false);
+
+  const state = useRef<null | {
+    [builder: string]: {
+      stack: string;
+      selectedBuildpacks: Buildpack[];
+      availableBuildpacks: Buildpack[];
+    };
+  }>(null);
+
+  const populateState = (
+    builder: string,
+    stack: string,
+    availableBuildpacks: Buildpack[] = [],
+    selectedBuildpacks: Buildpack[] = []
+  ) => {
+    state.current = {
+      ...state.current,
+      [builder]: {
+        stack: stack,
+        availableBuildpacks: availableBuildpacks,
+        selectedBuildpacks: selectedBuildpacks,
+      },
+    };
+  };
+
+  const populateBuildpacks = (
+    userBuildpacks: string[],
+    detectedBuildpacks: Buildpack[]
+  ) => {
+    const customBuildpackFactory = (name: string): Buildpack => ({
+      name: name,
+      buildpack: name,
+      config: null,
+    });
+
+    return userBuildpacks.map(
+      (ub) =>
+        detectedBuildpacks.find((db) => db.buildpack === ub) ||
+        customBuildpackFactory(ub)
+    );
+  };
+
+  const detectBuildpack = () => {
+    if (actionConfig.kind === "gitlab") {
+      return api.detectGitlabBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        { dir: actionConfig.folder_path || "." },
+        {
+          project_id: currentProject.id,
+          integration_id: actionConfig.gitlab_integration_id,
+
+          repo_owner: actionConfig.git_repo.split("/")[0],
+          repo_name: actionConfig.git_repo.split("/")[1],
+          branch: actionConfig.git_branch,
+        }
+      );
+    }
+
+    return api.detectBuildpack<DetectBuildpackResponse>(
+      "<token>",
+      {
+        dir: actionConfig.folder_path || ".",
+      },
+      {
+        project_id: currentProject.id,
+        git_repo_id: actionConfig.git_repo_id,
+        kind: "github",
+        owner: actionConfig.git_repo.split("/")[0],
+        name: actionConfig.git_repo.split("/")[1],
+        branch: actionConfig.git_branch,
+      }
+    );
+  };
+
+  useEffect(() => {
+    const currentBuildConfig = currentChart?.build_config;
+
+    if (!currentBuildConfig) {
+      return;
+    }
+
+    setIsLoading(true);
+
+    detectBuildpack()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find((builder) =>
+          builder.builders.find((stack) => stack === currentBuildConfig.builder)
+        );
+
+        const nonSelectedBuilder = builders.find(
+          (builder) =>
+            !builder.builders.find(
+              (stack) => stack === currentBuildConfig.builder
+            )
+        );
+
+        const fullDetectedBuildpacks = [
+          ...defaultBuilder.detected,
+          ...defaultBuilder.others,
+        ];
+
+        const userSelectedBuildpacks = populateBuildpacks(
+          currentBuildConfig.buildpacks,
+          fullDetectedBuildpacks
+        ).filter((b) => b.buildpack);
+
+        const availableBuildpacks = differenceBy(
+          fullDetectedBuildpacks,
+          userSelectedBuildpacks,
+          "buildpack"
+        );
+
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return stack === currentBuildConfig.builder;
+        });
+
+        populateState(
+          defaultBuilder.name.toLowerCase(),
+          defaultStack,
+          userSelectedBuildpacks,
+          availableBuildpacks
+        );
+
+        populateState(
+          nonSelectedBuilder.name.toLowerCase(),
+          nonSelectedBuilder.builders[0],
+          nonSelectedBuilder.others,
+          nonSelectedBuilder.detected
+        );
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(userSelectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(userSelectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+        setError(true);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, [currentProject, actionConfig, currentChart]);
+
+  useImperativeHandle(
+    ref,
+    () => {
+      return {
+        isLoading: isLoading,
+        getBuildConfig: () => {
+          const currentBuildConfig = currentChart?.build_config;
+
+          if (error) {
+            if (typeof currentBuildConfig.config === "string") {
+              return {
+                ...currentBuildConfig,
+                config: JSON.parse(atob(currentBuildConfig.config)) as Record<
+                  string,
+                  unknown
+                >,
+              } as BuildConfig;
+            } else {
+              return currentBuildConfig;
+            }
+          }
+
+          let buildConfig: BuildConfig = {} as BuildConfig;
+
+          buildConfig.builder = selectedStack;
+          buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+            return buildpack.buildpack;
+          });
+
+          return buildConfig;
+        },
+      };
+    },
+    [selectedBuilder, selectedBuildpacks, selectedStack, isLoading, error]
+  );
+
+  useEffect(() => {
+    populateState(
+      selectedBuilder,
+      selectedStack,
+      availableBuildpacks,
+      selectedBuildpacks
+    );
+  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+
+    setBuilders(builders);
+    setStacks(builder.builders);
+
+    const currState = state.current;
+    if (currState[builderName]) {
+      const stateBuilder = currState[builderName];
+      setSelectedBuilder(builderName);
+      setSelectedStack(stateBuilder.stack);
+      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
+      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
+      return;
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    if (!buildpacks.length && action === "remove") {
+      return (
+        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
+      );
+    }
+
+    if (!buildpacks.length && action === "add") {
+      return (
+        <StyledCard>
+          No additional buildpacks are available. You can add a custom buildpack
+          below.
+        </StyledCard>
+      );
+    }
+
+    return buildpacks?.map((buildpack, i) => {
+      const [languageName] = buildpack.name?.split("/").reverse();
+
+      const devicon = DeviconsNameList.find(
+        (devicon) => languageName.toLowerCase() === devicon.name
+      );
+
+      const icon = `devicon-${devicon?.name}-plain colored`;
+
+      let disableIcon = false;
+      if (!devicon) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={i}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (isLoading) {
+    return (
+      <div style={{ marginTop: "20px" }}>
+        <Loading />
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div style={{ marginTop: "20px" }}>
+        <Placeholder>
+          <div>
+            <h2>Couldn't retrieve buildpacks.</h2>
+            <p>
+              Check if the branch exists and the Porter App has enough
+              permissions on the repository.
+            </p>
+          </div>
+        </Placeholder>
+      </div>
+    );
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
+        <Helper>Available buildpacks:</Helper>
+        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+      </>
+    </BuildpackConfigurationContainer>
+  );
+});
+
+BuildpackConfigSection.displayName = "BuildpackConfigSection";
+
+export default BuildpackConfigSection;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+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 DeleteButton = 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;
+  }
+`;

+ 29 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/types.ts

@@ -0,0 +1,29 @@
+export type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+export type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+export type DetectBuildpackResponse = DetectedBuildpack[];
+
+export type UpdateBuildconfigResponse = {
+  CreatedAt: string;
+  DeletedAt: { Time: string; Valid: boolean };
+  Time: string;
+  Valid: boolean;
+  ID: number;
+  UpdatedAt: string;
+  builder: string;
+  buildpacks: string;
+  config: string;
+  name: string;
+};