Explorar o código

Merge branch 'nico/implement-pr-envs-fe-sketch' into trevor/pr-env-github-api

sunguroku %!s(int64=4) %!d(string=hai) anos
pai
achega
e912017d1a

+ 3 - 0
dashboard/src/assets/pull_request_icon.svg

@@ -0,0 +1,3 @@
+<svg id="Flat" fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
+  <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z"/>
+</svg>

+ 24 - 0
dashboard/src/components/DynamicLink.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+import { Link, LinkProps } from "react-router-dom";
+
+const DynamicLink: React.FC<LinkProps> = ({ to, children, ...props }) => {
+  // It is a simple element with nothing to link to
+  if (!to) return <span {...props}>{children}</span>;
+
+  // It is intended to be an external link
+  if (typeof to === "string" && /^https?:\/\//.test(to))
+    return (
+      <a href={to} {...props}>
+        {children}
+      </a>
+    );
+
+  // Finally, it is an internal link
+  return (
+    <Link to={to} {...props}>
+      {children}
+    </Link>
+  );
+};
+
+export default DynamicLink;

+ 22 - 4
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -24,6 +24,7 @@ type Props = {
   // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
+  disableBottomPadding?: boolean;
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -35,6 +36,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   namespace,
   sortType,
   currentView,
+  disableBottomPadding,
 }) => {
   const {
     newWebsocket,
@@ -129,7 +131,7 @@ const ChartList: React.FunctionComponent<Props> = ({
                 return chart;
               });
             case "DELETE":
-              return currentCharts.filter((chart) => !isSameChart(chart));
+              return currentCharts?.filter((chart) => !isSameChart(chart));
             default:
               return currentCharts;
           }
@@ -300,10 +302,16 @@ const ChartList: React.FunctionComponent<Props> = ({
         }
       });
     }
