소스 검색

rfac: consolidate branch deployments

Soham Parekh 3 년 전
부모
커밋
de96a0013b

+ 15 - 8
dashboard/src/main/home/cluster-dashboard/preview-environments/components/BranchFilterSelector.tsx

@@ -7,11 +7,13 @@ const BranchFilterSelector = ({
   options,
   onChange,
   showLoading,
+  multiSelect = true,
 }: {
   value: string[];
   options: string[];
   onChange: (value: string[]) => void;
   showLoading?: boolean;
+  multiSelect?: boolean;
 }) => {
   const filteredBranches = useMemo(() => {
     if (!options.length) {
@@ -26,6 +28,11 @@ const BranchFilterSelector = ({
   }, [options, value]);
 
   const handleAddBranch = (branch: string) => {
+    if (!multiSelect) {
+      onChange([branch]);
+      return;
+    }
+
     const newSelectedBranches = [...value, branch];
 
     onChange(newSelectedBranches);
@@ -55,14 +62,14 @@ const BranchFilterSelector = ({
       {/* List selected branches  */}
 
       <BranchRowList>
-      {value.map((branch) => (
-        <BranchRow key={branch}>
-          <div>{branch}</div>
-          <RemoveBranchButton onClick={() => handleDeleteBranch(branch)}>
-            x
-          </RemoveBranchButton>
-        </BranchRow>
-      ))}
+        {value.map((branch) => (
+          <BranchRow key={branch}>
+            <div>{branch}</div>
+            <RemoveBranchButton onClick={() => handleDeleteBranch(branch)}>
+              x
+            </RemoveBranchButton>
+          </BranchRow>
+        ))}
       </BranchRowList>
     </>
   );

+ 352 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateBranchEnvironment.tsx

@@ -0,0 +1,352 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { Environment } from "../types";
+import Helper from "components/form-components/Helper";
+import api from "shared/api";
+import { useQuery } from "@tanstack/react-query";
+import { validatePorterYAML } from "../utils";
+import Banner from "components/Banner";
+import { useRouting } from "shared/routing";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import Placeholder from "components/Placeholder";
+import BranchFilterSelector from "../components/BranchFilterSelector";
+import _ from "lodash";
+
+interface Props {
+  environmentID: string;
+}
+
+const CreateBranchEnvironment = ({ environmentID }: Props) => {
+  const router = useRouting();
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const { data: environment } = useQuery<Environment>(
+    ["environment", currentProject.id, currentCluster.id, environmentID],
+    async () => {
+      const { data: environment } = await api.getEnvironment<Environment>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: parseInt(environmentID),
+        }
+      );
+
+      return environment;
+    }
+  );
+
+  // Get all branches for the current environment
+  const { isLoading: branchesLoading, data: branches } = useQuery<string[]>(
+    ["branches", currentProject.id, currentCluster.id, environment],
+    async () => {
+      try {
+        const res = await api.getBranches<string[]>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            kind: "github",
+            name: environment.git_repo_name,
+            owner: environment.git_repo_owner,
+            git_repo_id: environment.git_installation_id,
+          }
+        );
+        return res.data ?? [];
+      } catch (err) {
+        setCurrentError(
+          "Couldn't load branches for this repository, please try again later."
+        );
+      }
+    },
+    {
+      enabled: !!environment,
+    }
+  );
+
+  const [selectedBranch, setSelectedBranch] = useState<string>();
+  const [loading, setLoading] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+
+  const handleRowItemClick = async (branch: string) => {
+    setSelectedBranch(branch);
+    setLoading(true);
+
+    const res = await validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: Number(environmentID),
+      branch,
+    });
+
+    setPorterYAMLErrors(res.data.errors ?? []);
+
+    setLoading(false);
+  };
+
+  const handleCreatePreviewDeployment = async () => {
+    try {
+      //   await api.createPreviewEnvironmentDeployment(
+      //     "<token>",
+      //     {
+      //       pr_title: "",
+      //       pr_number: 0,
+      //       repo_owner: environment.git_repo_name,
+      //       repo_name: environment.git_repo_owner,
+      //       branch_from: selectedBranch,
+      //       branch_into: selectedBranch,
+      //     },
+      //     {
+      //       cluster_id: currentCluster?.id,
+      //       project_id: currentProject?.id,
+      //     }
+      //   );
+
+      throw Error("Not implemented yet. (CreateBranchEnvironment.tsx:");
+
+      router.push(
+        `/preview-environments/deployments/${environmentID}/${environment.git_repo_name}/${environment.git_repo_owner}?status_filter=all`
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+  };
+
+  if (!branches?.length) {
+    return (
+      <>
+        <Br height="30px" />
+        <Placeholder height="370px">You do not have any branches.</Placeholder>
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Helper>
+        Select a branch to preview. Branches must contain a{" "}
+        <Code>porter.yaml</Code> file.
+      </Helper>
+      <Br height="10px" />
+      <BranchFilterSelector
+        onChange={(branches) => setSelectedBranch(branches[0])}
+        options={branches}
+        value={_.compact([selectedBranch])}
+        showLoading={branchesLoading}
+        multiSelect={false}
+      />
+      {showErrorsModal && selectedBranch ? (
+        <PorterYAMLErrorsModal
+          errors={porterYAMLErrors}
+          onClose={() => setShowErrorsModal(false)}
+          repo={environment.git_repo_name + "/" + environment.git_repo_owner}
+          branch={selectedBranch}
+        />
+      ) : null}
+      {selectedBranch && porterYAMLErrors.length ? (
+        <ValidationErrorBannerWrapper>
+          <Banner type="warning">
+            We found some errors in the porter.yaml file in the&nbsp;
+            {selectedBranch}&nbsp;branch. &nbsp;
+            <LearnMoreButton onClick={() => setShowErrorsModal(true)}>
+              Learn more
+            </LearnMoreButton>
+          </Banner>
+        </ValidationErrorBannerWrapper>
+      ) : null}
+      <CreatePreviewDeploymentWrapper>
+        <SubmitButton
+          onClick={handleCreatePreviewDeployment}
+          disabled={loading || !selectedBranch || porterYAMLErrors.length > 0}
+        >
+          Create preview deployment
+        </SubmitButton>
+        {selectedBranch && porterYAMLErrors.length ? (
+          <RevalidatePorterYAMLSpanWrapper>
+            Please fix your porter.yaml file to continue.{" "}
+            <RevalidateSpan
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+
+                if (!selectedBranch) {
+                  return;
+                }
+
+                handleRowItemClick(selectedBranch);
+              }}
+            >
+              Refresh
+            </RevalidateSpan>
+          </RevalidatePorterYAMLSpanWrapper>
+        ) : null}
+      </CreatePreviewDeploymentWrapper>
+    </>
+  );
+};
+
+export default CreateBranchEnvironment;
+
+const PullRequestRow = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "#26292e")};
+  border-bottom: ${(props) => (props.isLast ? "" : "1px solid #494b4f")};
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Code = styled.span`
+  font-family: monospace; ;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 16px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Br = styled.div<{ height: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "2px"};
+`;
+
+const ValidationErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const LearnMoreButton = styled.div`
+  text-decoration: underline;
+  fontweight: bold;
+  cursor: pointer;
+`;
+
+const CreatePreviewDeploymentWrapper = styled.div`
+  margin-top: 30px;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const RevalidatePorterYAMLSpanWrapper = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const RevalidateSpan = styled.span`
+  color: #aaaabb;
+  text-decoration: underline;
+  cursor: pointer;
+`;

+ 21 - 368
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreateEnvironment.tsx

@@ -1,31 +1,21 @@
 import DynamicLink from "components/DynamicLink";
-import Loading from "components/Loading";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
-import { Context } from "shared/Context";
 import { useParams } from "react-router";
-import { PullRequest } from "../types";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
-import Helper from "components/form-components/Helper";
-import pr_icon from "assets/pull_request_icon.svg";
-import api from "shared/api";
-import { EllipsisTextWrapper, RepoLink } from "../components/styled";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { getPRDeploymentList, validatePorterYAML } from "../utils";
-import Banner from "components/Banner";
-import Modal from "main/home/modals/Modal";
-import { useRouting } from "shared/routing";
-import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
-import { PlaceHolder } from "brace";
-import Placeholder from "components/Placeholder";
+import CreatePREnvironment from "./CreatePREnvironment";
+import TabSelector from "components/TabSelector";
+import CreateBranchEnvironment from "./CreateBranchEnvironment";
+
+const TAB_OPTIONS = [
+  { label: "Pull Requests", value: "pull_requests" },
+  { label: "Branches", value: "branches" },
+];
 
 const CreateEnvironment: React.FC = () => {
-  const router = useRouting();
-  const queryClient = useQueryClient();
-  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
-  const { currentProject, currentCluster, setCurrentError } = useContext(
-    Context
+  const [currentTab, setCurrentTab] = useState<typeof TAB_OPTIONS[0]>(
+    TAB_OPTIONS[0]
   );
   const { environment_id, repo_name, repo_owner } = useParams<{
     environment_id: string;
@@ -33,158 +23,8 @@ const CreateEnvironment: React.FC = () => {
     repo_owner: string;
   }>();
 
-  const { isLoading: getPullRequestsLoading, data: pullRequests } = useQuery<
-    PullRequest[]
-  >(
-    ["pullRequests", currentProject.id, currentCluster.id, environment_id],
-    async () => {
-      try {
-        const res = await getPRDeploymentList({
-          projectID: currentProject.id,
-          clusterID: currentCluster.id,
-          environmentID: Number(environment_id),
-        });
-
-        return res.data.pull_requests || [];
-      } catch (err) {
-        setCurrentError(err);
-      }
-    }
-  );
-
-  const [selectedPR, setSelectedPR] = useState<PullRequest>();
-  const [loading, setLoading] = useState(false);
-  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
-
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
-  const handlePRRowItemClick = async (pullRequest: PullRequest) => {
-    setSelectedPR(pullRequest);
-    setLoading(true);
-
-    const res = await validatePorterYAML({
-      projectID: currentProject.id,
-      clusterID: currentCluster.id,
-      environmentID: Number(environment_id),
-      branch: pullRequest.branch_from,
-    });
-
-    setPorterYAMLErrors(res.data.errors ?? []);
-
-    setLoading(false);
-  };
-
-  const handleCreatePreviewDeployment = async () => {
-    try {
-      await api.createPreviewEnvironmentDeployment("<token>", selectedPR, {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-      });
-
-      router.push(
-        `/preview-environments/deployments/${environment_id}/${selectedPR.repo_owner}/${selectedPR.repo_name}?status_filter=all`
-      );
-    } catch (err) {
-      setCurrentError(err);
-    }
-  };
-
-  const renderPullRequestList = () => {
-    return (
-      <>
-        <Helper>
-          Select an open pull request to preview. Pull requests must contain a{" "}
-          <Code>porter.yaml</Code> file.
-        </Helper>
-        <Br height="10px" />
-        <PullRequestList>
-          {(pullRequests ?? []).map((pullRequest: PullRequest, i: number) => {
-            return (
-              <PullRequestRow
-                onClick={() => {
-                  handlePRRowItemClick(pullRequest);
-                }}
-                isLast={i === pullRequests.length - 1}
-                isSelected={pullRequest === selectedPR}
-              >
-                <PRName>
-                  <PRIcon src={pr_icon} alt="pull request icon" />
-                  <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
-                    {pullRequest.pr_title}
-                  </EllipsisTextWrapper>
-                </PRName>
-
-                <Flex>
-                  <DeploymentImageContainer>
-                    {/* <InfoWrapper>
-                    <LastDeployed>
-                      #{pullRequest.pr_number} last updated xyz
-                    </LastDeployed>
-                  </InfoWrapper>
-                  <SepDot>•</SepDot> */}
-                    <MergeInfoWrapper>
-                      <MergeInfo>
-                        {pullRequest.branch_from}
-                        <i className="material-icons">arrow_forward</i>
-                        {pullRequest.branch_into}
-                      </MergeInfo>
-                    </MergeInfoWrapper>
-                  </DeploymentImageContainer>
-                </Flex>
-              </PullRequestRow>
-            );
-          })}
-        </PullRequestList>
-        {showErrorsModal && selectedPR ? (
-          <PorterYAMLErrorsModal
-            errors={porterYAMLErrors}
-            onClose={() => setShowErrorsModal(false)}
-            repo={selectedPR.repo_owner + "/" + selectedPR.repo_name}
-            branch={selectedPR.branch_from}
-          />
-        ) : null}
-        {selectedPR && porterYAMLErrors.length ? (
-          <ValidationErrorBannerWrapper>
-            <Banner type="warning">
-              We found some errors in the porter.yaml file in the&nbsp;
-              {selectedPR.branch_from}&nbsp;branch. &nbsp;
-              <LearnMoreButton onClick={() => setShowErrorsModal(true)}>
-                Learn more
-              </LearnMoreButton>
-            </Banner>
-          </ValidationErrorBannerWrapper>
-        ) : null}
-        <CreatePreviewDeploymentWrapper>
-          <SubmitButton
-            onClick={handleCreatePreviewDeployment}
-            disabled={loading || !selectedPR || porterYAMLErrors.length > 0}
-          >
-            Create preview deployment
-          </SubmitButton>
-          {selectedPR && porterYAMLErrors.length ? (
-            <RevalidatePorterYAMLSpanWrapper>
-              Please fix your porter.yaml file to continue.{" "}
-              <RevalidateSpan
-                onClick={(e) => {
-                  e.preventDefault();
-                  e.stopPropagation();
-
-                  if (!selectedPR) {
-                    return;
-                  }
-
-                  handlePRRowItemClick(selectedPR);
-                }}
-              >
-                Refresh
-              </RevalidateSpan>
-            </RevalidatePorterYAMLSpanWrapper>
-          ) : null}
-        </CreatePreviewDeploymentWrapper>
-      </>
-    );
-  };
-
   return (
     <>
       <BreadcrumbRow>
@@ -206,15 +46,17 @@ const CreateEnvironment: React.FC = () => {
         capitalize={false}
       />
       <DarkMatter />
-      {pullRequests?.length ? (
-        renderPullRequestList()
+      <TabSelector
+        options={TAB_OPTIONS}
+        currentTab={currentTab.value}
+        setCurrentTab={(value: string) =>
+          setCurrentTab(TAB_OPTIONS.find((tab) => tab.value === value))
+        }
+      />
+      {currentTab.value === "pull_requests" ? (
+        <CreatePREnvironment environmentID={environment_id} />
       ) : (
-        <>
-          <Br height="30px" />
-          <Placeholder height="370px">
-            You do not have any pull requests.
-          </Placeholder>
-        </>
+        <CreateBranchEnvironment environmentID={environment_id} />
       )}
     </>
   );
@@ -222,161 +64,11 @@ const CreateEnvironment: React.FC = () => {
 
 export default CreateEnvironment;
 
-const PullRequestList = styled.div`
-  border: 1px solid #494b4f;
-  border-radius: 5px;
-  overflow: hidden;
-`;
-
-const PullRequestRow = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
-  width: 100%;
-  padding: 15px;
-  cursor: pointer;
-  background: ${(props) => (props.isSelected ? "#ffffff11" : "#26292e")};
-  border-bottom: ${(props) => (props.isLast ? "" : "1px solid #494b4f")};
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
-const Code = styled.span`
-  font-family: monospace; ;
-`;
-
-const SepDot = styled.div`
-  color: #aaaabb66;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 10px;
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-right: 8px;
-  margin-left: 7px;
-`;
-
-const LastDeployed = styled.div`
-  font-size: 13px;
-  margin-top: -1px;
-  margin-left: 10px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb66;
-`;
-
-const MergeInfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 8px;
-  position: relative;
-  margin-left: 10px;
-`;
-
-const MergeInfo = styled.div`
-  font-size: 13px;
-  align-items: center;
-  color: #aaaabb66;
-  white-space: nowrap;
-  display: flex;
-  align-items: center;
-  text-overflow: ellipsis;
-  overflow: hidden;
-  max-width: 300px;
-
-  > i {
-    font-size: 16px;
-    margin: 0 2px;
-  }
-`;
-
-const PRIcon = styled.img`
-  font-size: 20px;
-  height: 16px;
-  margin-right: 10px;
-  color: #aaaabb;
-  opacity: 50%;
-`;
-
-const PRName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-  display: flex;
-  font-size: 14px;
-  align-items: center;
-  margin-bottom: 10px;
-`;
-
-const SubmitButton = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 5px;
-  font-weight: 500;
-  color: white;
-  height: 30px;
-  padding: 0 8px;
-  width: 200px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
 const DarkMatter = styled.div`
   width: 100%;
   margin-top: -15px;
 `;
 
-const Br = styled.div<{ height: string }>`
-  width: 100%;
-  height: ${(props) => props.height || "2px"};
-`;
-
 const Slash = styled.div`
   margin: 0 4px;
   color: #aaaabb88;
