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

updated entry point for configuring preview envs (#4082)

ianedwards 2 жил өмнө
parent
commit
3faac8e503

+ 4 - 6
dashboard/src/lib/revisions/types.ts

@@ -25,12 +25,10 @@ export const appRevisionValidator = z.object({
   ]),
   b64_app_proto: z.string(),
   revision_number: z.number(),
-  deployment_target: z.object(
-{
-        id: z.string(),
-        name: z.string()
-      }
-  ),
+  deployment_target: z.object({
+    id: z.string(),
+    name: z.string(),
+  }),
   id: z.string(),
   created_at: z.string(),
   updated_at: z.string(),

+ 11 - 3
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -57,6 +57,7 @@ type LatestRevisionContextType = {
   appEnv?: PopulatedEnvGroup;
   setPreviewRevision: Dispatch<SetStateAction<AppRevision | null>>;
   latestClientServices: ClientService[];
+  loading: boolean;
 };
 
 const LatestRevisionContext = createContext<LatestRevisionContextType | null>(
@@ -75,11 +76,13 @@ export const useLatestRevision = (): LatestRevisionContextType => {
 
 type LatestRevisionProviderProps = {
   appName?: string;
+  showLoader?: boolean;
   children: JSX.Element;
 };
 
 export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
   appName,
+  showLoader = true,
   children,
 }) => {
   const [previewRevision, setPreviewRevision] = useState<AppRevision | null>(
@@ -307,12 +310,16 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
     );
   }, [latestPorterAppNotifications, latestClientServices]);
 