-    return () => (isSubscribed = false);
+    return () => {
+      isSubscribed = false;
+    };
   }, [namespace, currentView]);
 
   const filteredCharts = useMemo(() => {
+    if (!Array.isArray(charts)) {
+      return [];
+    }
+
     const result = charts
       .filter((chart: ChartType) => {
         return (
@@ -386,7 +394,11 @@ const ChartList: React.FunctionComponent<Props> = ({
     });
   };
 
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
+  return (
+    <StyledChartList disableBottomPadding={disableBottomPadding}>
+      {renderChartList()}
+    </StyledChartList>
+  );
 };
 
 export default ChartList;
@@ -417,5 +429,11 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledChartList = styled.div`
-  padding-bottom: 105px;
+  padding-bottom: ${(props: { disableBottomPadding: boolean }) => {
+    if (props.disableBottomPadding) {
+      return "unset";
+    }
+
+    return "105px";
+  }};
 `;

+ 24 - 4
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -12,13 +12,23 @@ import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
 import EventsTab from "./events/EventsTab";
-
-type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "events";
+import EnvironmentList from "./preview-environments/EnvironmentList";
+import { useLocation } from "react-router";
+import { getQueryParam } from "shared/routing";
+
+type TabEnum =
+  | "preview_environments"
+  | "nodes"
+  | "settings"
+  | "namespaces"
+  | "metrics"
+  | "events";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
+  { label: "PR Preview", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Events", value: "events" },
   { label: "Metrics", value: "metrics" },
@@ -27,13 +37,16 @@ const tabOptions: {
 ];
 
 export const Dashboard: React.FunctionComponent = () => {
-  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const [currentTab, setCurrentTab] = useState<TabEnum>("preview_environments");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
+  const location = useLocation();
 
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
+      case "preview_environments":
+        return <EnvironmentList />;
       case "events":
         return <EventsTab />;
       case "settings":
@@ -59,6 +72,13 @@ export const Dashboard: React.FunctionComponent = () => {
     );
   }, [isAuthorized]);
 
+  useEffect(() => {
+    const selectedTab = getQueryParam({ location }, "selected_tab");
+    if (tabOptions.find((tab) => tab.value === selectedTab)) {
+      setCurrentTab(selectedTab as any);
+    }
+  }, [location]);
+
   return (
     <>
       <TitleSection>
@@ -133,7 +153,7 @@ const InfoLabel = styled.div`
 `;
 
 const InfoSection = styled.div`
-  margin-top: 20px;
+  margin-top: 36px;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
   margin-bottom: 35px;

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -2,6 +2,7 @@ import React from "react";
 import { Route, Switch, useRouteMatch } from "react-router";
 import { Dashboard } from "./Dashboard";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
+import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 export const Routes = () => {
   const { url } = useRouteMatch();
@@ -11,6 +12,9 @@ export const Routes = () => {
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>
+        <Route path={`${url}/pr-env-detail/:repoId`}>
+          <EnvironmentDetail />
+        </Route>
         <Route path={`${url}/`}>
           <Dashboard />
         </Route>

+ 296 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx

@@ -0,0 +1,296 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import backArrow from "assets/back_arrow.png";
+import TitleSection from "components/TitleSection";
+import pr_icon from "assets/pull_request_icon.svg";
+import { useRouteMatch } from "react-router";
+import DynamicLink from "components/DynamicLink";
+import { capitalize, Environment } from "./EnvironmentList";
+import Loading from "components/Loading";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import ChartList from "../../chart/ChartList";
+import github from "assets/github-white.png";
+
+const mockEnvironment = {
+  id: 1,
+  url: "https://porter.run",
+  pr_link: "https://githubsuperpullrequest.com",
+  status: "deployed",
+  namespace: "default",
+  actions_link: "https://githubsuperactions.com",
+};
+
+const getMockData = () =>
+  new Promise<{ data: Environment }>((resolve) => {
+    setTimeout(() => {
+      resolve({ data: mockEnvironment });
+      // resolve({ data: [] });
+    }, 2000);
+  });
+
+const EnvironmentDetail = () => {
+  const { params } = useRouteMatch<{ repoId: string }>();
+  const context = useContext(Context);
+  const [environment, setEnvironment] = useState<Environment>(null);
+
+  useEffect(() => {
+    // TODO: FETCH REPO OR PR?
+    console.log(params.repoId);
+
+    getMockData().then((res) => {
+      setEnvironment(res.data);
+    });
+  }, [params]);
+
+  if (!environment) {
+    return <Loading />;
+  }
+
+  return (
+    <StyledExpandedChart>
+      <HeaderWrapper>
+        <BackButton to={"/cluster-dashboard?selected_tab=preview_environments"}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <Title icon={pr_icon} iconWidth="25px">
+          {environment.url}
+          <TagWrapper>
+            Namespace <NamespaceTag>{environment.namespace}</NamespaceTag>
+          </TagWrapper>
+        </Title>
+        <InfoWrapper>
+          <PRLink to={environment.pr_link} target="_blank">
+            <i className="material-icons">link</i>
+            {environment.pr_link}
+          </PRLink>
+        </InfoWrapper>
+        <Flex>
+          <Status>
+            <StatusDot status={environment.status} />
+            {capitalize(environment.status)}
+          </Status>
+          <Dot>•</Dot>
+          <GHALink to={environment.actions_link} target="_blank">
+            <img src={github} /> GitHub Action
+            <i className="material-icons">open_in_new</i>
+          </GHALink>
+        </Flex>
+        <LinkToActionsWrapper></LinkToActionsWrapper>
+      </HeaderWrapper>
+      <LineBreak />
+      <ChartListWrapper>
+        <ChartList
+          currentCluster={context.currentCluster}
+          currentView="cluster-dashboard"
+          sortType="Newest"
+          namespace={environment.namespace}
+          disableBottomPadding
+        />
+      </ChartListWrapper>
+    </StyledExpandedChart>
+  );
+};
+
+export default EnvironmentDetail;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;
+
+const GHALink = styled(DynamicLink)`
+  font-size: 13px;
+  font-weight: 400;
+  margin-left: 7px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    text-decoration: underline;
+    color: white;
+  }
+
+  > img {
+    height: 16px;
+    margin-right: 9px;
+    margin-left: 5px;
+
+    :text-decoration: none;
+    :hover {
+      text-decoration: underline;
+      color: white;
+    }
+  }
+
+  > i {
+    margin-left: 7px;
+    font-size: 17px;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin-bottom: 20px;
+`;
+
+const BackButton = styled(DynamicLink)`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const Dot = styled.div`
+  margin-left: 9px;
+  font-size: 14px;
+  color: #ffffff33;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  width: auto;
+  justify-content: space-between;
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  flex-direction: column;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Title = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 4px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "deployed"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const PRLink = styled(DynamicLink)`
+  margin-left: 0px;
+  display: flex;
+  margin-top: 1px;
+  align-items: center;
+  font-size: 13px;
+  > i {
+    font-size: 15px;
+    margin-right: 10px;
+  }
+`;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const LinkToActionsWrapper = styled.div`
+  width: 100%;
+  margin-top: 15px;
+  margin-bottom: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 395 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx

@@ -0,0 +1,395 @@
+import DynamicLink from "components/DynamicLink";
+import React, { useEffect, useState } from "react";
+import { useHistory, useLocation, useRouteMatch } from "react-router";
+import { getQueryParam } from "shared/routing";
+import styled from "styled-components";
+import SortSelector from "../../SortSelector";
+import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
+import ConnectNewRepo from "./components/ConnectNewRepo";
+import Loading from "components/Loading";
+import pr_icon from "assets/pull_request_icon.svg";
+
+export type Environment = {
+  id: number;
+  url: string;
+  pr_link: string;
+  status: string;
+  namespace: string;
+  actions_link: string;
+};
+
+const mockData: Environment[] = [
+  {
+    id: 1,
+    url: "http://some-url",
+    pr_link: "https://githubsuper",
+    status: "deployed",
+    namespace: "stuff",
+    actions_link: "https://githubsuperactions.com",
+  },
+  {
+    id: 2,
+    url: "http://some-url",
+    pr_link: "https://githubsuper",
+    status: "deployed",
+    namespace: "stuff",
+    actions_link: "https://githubsuperactions.com",
+  },
+  {
+    id: 3,
+    url: "http://some-url",
+    pr_link: "https://githubsuper",
+    status: "deployed",
+    namespace: "stuff",
+    actions_link: "https://githubsuperactions.com",
+  },
+  {
+    id: 4,
+    url: "http://some-url",
+    pr_link: "https://githubsuper",
+    status: "deployed",
+    namespace: "stuff",
+    actions_link: "https://githubsuperactions.com",
+  },
+];
+
+const getMockData = () =>
+  new Promise<{ data: Environment[] }>((resolve) => {
+    setTimeout(() => {
+      resolve({ data: mockData });
+      // resolve({ data: [] });
+    }, 2000);
+  });
+
+export const capitalize = (s: string) => {
+  return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
+};
+
+const EnvironmentList = () => {
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [environmentList, setEnvironmentList] = useState<Environment[]>([]);
+  const [showConnectRepoFlow, setShowConnectRepoFlow] = useState(false);
+
+  const { url: currentUrl } = useRouteMatch();
+
+  const location = useLocation();
+  const history = useHistory();
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    // TODO: Replace get mock data by endpoint
+    getMockData()
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        setEnvironmentList(data);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+          setEnvironmentList([]);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  useEffect(() => {
+    const action = getQueryParam({ location }, "action");
+    console.log("HERE", action, location);
+    if (action === "connect-repo") {
+      setShowConnectRepoFlow(true);
+    } else {
+      setShowConnectRepoFlow(false);
+    }
+  }, [location.search, history]);
+
+  if (showConnectRepoFlow) {
+    return (
+      <Container>
+        <ConnectNewRepo />
+      </Container>
+    );
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (hasError) {
+    return <>Unexpected error occured, please try again later</>;
+  }
+
+  if (!environmentList.length) {
+    return (
+      <>
+        <ButtonEnablePREnvironments />
+      </>
+    );
+  }
+
+  return (
+    <Container>
+      <ControlRow>
+        <Button
+          to={`${currentUrl}?selected_tab=preview_environments&action=connect-repo`}
+          onClick={() => console.log("launch repo")}
+        >
+          <i className="material-icons">add</i> Add Repository
+        </Button>
+        <SortFilterWrapper>
+          {/* <SortSelector
+            setSortType={(sortType) => this.setState({ sortType })}
+            sortType={this.state.sortType}
+          /> */}
+        </SortFilterWrapper>
+      </ControlRow>
+      <EventsGrid>
+        {environmentList.map((env) => {
+          return (
+            <EnvironmentCard>
+              <DataContainer>
+                <PRName>
+                  <PRIcon src={pr_icon} alt="pull request icon" />
+                  {env.url}
+                </PRName>
+                <StatusContainer>
+                  <Status>
+                    <StatusDot status={env.status} />
+                    {capitalize(env.status)}
+                  </Status>
+                </StatusContainer>
+              </DataContainer>
+              <Flex>
+                <RowButton
+                  to={`${currentUrl}/pr-env-detail/${env.id}`}
+                  key={env.pr_link}
+                >
+                  <i className="material-icons-outlined">info</i>
+                  Details
+                </RowButton>
+                <RowButton to={env.pr_link} target="_blank">
+                  <i className="material-icons">open_in_new</i>
+                  View Live
+                </RowButton>
+              </Flex>
+            </EnvironmentCard>
+          );
+        })}
+      </EventsGrid>
+    </Container>
+  );
+};
+
+export default EnvironmentList;
+
+const Placeholder = styled.div`
+  height: 300px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const PRName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const Container = styled.div`
+  margin-top: 33px;
+  padding-bottom: 120px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const Button = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  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 SortFilterWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  > div:not(:first-child) {
+    margin-left: 30px;
+  }
+`;
+
+const EnvironmentCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 20px;
+  grid-template-columns: 1;
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+`;
+
+const StatusContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  height: 100%;
+`;
+
+const DataPRUrl = styled.span`
+  font-size: 16px;
+  display: flex;
+  align-items: center;
+`;
+
+const PRIcon = styled.img`
+  font-size: 20px;
+  height: 17px;
+  margin-right: 10px;
+  color: #aaaabb;
+  opacity: 50%;
+`;
+
+const RowButton = styled(DynamicLink)`
+  font-size: 12px;
+  padding: 8px 10px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  display: flex;
+  align-items: center;
+  background: #ffffff08;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  margin-right: 15px;
+  background: ${(props: { status: string }) =>
+    props.status === "deployed"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+`;

+ 124 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -0,0 +1,124 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import pr_icon from "assets/pull_request_icon.svg";
+import { Link } from "react-router-dom";
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+
+// TODO: Billing is still not capable to show if a user can use or not PR environments, add that instead of "hasBillingEnabled"
+const ButtonEnablePREnvironments = () => {
+  const { hasBillingEnabled } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasGHAccountConnected, setHasGHAccountConnected] = useState(false);
+
+  const getAccounts = async () => {
+    setIsLoading(true);
+    try {
+      const res = await api.getGithubAccounts("<token>", {}, {});
+      if (res.status !== 200) {
+        throw new Error("Not authorized");
+      }
+      return res.data;
+    } catch (error) {
+      console.log(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getAccounts().then((accountsData) => {
+      if (isSubscribed) {
+        if (!accountsData) {
+          setHasGHAccountConnected(true);
+        } else {
+          setHasGHAccountConnected(true);
+        }
+      }
+    });
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  const getButtonProps = () => {
+    const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+
+    const encoded_redirect_uri = encodeURIComponent(url);
+
+    const backendUrl = `${window.location.protocol}//${window.location.host}`;
+
+    if (!hasGHAccountConnected) {
+      return {
+        to: `${backendUrl}/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`,
+        target: "_self",
+      };
+    }
+
+    if (!hasBillingEnabled) {
+      return {
+        to: {
+          pathname: "/project-settings",
+          search: "?selected_tab=billing",
+        },
+      };
+    }
+    return {
+      to:
+        "/cluster-dashboard?selected_tab=preview_environments&action=connect-repo",
+    };
+  };
+
+  if (isLoading) {
+    return (
+      <Container>
+        <Loading />
+      </Container>
+    );
+  }
+  return (
+    <>
+      <Container>
+        <Button {...getButtonProps()}>
+          <img src={pr_icon} alt="Pull request icon" />
+          Enable PR environments
+        </Button>
+      </Container>
+    </>
+  );
+};
+
+export default ButtonEnablePREnvironments;
+
+const Button = styled(DynamicLink)`
+  background-color: #616feecc;
+  border: none;
+  border-radius: 15px;
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px 20px;
+  font-size: 16px;
+  cursor: pointer;
+  img {
+    margin-right: 10px;
+    width: 30px;
+    height: 30px;
+  }
+  transition: background-color 150ms ease-out;
+  :hover {
+    background-color: #616feefb;
+  }
+`;
+
+const Container = styled.div`
+  width: 100%;
+  min-height: 400px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;

+ 105 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx

@@ -0,0 +1,105 @@
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import SelectRow from "components/form-components/SelectRow";
+import SaveButton from "components/SaveButton";
+import Selector from "components/Selector";
+import TitleSection from "components/TitleSection";
+import React from "react";
+import { useRouteMatch } from "react-router";
+import styled from "styled-components";
+
+const porterYamlDocsLink = "https://docs.porter.run";
+
+const ConnectNewRepo = () => {
+  const { url } = useRouteMatch();
+  return (
+    <div>
+      <ControlRow>
+        <BackButton to={`${url}?selected_tab=preview_environments`}>
+          <i className="material-icons">close</i>
+        </BackButton>
+        <Title>Connect a new repo</Title>
+      </ControlRow>
+
+      <Heading>Select repo</Heading>
+      <br />
+      <Selector
+        width="100%"
+        options={[]}
+        setActiveValue={console.log}
+        activeValue=""
+      />
+
+      <Heading>Disclaimer</Heading>
+      <Helper>
+        You will need to add a porter.yaml file to let porter know how to create
+        the preview environment
+      </Helper>
+      <PorterYamlLink to={porterYamlDocsLink} target="_blank">
+        Know more about porter.yaml
+      </PorterYamlLink>
+      <ActionContainer>
+        <SaveButton
+          text="Connect repo"
+          disabled={false}
+          onClick={() => console.log()}
+          makeFlush={true}
+          clearPosition={true}
+          status={""}
+          statusPosition={"left"}
+        ></SaveButton>
+      </ActionContainer>
+    </div>
+  );
+};
+
+export default ConnectNewRepo;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const BackButton = styled(DynamicLink)`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  color: white;
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const Title = styled(TitleSection)`
+  margin-left: 10px;
+  margin-bottom: 0;
+  font-size: 18px;
+`;
+
+const PorterYamlLink = styled(DynamicLink)`
+  font-size: 14px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 50px;
+`;