@@ -420,42 +112,3 @@ const Breadcrumb = styled(DynamicLink)`
     background: #ffffff11;
   }
 `;
-
-const ValidationErrorBannerWrapper = styled.div`
-  margin-block: 20px;
-`;
-
-const LearnMoreButton = styled.div`
-  text-decoration: underline;
-  fontweight: bold;
-  cursor: pointer;
-`;
-
-const Message = styled.div`
-  padding: 20px;
-  background: #26292e;
-  border-radius: 5px;
-  line-height: 1.5em;
-  border: 1px solid #aaaabb33;
-  font-size: 13px;
-  margin-top: 40px;
-`;
-
-const CreatePreviewDeploymentWrapper = styled.div`
-  margin-top: 30px;
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 10px;
-`;
-
-const RevalidatePorterYAMLSpanWrapper = styled.div`
-  font-size: 13px;
-  color: #aaaabb;
-`;
-
-const RevalidateSpan = styled.span`
-  color: #aaaabb;
-  text-decoration: underline;
-  cursor: pointer;
-`;

+ 493 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/CreatePREnvironment.tsx

@@ -0,0 +1,493 @@
+import React, { useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { PullRequest } from "../types";
+import Helper from "components/form-components/Helper";
+import pr_icon from "assets/pull_request_icon.svg";
+import api from "shared/api";
+import { EllipsisTextWrapper } from "../components/styled";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import Banner from "components/Banner";
+import { useRouting } from "shared/routing";
+import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import Placeholder from "components/Placeholder";
+import RadioFilter from "components/RadioFilter";
+
+import sort from "assets/sort.svg";
+import { search } from "shared/search";
+import _ from "lodash";
+
+interface Props {
+  environmentID: string;
+}
+
+const CreatePREnvironment = ({ environmentID }: Props) => {
+  const queryClient = useQueryClient();
+  const router = useRouting();
+  const [searchValue, setSearchValue] = useState("");
+  const [sortOrder, setSortOrder] = useState("Newest");
+  const [showErrorsModal, setShowErrorsModal] = useState<boolean>(false);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  // Get all PRs for the current environment
+  const { isLoading: getPullRequestsLoading, data: pullRequests } = useQuery<
+    PullRequest[]
+  >(
+    ["pullRequests", currentProject.id, currentCluster.id, environmentID],
+    async () => {
+      try {
+        const res = await getPRDeploymentList({
+          projectID: currentProject.id,
+          clusterID: currentCluster.id,
+          environmentID: Number(environmentID),
+        });
+
+        return res.data.pull_requests || [];
+      } catch (err) {
+        setCurrentError(err);
+      }
+    }
+  );
+
+  const [selectedPR, setSelectedPR] = useState<PullRequest>();
+  const [loading, setLoading] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+
+  const handleRefresh = () => {
+    queryClient.invalidateQueries({
+      queryKey: ["pullRequests"],
+    });
+  };
+
+  const handlePRRowItemClick = async (pullRequest: PullRequest) => {
+    setSelectedPR(pullRequest);
+    setLoading(true);
+
+    const res = await validatePorterYAML({
+      projectID: currentProject.id,
+      clusterID: currentCluster.id,
+      environmentID: Number(environmentID),
+      branch: pullRequest.branch_from,
+    });
+
+    setPorterYAMLErrors(res.data.errors ?? []);
+
+    setLoading(false);
+  };
+
+  const handleCreatePreviewDeployment = async () => {
+    try {
+      await api.createPreviewEnvironmentDeployment("<token>", selectedPR, {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      });
+
+      router.push(
+        `/preview-environments/deployments/${environmentID}/${selectedPR.repo_owner}/${selectedPR.repo_name}?status_filter=all`
+      );
+    } catch (err) {
+      setCurrentError(err);
+    }
+  };
+
+  const filteredPullRequests = useMemo(() => {
+    const filteredBySearch = search<PullRequest>(pullRequests, searchValue, {
+      isCaseSensitive: false,
+      keys: ["pr_title", "branch_from", "branch_into"],
+    });
+
+    switch (sortOrder) {
+    //   case "Newest":
+    //     return _.sortBy(filteredBySearch, "updated_at").reverse();
+    //   case "Oldest":
+    //     return _.sortBy(filteredBySearch, "updated_at");
+      case "Alphabetical":
+      default:
+        return _.sortBy(filteredBySearch, "gh_pr_name");
+    }
+  }, [pullRequests, searchValue, sortOrder]);
+
+  if (!filteredPullRequests?.length) {
+    const noPullRequestsMessage = pullRequests?.length
+      ? `
+        No pull requests match your search. Try searching for a different pull request.
+    `
+      : `You do not have any pull requests.`;
+
+    return (
+      <>
+        <Br height="30px" />
+        <Placeholder height="370px">{noPullRequestsMessage}</Placeholder>
+      </>
+    );
+  }
+
+  return (
+    <>
+      <Helper>
+        Select an open pull request to preview. Pull requests must contain a{" "}
+        <Code>porter.yaml</Code> file.
+      </Helper>
+      <FlexRow>
+        <Flex>
+          <SearchRowWrapper>
+            <SearchBarWrapper>
+              <i className="material-icons">search</i>
+              <SearchInput
+                value={searchValue}
+                onChange={(e: any) => {
+                  setSearchValue(e.target.value);
+                }}
+                placeholder="Search"
+              />
+            </SearchBarWrapper>
+          </SearchRowWrapper>
+        </Flex>
+        <Flex>
+          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
+            <i className="material-icons">refresh</i>
+          </RefreshButton>
+          {/* TODO: Uncomment when we support sorting. Right now we dont get dates from backend */}
+          {/* <RadioFilter
+            icon={sort}
+            selected={sortOrder}
+            setSelected={setSortOrder}
+            options={[
+              { label: "Newest", value: "Newest" },
+              { label: "Oldest", value: "Oldest" },
+              { label: "Alphabetical", value: "Alphabetical" },
+            ]}
+            name="Sort"
+          /> */}
+        </Flex>
+      </FlexRow>
+      <Br height="10px" />
+      <PullRequestList>
+        {(filteredPullRequests ?? []).map(
+          (pullRequest: PullRequest, i: number) => {
+            return (
+              <PullRequestRow
+                onClick={() => {
+                  handlePRRowItemClick(pullRequest);
+                }}
+                isLast={i === filteredPullRequests.length - 1}
+                isSelected={pullRequest === selectedPR}
+              >
+                <PRName>
+                  <PRIcon src={pr_icon} alt="pull request icon" />
+                  <EllipsisTextWrapper tooltipText={pullRequest.pr_title}>
+                    {pullRequest.pr_title}
+                  </EllipsisTextWrapper>
+                </PRName>
+
+                <Flex>
+                  <DeploymentImageContainer>
+                    {/* <InfoWrapper>
+                  <LastDeployed>
+                    #{pullRequest.pr_number} last updated xyz
+                  </LastDeployed>
+                </InfoWrapper>
+                <SepDot>•</SepDot> */}
+                    <MergeInfoWrapper>
+                      <MergeInfo>
+                        {pullRequest.branch_from}
+                        <i className="material-icons">arrow_forward</i>
+                        {pullRequest.branch_into}
+                      </MergeInfo>
+                    </MergeInfoWrapper>
+                  </DeploymentImageContainer>
+                </Flex>
+              </PullRequestRow>
+            );
+          }
+        )}
+      </PullRequestList>
+      {showErrorsModal && selectedPR ? (
+        <PorterYAMLErrorsModal
+          errors={porterYAMLErrors}
+          onClose={() => setShowErrorsModal(false)}
+          repo={selectedPR.repo_owner + "/" + selectedPR.repo_name}
+          branch={selectedPR.branch_from}
+        />
+      ) : null}
+      {selectedPR && porterYAMLErrors.length ? (
+        <ValidationErrorBannerWrapper>
+          <Banner type="warning">
+            We found some errors in the porter.yaml file in the&nbsp;
+            {selectedPR.branch_from}&nbsp;branch. &nbsp;
+            <LearnMoreButton onClick={() => setShowErrorsModal(true)}>
+              Learn more
+            </LearnMoreButton>
+          </Banner>
+        </ValidationErrorBannerWrapper>
+      ) : null}
+      <CreatePreviewDeploymentWrapper>
+        <SubmitButton
+          onClick={handleCreatePreviewDeployment}
+          disabled={loading || !selectedPR || porterYAMLErrors.length > 0}
+        >
+          Create preview deployment
+        </SubmitButton>
+        {selectedPR && porterYAMLErrors.length ? (
+          <RevalidatePorterYAMLSpanWrapper>
+            Please fix your porter.yaml file to continue.{" "}
+            <RevalidateSpan
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+
+                if (!selectedPR) {
+                  return;
+                }
+
+                handlePRRowItemClick(selectedPR);
+              }}
+            >
+              Refresh
+            </RevalidateSpan>
+          </RevalidatePorterYAMLSpanWrapper>
+        ) : null}
+      </CreatePreviewDeploymentWrapper>
+    </>
+  );
+};
+
+export default CreatePREnvironment;
+
+const PullRequestList = styled.div`
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  overflow: hidden;
+  margin-top: 33px;
+`;
+
+const PullRequestRow = styled.div<{ isLast?: boolean; isSelected?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "#26292e")};
+  border-bottom: ${(props) => (props.isLast ? "" : "1px solid #494b4f")};
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const Code = styled.span`
+  font-family: monospace; ;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 10px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  margin-left: 10px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const MergeInfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 8px;
+  position: relative;
+  margin-left: 10px;
+`;
+
+const MergeInfo = styled.div`
+  font-size: 13px;
+  align-items: center;
+  color: #aaaabb66;
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 300px;
+
+  > i {
+    font-size: 16px;
+    margin: 0 2px;
+  }
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 16px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const SubmitButton = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 5px;
+  font-weight: 500;
+  color: white;
+  height: 30px;
+  padding: 0 8px;
+  width: 200px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Br = styled.div<{ height: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "2px"};
+`;
+
+const ValidationErrorBannerWrapper = styled.div`
+  margin-block: 20px;
+`;
+
+const LearnMoreButton = styled.div`
+  text-decoration: underline;
+  fontweight: bold;
+  cursor: pointer;
+`;
+
+const CreatePreviewDeploymentWrapper = styled.div`
+  margin-top: 30px;
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const RevalidatePorterYAMLSpanWrapper = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const RevalidateSpan = styled.span`
+  color: #aaaabb;
+  text-decoration: underline;
+  cursor: pointer;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 100%;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 30px;
+  margin-right: 10px;
+  background: #26292e;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-radius: 5px;
+  width: 250px;
+  margin-top: 35px;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 8px;
+    font-size: 16px;
+    margin-right: 8px;
+  }
+`;
+
+const FlexRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 10px;
+`;
+
+const RefreshButton = styled.button`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: ${(props: { color: string }) => props.color};
+  cursor: pointer;
+  border: none;
+  width: 30px;
+  height: 30px;
+  margin-right: 15px;
+  background: none;
+  border-radius: 50%;
+  margin-left: 10px;
+  > i {
+    font-size: 20px;
+  }
+  :hover {
+    background-color: rgb(97 98 102 / 44%);
+    color: white;
+  }
+`;