-  if (
+  const loading =
     status === "loading" ||
     porterAppStatus === "loading" ||
     !appParamsExist ||
-    porterYamlLoading
-  ) {
+    porterYamlLoading;
+
+  if (loading) {
+    if (!showLoader) {
+      return null;
+    }
     return <Loading />;
   }
 
@@ -354,6 +361,7 @@ export const LatestRevisionProvider: React.FC<LatestRevisionProviderProps> = ({
         setPreviewRevision,
         latestClientServices,
         appName,
+        loading,
       }}
     >
       {children}

+ 1 - 6
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -19,7 +19,6 @@ import document from "assets/document.svg";
 import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
 import { useLatestRevision } from "../LatestRevisionContext";
 import ExportAppModal from "./ExportAppModal";
-import PreviewEnvironmentSettings from "./preview-environments/PreviewEnvironmentSettings";
 
 const Settings: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
@@ -27,7 +26,7 @@ const Settings: React.FC = () => {
   const history = useHistory();
   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
   const [isExportModalOpen, setIsExportModalOpen] = useState(false);
-  const { porterApp, clusterId, projectId, latestProto } = useLatestRevision();
+  const { porterApp, clusterId, projectId } = useLatestRevision();
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
   const { control } = useFormContext<PorterAppFormData>();
@@ -147,10 +146,6 @@ const Settings: React.FC = () => {
 
   return (
     <StyledSettingsTab>
-      {currentProject?.preview_envs_enabled && !!latestProto.build ? (
-        <PreviewEnvironmentSettings />
-      ) : null}
-
       {currentCluster?.cloud_provider === "AWS" &&
         currentProject?.efs_enabled && (
           <>

+ 50 - 104
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -1,27 +1,25 @@
-import React, {useContext, useMemo} from "react";
-import { type AppRevisionWithSource } from "./types";
-import { search } from "shared/search";
+import React, { useContext, useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
 import _ from "lodash";
-import { match } from "ts-pattern";
 import { Link } from "react-router-dom";
+import styled from "styled-components";
+import { match } from "ts-pattern";
 
-import web from "assets/web.png";
-import box from "assets/box.png";
-import time from "assets/time.png";
-import target from "assets/target.svg";
-import notFound from "assets/not-found.png";
-import github from "assets/github.png";
-
-import Fieldset from "components/porter/Fieldset";
 import Container from "components/porter/Container";
-import Text from "components/porter/Text";
-import styled from "styled-components";
-import { PorterApp } from "@porter-dev/api-contracts";
-import Icon from "components/porter/Icon";
+import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
-import { readableDate } from "shared/string_utils";
+import Text from "components/porter/Text";
+
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
-import {Context} from "../../../../shared/Context";
+import { search } from "shared/search";
+import { readableDate } from "shared/string_utils";
+import notFound from "assets/not-found.png";
+import target from "assets/target.svg";
+import time from "assets/time.png";
+
+import { Context } from "../../../../shared/Context";
+import { AppIcon, AppSource } from "./AppMeta";
+import { type AppRevisionWithSource } from "./types";
 
 type AppGridProps = {
   apps: AppRevisionWithSource[];
@@ -30,17 +28,10 @@ type AppGridProps = {
   sort: "letter" | "calendar";
 };
 
-const icons = [
-  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
-  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg",
-  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-plain.svg",
-  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/go/go-original-wordmark.svg",
-  web,
-];
-
 const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
   const { currentDeploymentTarget } = useDeploymentTarget();
   const { currentProject } = useContext(Context);
+
   const appsWithProto = useMemo(() => {
     return apps.map((app) => {
       return {
@@ -82,64 +73,6 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
       .exhaustive();
   }, [appsWithProto, searchValue, sort]);
 
-  const renderIcon = (bp: string[], size?: string): JSX.Element => {
-    let src = box;
-    if (bp.length) {
-      const [_, name] = bp[0].split("/");
-      switch (name) {
-        case "ruby":
-          src = icons[0];
-          break;
-        case "nodejs":
-          src = icons[1];
-          break;
-        case "python":
-          src = icons[2];
-          break;
-        case "go":
-          src = icons[3];
-          break;
-        default:
-          break;
-      }
-    }
-    return (
-      <>
-        {size === "larger" ? (
-          <Icon height="16px" src={src} />
-        ) : (
-          <Icon height="18px" src={src} />
-        )}
-      </>
-    );
-  };
-
-  const renderSource = (source: AppRevisionWithSource["source"]): JSX.Element => {
-    return (
-      <>
-        {source.repo_name ? (
-          <Container row>
-            <SmallIcon opacity="0.6" src={github} />
-            <Text truncate={true} size={13} color="#ffffff44">
-              {source.repo_name}
-            </Text>
-          </Container>
-        ) : (
-          <Container row>
-            <SmallIcon
-              opacity="0.7"
-              height="18px"
-              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
-            />
-            <Text truncate={true} size={13} color="#ffffff44">
-              {source.image_repo_uri}
-            </Text>
-          </Container>
-        )}
-      </>
-    );
-  };
-
   if (filteredApps.length === 0) {
     return (
       <Fieldset>
@@ -155,39 +88,49 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
     .with("grid", () => (
       <GridList>
         {(filteredApps ?? []).map(
-          ({ app_revision: { proto, updated_at: updatedAt, deployment_target: deploymentTarget }, source }, i) => {
-
+          (
+            {
+              app_revision: {
+                proto,
+                updated_at: updatedAt,
+                deployment_target: deploymentTarget,
+              },
+              source,
+            },
+            i
+          ) => {
             let appLink = `/apps/${proto.name}`;
             if (currentProject?.managed_deployment_targets_enabled) {
-                appLink = `/apps/${proto.name}/activity?target=${deploymentTarget.id}`;
+              appLink = `/apps/${proto.name}/activity?target=${deploymentTarget.id}`;
             }
             if (currentDeploymentTarget?.is_preview) {
-                appLink = `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`;
+              appLink = `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`;
             }
 
             return (
-              <Link
-                to={appLink}
-                key={i}
-              >
+              <Link to={appLink} key={i}>
                 <Block>
                   <Container row>
-                    {renderIcon(proto.build?.buildpacks ?? [])}
+                    <AppIcon
+                      buildpacks={proto.build?.buildpacks ?? []}
+                      size="larger"
+                    />
                     <Spacer inline width="12px" />
                     <Text size={14}>{proto.name}</Text>
                     <Spacer inline x={2} />
                   </Container>
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
-                  {renderSource(source)}
-                  {currentProject?.managed_deployment_targets_enabled && !currentDeploymentTarget?.is_preview && (
-                    <Container row>
-                      <SmallIcon opacity="0.4" src={target} />
-                      <Text size={13} color="#ffffff44">
-                        {deploymentTarget.name}
-                      </Text>
-                    </Container>
-                  )}
+                  <AppSource source={source} />
+                  {currentProject?.managed_deployment_targets_enabled &&
+                    !currentDeploymentTarget?.is_preview && (
+                      <Container row>
+                        <SmallIcon opacity="0.4" src={target} />
+                        <Text size={13} color="#ffffff44">
+                          {deploymentTarget.name}
+                        </Text>
+                      </Container>
+                    )}
                   <Container row>
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">
@@ -217,7 +160,10 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                 <Row>
                   <Container row>
                     <Spacer inline width="1px" />
-                    {renderIcon(proto.build?.buildpacks ?? [], "larger")}
+                    <AppIcon
+                      buildpacks={proto.build?.buildpacks ?? []}
+                      size="larger"
+                    />
                     <Spacer inline width="12px" />
                     <Text size={14}>{proto.name}</Text>
                     <Spacer inline x={1} />
@@ -226,7 +172,7 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
                   </Container>
                   <Spacer height="15px" />
                   <Container row>
-                    {renderSource(source)}
+                    <AppSource source={source} />
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">

+ 92 - 0
dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx

@@ -0,0 +1,92 @@
+import React from "react";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Text from "components/porter/Text";
+
+import box from "assets/box.png";
+import github from "assets/github.png";
+import web from "assets/web.png";
+
+import { type AppRevisionWithSource } from "./types";
+
+const icons = [
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/nodejs/nodejs-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-plain.svg",
+  "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/go/go-original-wordmark.svg",
+  web,
+];
+
+type IconProps = {
+  buildpacks: string[];
+  size?: string;
+};
+
+type SourceProps = {
+  source: AppRevisionWithSource["source"];
+};
+
+export const AppSource: React.FC<SourceProps> = ({ source }) => {
+  return (
+    <>
+      {source.repo_name ? (
+        <Container row>
+          <SmallIcon opacity="0.6" src={github} />
+          <Text truncate={true} size={13} color="#ffffff44">
+            {source.repo_name}
+          </Text>
+        </Container>
+      ) : (
+        <Container row>
+          <SmallIcon
+            opacity="0.7"
+            height="18px"
+            src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+          />
+          <Text truncate={true} size={13} color="#ffffff44">
+            {source.image_repo_uri}
+          </Text>
+        </Container>
+      )}
+    </>
+  );
+};
+
+export const AppIcon: React.FC<IconProps> = ({ buildpacks, size }) => {
+  let src = box;
+  if (buildpacks.length) {
+    const [_, name] = buildpacks[0].split("/");
+    switch (name) {
+      case "ruby":
+        src = icons[0];
+        break;
+      case "nodejs":
+        src = icons[1];
+        break;
+      case "python":
+        src = icons[2];
+        break;
+      case "go":
+        src = icons[3];
+        break;
+      default:
+        break;
+    }
+  }
+
+  return size === "larger" ? (
+    <Icon height="16px" src={src} />
+  ) : (
+    <Icon height="18px" src={src} />
+  );
+};
+
+const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
+  margin-left: 2px;
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
+  margin-right: 10px;
+`;

+ 120 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx

@@ -0,0 +1,120 @@
+import React, { useContext } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import { z } from "zod";
+
+import Loading from "components/Loading";
+import Button from "components/porter/Button";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { appRevisionWithSourceValidator } from "main/home/app-dashboard/apps/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import { ConfigurableAppRow } from "./ConfigurableAppRow";
+
+export const ConfigurableAppList: React.FC = () => {
+  const history = useHistory();
+  const queryParams = new URLSearchParams(window.location.search);
+
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { data: apps = [], status } = useQuery(
+    [
+      "getLatestAppRevisions",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+      },
+    ],
+    async () => {
+      if (
+        !currentCluster ||
+        !currentProject ||
+        currentCluster.id === -1 ||
+        currentProject.id === -1
+      ) {
+        return;
+      }
+
+      const res = await api.getLatestAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: undefined,
+          ignore_preview_apps: true,
+        },
+        { cluster_id: currentCluster.id, project_id: currentProject.id }
+      );
+
+      const apps = await z
+        .object({
+          app_revisions: z.array(appRevisionWithSourceValidator),
+        })
+        .parseAsync(res.data);
+
+      return apps.app_revisions;
+    },
+    {
+      refetchOnWindowFocus: false,
+      enabled: !!currentCluster && !!currentProject,
+    }
+  );
+
+  if (status === "loading") {
+    return <Loading offset="-150px" />;
+  }
+
+  if (apps.length === 0) {
+    return (
+      <Fieldset>
+        <CentralContainer>
+          <Text size={16}>No apps have been deployed yet.</Text>
+          <Spacer y={1} />
+
+          <Text color={"helper"}>Get started by creating a new app.</Text>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              history.push("/apps/new/app");
+            }}
+          >
+            Create App
+          </Button>
+        </CentralContainer>
+      </Fieldset>
+    );
+  }
+
+  return (
+    <List>
+      {apps.map((a) => (
+        <ConfigurableAppRow
+          key={a.source.id}
+          setEditingApp={() => {
+            queryParams.set("target", a.app_revision.deployment_target.id);
+            queryParams.set("app_name", a.source.name);
+            history.push({
+              pathname: "/preview-environments/configure",
+              search: queryParams.toString(),
+            });
+          }}
+          app={a}
+        />
+      ))}
+    </List>
+  );
+};
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;

+ 88 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppRow.tsx

@@ -0,0 +1,88 @@
+import React, { useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
+import { type AppRevisionWithSource } from "main/home/app-dashboard/apps/types";
+
+import settings from "assets/settings.svg";
+
+type Props = {
+  app: AppRevisionWithSource;
+  setEditingApp: () => void;
+};
+
+export const ConfigurableAppRow: React.FC<Props> = ({ app, setEditingApp }) => {
+  const proto = useMemo(() => {
+    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
+      ignoreUnknownFields: true,
+    });
+  }, [app.app_revision.b64_app_proto]);
+
+  return (
+    <Row>
+      <div>
+        <Container row>
+          <Spacer inline width="1px" />
+          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{proto.name}</Text>
+          <Spacer inline x={1} />
+        </Container>
+        <Spacer height="15px" />
+        <Container row>
+          <AppSource source={app.source} />
+          <Spacer inline x={1} />
+        </Container>
+      </div>
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+        }}
+      >
+        <SettingsButton
+          onClick={() => {
+            setEditingApp();
+          }}
+        >
+          <img src={settings} />
+          <Spacer inline x={0.5} />
+          Update Previews
+        </SettingsButton>
+      </div>
+    </Row>
+  );
+};
+
+const SettingsButton = styled.button`
+  background: ${(props) => props.theme.fg};
+  padding: 8px 12px;
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+const Row = styled.div<{ isAtBottom?: boolean }>`
+  padding: 15px;
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${({ theme }) => theme.fg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+  display: flex;
+  justify-content: space-between;
+`;

+ 98 - 93
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -1,36 +1,47 @@
-import React, { useMemo } from "react";
-import { match } from "ts-pattern";
+import React, {
+  useMemo,
+  useState,
+  type Dispatch,
+  type SetStateAction,
+} from "react";
 import _ from "lodash";
-import styled from "styled-components";
 import { Link } from "react-router-dom";
+import styled from "styled-components";
+import { match } from "ts-pattern";
 
-import time from "assets/time.png";
-import healthy from "assets/status-healthy.png";
-import notFound from "assets/not-found.png";
-import pull_request from "assets/pull_request_icon.svg";
-
-import { search } from "shared/search";
-import Fieldset from "components/porter/Fieldset";
+import Button from "components/porter/Button";
 import Container from "components/porter/Container";
+import Fieldset from "components/porter/Fieldset";
 import Icon from "components/porter/Icon";
+import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import Toggle from "components/porter/Toggle";
+import type { DeploymentTarget } from "lib/hooks/useDeploymentTarget";
+
+import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
-import type {DeploymentTarget} from "lib/hooks/useDeploymentTarget";
+import calendar from "assets/calendar-number.svg";
+import notFound from "assets/not-found.png";
+import pull_request from "assets/pull_request_icon.svg";
+import healthy from "assets/status-healthy.png";
+import time from "assets/time.png";
+import letter from "assets/vector.svg";
+
+import { type ValidTab } from "./PreviewEnvs";
 
 type PreviewEnvGridProps = {
   deploymentTargets: DeploymentTarget[];
-  searchValue: string;
-  view: "grid" | "list";
-  sort: "letter" | "calendar";
+  setTab: Dispatch<SetStateAction<ValidTab>>;
 };
 
 const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
   deploymentTargets,
-  searchValue,
-  view,
-  sort,
+  setTab,
 }) => {
+  const [searchValue, setSearchValue] = useState("");
+  const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+
   const filteredEnvs = useMemo(() => {
     const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
       keys: ["namespace"],
@@ -45,10 +56,34 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
       .exhaustive();
   }, [deploymentTargets, searchValue, sort]);
 
+  if (deploymentTargets.length === 0) {
+    return (
+      <Fieldset>
+        <CentralContainer>
+          <Text size={16}>No preview environments have been deployed yet.</Text>
+          <Spacer y={1} />
+
+          <Text color={"helper"}>
+            Get started by enabling preview envs for your apps.
+          </Text>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              setTab("config");
+            }}
+          >
+            Configure Preview Environments
+          </Button>
+        </CentralContainer>
+      </Fieldset>
+    );
+  }
+
   if (filteredEnvs.length === 0) {
-    let copy = "No preview environments exist. To get started with preview environments, enable them in the Settings tab of an existing application."
+    let copy =
+      "No preview environments exist. To get started with preview environments, enable them in the Settings tab of an existing application.";
     if (searchValue !== "") {
-      copy = "No matching environments were found."
+      copy = "No matching environments were found.";
     }
     return (
       <Fieldset>
@@ -60,36 +95,34 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
     );
   }
 
-  return match(view)
-    .with("grid", () => (
-      <GridList>
-        {(filteredEnvs ?? []).map((env) => {
-          return (
-            <Link
-              to={`/preview-environments/apps?target=${env.id}`}
-              key={env.namespace}
-            >
-              <Block>
-                <Container row>
-                  <Icon height="18px" src={pull_request} />
-                  <Spacer inline width="12px" />
-                  <Text size={14}>{env.namespace}</Text>
-                  <Spacer inline x={2} />
-                </Container>
-                <StatusIcon src={healthy} />
-                <Container row>
-                  <SmallIcon opacity="0.4" src={time} />
-                  <Text size={13} color="#ffffff44">
-                    {readableDate(env.created_at)}
-                  </Text>
-                </Container>
-              </Block>
-            </Link>
-          );
-        })}
-      </GridList>
-    ))
-    .with("list", () => (
+  return (
+    <>
+      <Container row spaced>
+        <SearchBar
+          value={searchValue}
+          setValue={(x) => {
+            setSearchValue(x);
+          }}
+          placeholder="Search environments . . ."
+          width="100%"
+        />
+        <Spacer inline x={2} />
+        <Toggle
+          items={[
+            { label: <ToggleIcon src={calendar} />, value: "calendar" },
+            { label: <ToggleIcon src={letter} />, value: "letter" },
+          ]}
+          active={sort}
+          setActive={(x) => {
+            if (x === "calendar") {
+              setSort("calendar");
+            } else {
+              setSort("letter");
+            }
+          }}
+        />
+      </Container>
+      <Spacer y={1} />
       <List>
         {(filteredEnvs ?? []).map((env) => {
           return (
@@ -102,13 +135,12 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
                   <Spacer inline width="1px" />
                   <Icon height="18px" src={pull_request} />
                   <Spacer inline width="12px" />
-                  <Text size={14}>{env.namespace}</Text>
+                  <Text size={14}>{env.name}</Text>
                   <Spacer inline x={1} />
                   <Icon height="16px" src={healthy} />
                 </Container>
                 <Spacer height="15px" />
                 <Container row>
-                  <Spacer inline x={1} />
                   <SmallIcon opacity="0.4" src={time} />
                   <Text size={13} color="#ffffff44">
                     {readableDate(env.created_at)}
@@ -119,8 +151,8 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           );
         })}
       </List>
-    ))
-    .exhaustive();
+    </>
+  );
 };
 
 export default PreviewEnvGrid;
@@ -131,46 +163,6 @@ const PlaceholderIcon = styled.img`
   opacity: 0.65;
 `;
 
-const GridList = styled.div`
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-`;
-
-const Block = styled.div`
-  height: 150px;
-  flex-direction: column;
-  display: flex;
-  justify-content: space-between;
-  cursor: pointer;
-  padding: 20px;
-  color: ${(props) => props.theme.text.primary};
-  position: relative;
-  border-radius: 5px;
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-  }
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const StatusIcon = styled.img`
-  position: absolute;
-  top: 20px;
-  right: 20px;
-  height: 18px;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;
@@ -195,3 +187,16 @@ const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   filter: grayscale(100%);
   margin-right: 10px;
 `;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;

+ 29 - 88
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -1,28 +1,23 @@
 import React, { useState } from "react";
 import styled from "styled-components";
+import { match } from "ts-pattern";
 
 import Loading from "components/Loading";
-import Container from "components/porter/Container";
-import Fieldset from "components/porter/Fieldset";
-import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import Toggle from "components/porter/Toggle";
+import TabSelector from "components/TabSelector";
 import { useDeploymentTargetList } from "lib/hooks/useDeploymentTarget";
 
-import calendar from "assets/calendar-number.svg";
-import grid from "assets/grid.png";
-import list from "assets/list.png";
 import PullRequestIcon from "assets/pull_request_icon.svg";
-import letter from "assets/vector.svg";
 
 import DashboardHeader from "../../DashboardHeader";
+import { ConfigurableAppList } from "./ConfigurableAppList";
 import PreviewEnvGrid from "./PreviewEnvGrid";
 
+const tabs = ["environments", "config"] as const;
+export type ValidTab = (typeof tabs)[number];
+
 const PreviewEnvs: React.FC = () => {
-  const [searchValue, setSearchValue] = useState("");
-  const [view, setView] = useState<"grid" | "list">("grid");
-  const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+  const [tab, setTab] = useState<ValidTab>("environments");
 
   const { deploymentTargetList, isDeploymentTargetListLoading } =
     useDeploymentTargetList({ preview: true });
@@ -32,72 +27,15 @@ const PreviewEnvs: React.FC = () => {
       return <Loading offset="-150px" />;
     }
 
-    if (deploymentTargetList.length === 0) {
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No preview environments have been deployed yet.</Text>
-          <Spacer y={1} />
-
-          <Text color={"helper"}>
-            Get started by enabling preview envs for your apps.
-          </Text>
-          <Spacer y={0.5} />
-        </CentralContainer>
-      </Fieldset>;
-    }
-
-    return (
-      <>
-        <Container row spaced>
-          <SearchBar
-            value={searchValue}
-            setValue={(x) => {
-              setSearchValue(x);
-            }}
-            placeholder="Search environments . . ."
-            width="100%"
-          />
-          <Spacer inline x={2} />
-          <Toggle
-            items={[
-              { label: <ToggleIcon src={calendar} />, value: "calendar" },
-              { label: <ToggleIcon src={letter} />, value: "letter" },
-            ]}
-            active={sort}
-            setActive={(x) => {
-              if (x === "calendar") {
-                setSort("calendar");
-              } else {
-                setSort("letter");
-              }
-            }}
-          />
-          <Spacer inline x={1} />
-
-          <Toggle
-            items={[
-              { label: <ToggleIcon src={grid} />, value: "grid" },
-              { label: <ToggleIcon src={list} />, value: "list" },
-            ]}
-            active={view}
-            setActive={(x) => {
-              if (x === "grid") {
-                setView("grid");
-              } else {
-                setView("list");
-              }
-            }}
-          />
-        </Container>
-        <Spacer y={1} />
+    return match(tab)
+      .with("environments", () => (
         <PreviewEnvGrid
           deploymentTargets={deploymentTargetList}
-          sort={sort}
-          view={view}
-          searchValue={searchValue}
+          setTab={setTab}
         />
-      </>
-    );
+      ))
+      .with("config", () => <ConfigurableAppList />)
+      .exhaustive();
   };
 
   return (
@@ -108,6 +46,22 @@ const PreviewEnvs: React.FC = () => {
         description="Preview environments are created for each pull request. They are automatically deleted when the pull request is closed."
         disableLineBreak
       />
+      <TabSelector
+        noBuffer
+        options={[
+          { label: "Environments", value: "environments" },
+          { label: "Settings", value: "config" },
+        ]}
+        currentTab={tab}
+        setCurrentTab={(tab: string) => {
+          if (tab === "environments") {
+            setTab("environments");
+            return;
+          }
+          setTab("config");
+        }}
+      />
+      <Spacer y={1} />
       {renderContents()}
       <Spacer y={5} />
     </StyledAppDashboard>
@@ -120,16 +74,3 @@ const StyledAppDashboard = styled.div`
   width: 100%;
   height: 100%;
 `;
-
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;
-
-const ToggleIcon = styled.img`
-  height: 12px;
-  margin: 0 5px;
-  min-width: 12px;
-`;

+ 38 - 33
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx

@@ -1,37 +1,37 @@
 import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import axios from "axios";
+import _ from "lodash";
 import { FormProvider, useForm } from "react-hook-form";
+import { Redirect, useHistory } from "react-router";
+import { z } from "zod";
 
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
+import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
+import { populatedEnvGroup } from "main/home/app-dashboard/validate-apply/app-settings/types";
+import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
 import {
-  PorterAppFormData,
-  SourceOptions,
   applyPreviewOverrides,
   clientAppFromProto,
   clientAppToProto,
   porterAppFormValidator,
+  type PorterAppFormData,
+  type SourceOptions,
 } from "lib/porter-apps";
 import {
   defaultSerialized,
   deserializeService,
 } from "lib/porter-apps/services";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
-import Spacer from "components/porter/Spacer";
-import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
-import Text from "components/porter/Text";
-import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
+
 import api from "shared/api";
-import { z } from "zod";
-import { populatedEnvGroup } from "main/home/app-dashboard/validate-apply/app-settings/types";
-import { useQuery } from "@tanstack/react-query";
-import { Redirect, useHistory } from "react-router";
-import Button from "components/porter/Button";
-import { useAppValidation } from "lib/hooks/useAppValidation";
-import { PorterApp } from "@porter-dev/api-contracts";
-import axios from "axios";
-import GithubActionModal from "main/home/app-dashboard/new-app-flow/GithubActionModal";
-import Error from "components/porter/Error";
-import _ from "lodash";
 import { useClusterResources } from "shared/ClusterResourcesContext";
 
 type Props = {
@@ -82,13 +82,13 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
         }
       );
 
-      const { environment_groups } = await z
+      const { environment_groups: envGroups } = await z
         .object({
           environment_groups: z.array(populatedEnvGroup).default([]),
         })
         .parseAsync(res.data);
 
-      return environment_groups;
+      return envGroups;
     }
   );
 
@@ -209,7 +209,7 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
         variables,
         secrets,
       });
-      history.push(`/apps/${proto.name}/settings`);
+      history.push(`/preview-environments`);
     } catch (err) {
       if (axios.isAxiosError(err) && err.response?.data?.error) {
         setCreateError(err.response?.data?.error);
@@ -336,21 +336,25 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
                 fieldArrayName={"app.predeploy"}
               />
             </>,
-            <Button
-              type="submit"
-              loadingText={"Saving..."}
-              width={"150px"}
-              status={buttonStatus}
-            >
-              {existingTemplate ? "Update Previews" : "Enable Previews"}
-            </Button>,
+            <>
+              <Button
+                type="submit"
+                loadingText={"Saving..."}
+                width={"150px"}
+                status={buttonStatus}
+              >
+                {existingTemplate ? "Update Previews" : "Enable Previews"}
+              </Button>
+            </>,
           ].filter((x) => x)}
         />
       </form>
       {showGHAModal && (
         <GithubActionModal
           type="preview"
-          closeModal={() => setShowGHAModal(false)}
+          closeModal={() => {
+            setShowGHAModal(false);
+          }}
           githubAppInstallationID={latestSource.git_repo_id}
           githubRepoOwner={latestSource.git_repo_name.split("/")[0]}
           githubRepoName={latestSource.git_repo_name.split("/")[1]}
@@ -358,8 +362,8 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
           stackName={porterApp.name}
           projectId={projectId}
           clusterId={clusterId}
-          deployPorterApp={() =>
-            createTemplateAndWorkflow({
+          deployPorterApp={async () =>
+            await createTemplateAndWorkflow({
               app: validatedAppProto,
               variables,
               secrets,
@@ -367,6 +371,7 @@ const AppTemplateForm: React.FC<Props> = ({ existingTemplate }) => {
           }
           deploymentError={createError}
           porterYamlPath={latestSource.porter_yaml_path}
+          redirectPath={"/preview-environments"}
         />
       )}
     </FormProvider>

+ 13 - 12
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx

@@ -1,23 +1,24 @@
 import React, { useContext, useMemo } from "react";
-import { RouteComponentProps, withRouter } from "react-router";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
+import { match } from "ts-pattern";
+import { z } from "zod";
 
-import pull_request from "assets/pull_request_icon.svg";
-
+import Loading from "components/Loading";
 import Back from "components/porter/Back";
-import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import Spacer from "components/porter/Spacer";
-import AppTemplateForm from "./AppTemplateForm";
 import { LatestRevisionProvider } from "main/home/app-dashboard/app-view/LatestRevisionContext";
-import { useQuery } from "@tanstack/react-query";
-import { Context } from "shared/Context";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+
 import api from "shared/api";
-import { match } from "ts-pattern";
-import { z } from "zod";
-import { PorterApp } from "@porter-dev/api-contracts";
-import Loading from "components/Loading";
+import { Context } from "shared/Context";
+import pull_request from "assets/pull_request_icon.svg";
+
+import AppTemplateForm from "./AppTemplateForm";
 
-type Props = RouteComponentProps & {};
+type Props = RouteComponentProps;
 
 const SetupApp: React.FC<Props> = ({ location }) => {
   const { currentCluster, currentProject } = useContext(Context);