Просмотр исходного кода

Merge branch 'nico/por-629-crud-operations-on-stack-resources' of github.com:porter-dev/porter into dev

jnfrati 3 лет назад
Родитель
Сommit
bc06a05b6b
16 измененных файлов с 1184 добавлено и 470 удалено
  1. 19 41
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  2. 160 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx
  3. 154 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  4. 27 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx
  5. 66 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewEnvGroup.tsx
  6. 99 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  7. 32 27
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  8. 7 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/NewAppResource.tsx
  9. 40 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/routes.tsx
  10. 312 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx
  11. 165 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx
  12. 20 250
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  13. 16 142
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  14. 1 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  15. 4 10
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  16. 62 0
      dashboard/src/shared/api.tsx

+ 19 - 41
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -2,22 +2,20 @@ import Loading from "components/Loading";
 import Placeholder from "components/Placeholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
 import backArrow from "assets/back_arrow.png";
-import { useParams } from "react-router";
+import { useParams, useRouteMatch } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
 import { readableDate } from "shared/string_utils";
 import styled from "styled-components";
 import ChartList from "../../chart/ChartList";
-import SortSelector from "../../SortSelector";
 import Status from "../components/Status";
 import {
   Br,
   InfoWrapper,
   LastDeployed,
-  LineBreak,
   NamespaceTag,
   SepDot,
   Text,
@@ -29,50 +27,31 @@ import RevisionList from "./_RevisionList";
 import SourceConfig from "./_SourceConfig";
 import { NavLink } from "react-router-dom";
 import Settings from "./components/Settings";
+import { ExpandedStackStore } from "./Store";
+import DynamicLink from "components/DynamicLink";
 
 const ExpandedStack = () => {
-  const { namespace, stack_id } = useParams<{
+  const { namespace } = useParams<{
     namespace: string;
     stack_id: string;
   }>();
 
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+
   const { pushFiltered } = useRouting();
 
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
 
-  const [stack, setStack] = useState<Stack>();
-  const [isLoading, setIsLoading] = useState(true);
+  const { url } = useRouteMatch();
+
   const [isDeleting, setIsDeleting] = useState(false);
   const [currentTab, setCurrentTab] = useState("apps");
 
-  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
-
-  const getStack = async () => {
-    setIsLoading(true);
-    try {
-      const newStack = await api
-        .getStack<Stack>(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            stack_id: stack_id,
-            namespace,
-          }
-        )
-        .then((res) => res.data);
-
-      setStack(newStack);
-      setCurrentRevision(newStack.latest_revision);
-      setIsLoading(false);
-    } catch (error) {
-      setCurrentError(error);
-      pushFiltered("/stacks", []);
-    }
-  };
+  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>(
+    () => stack.latest_revision
+  );
 
   const handleDelete = () => {
     setIsDeleting(true);
@@ -96,12 +75,8 @@ const ExpandedStack = () => {
       });
   };
 
-  useEffect(() => {
-    getStack();
-  }, [stack_id]);
-
-  if (isLoading) {
-    return <Loading />;
+  if (stack === null) {
+    return null;
   }
 
   if (isDeleting) {
@@ -163,7 +138,7 @@ const ExpandedStack = () => {
         stackId={stack.id}
         stackNamespace={namespace}
         onRevisionClick={(revision) => setCurrentRevision(revision)}
-        onRollback={() => getStack()}
+        onRollback={() => refreshStack()}
       ></RevisionList>
       <Br />
       <TabSelector
@@ -175,6 +150,9 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
+                <DynamicLink to={`${url}/new-app-resource`}>
+                  Add new app
+                </DynamicLink>
                 {currentRevision.id !== stack.latest_revision.id ? (
                   <ChartListWrapper>
                     <Placeholder>
@@ -209,7 +187,7 @@ const ExpandedStack = () => {
                   namespace={namespace}
                   revision={currentRevision}
                   readOnly={stack.latest_revision.id !== currentRevision.id}
-                  onSourceConfigUpdate={() => getStack()}
+                  onSourceConfigUpdate={() => refreshStack()}
                 ></SourceConfig>
               </>
             ),

+ 160 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx

@@ -0,0 +1,160 @@
+import { AxiosError } from "axios";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewAppResourceForm from "../../components/NewAppResourceForm";
+import { CreateStackBody } from "../../types";
+import { ExpandedStackStore } from "../Store";
+
+const parsePopulatedEnvGroup = (envGroup: PopulatedEnvGroup) => {
+  const variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => !value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+  const secret_variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+
+  return {
+    name: envGroup.name,
+    variables,
+    secret_variables,
+    linked_applications: envGroup.applications as string[],
+  };
+};
+
+const Settings = () => {
+  const params = useParams<{
+    template_name: string;
+    template_version: string;
+  }>();
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [availableEnvGroups, setAvailableEnvGroups] = useState<
+    {
+      name: string;
+      variables: Record<string, string>;
+      secret_variables: Record<string, string>;
+      linked_applications: string[];
+    }[]
+  >([]);
+
+  const { pushFiltered } = useRouting();
+
+  const populateEnvGroups = async () => {
+    const stackEnvGroups = stack.latest_revision.env_groups;
+    const envGroupsPromises = stackEnvGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            namespace: stack.namespace,
+            version: envGroup.env_group_version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const response = await Promise.allSettled(envGroupsPromises);
+
+      const envGroups = response
+        .map((res) => {
+          if (res.status === "fulfilled") {
+            return res.value;
+          }
+          return undefined;
+        })
+        .filter(Boolean);
+
+      return envGroups;
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    populateEnvGroups().then((populatedEnvGroups) => {
+      if (!isSubscribed) {
+        return;
+      }
+
+      if (Array.isArray(populatedEnvGroups)) {
+        const availableEnvGroups = populatedEnvGroups.map(
+          parsePopulatedEnvGroup
+        );
+
+        setAvailableEnvGroups(availableEnvGroups);
+      }
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [stack, params, currentProject, currentCluster]);
+
+  const handleSubmit = async (
+    appResource: CreateStackBody["app_resources"][0]
+  ) => {
+    try {
+      await api.addStackAppResource(
+        "<token>",
+        {
+          ...appResource,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+
+      pushFiltered(`/stacks/${stack.namespace}/${stack.id}`, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+      if (axiosError.code === "409") {
+        throw "Application resource name already exists.";
+      }
+
+      throw "Unexpected error, please try again.";
+    }
+  };
+
+  return (
+    <NewAppResourceForm
+      availableEnvGroups={availableEnvGroups}
+      namespace={stack.namespace}
+      sourceConfig={stack.latest_revision.source_configs[0]}
+      templateInfo={{
+        name: params.template_name,
+        version: params.template_version,
+      }}
+      onCancel={() => {
+        pushFiltered(`../template-selector`, []);
+      }}
+      onSubmit={handleSubmit}
+    />
+  );
+};
+
+export default Settings;

+ 154 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -0,0 +1,154 @@
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import { BackButton, Card } from "../../launch/components/styles";
+import DynamicLink from "components/DynamicLink";
+import { VersionSelector } from "../../launch/components/VersionSelector";
+import TitleSection from "components/TitleSection";
+
+const TemplateSelector = () => {
+  const [templates, setTemplates] = useState<PorterTemplate[]>([]);
+  const [selectedVersion, setSelectedVersion] = useState<{
+    [template_name: string]: string;
+  }>({});
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+
+  const getTemplates = async () => {
+    try {
+      const res = await api.getTemplates<PorterTemplate[]>(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      );
+      let sortedVersionData = res.data
+        .map((template: PorterTemplate) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        })
+        .sort((a, b) => {
+          if (a.name < b.name) {
+            return -1;
+          }
+          if (a.name > b.name) {
+            return 1;
+          }
+          return 0;
+        });
+
+      return sortedVersionData;
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIsLoading(true);
+    getTemplates()
+      .then((porterTemplates) => {
+        const latestVersions = porterTemplates.reduce((acc, template) => {
+          return {
+            ...acc,
+            [template.name]: template.versions[0],
+          };
+        }, {} as Record<string, string>);
+
+        if (isSubscribed) {
+          setTemplates(porterTemplates);
+          setSelectedVersion(latestVersions);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (hasError) {
+    return (
+      <Placeholder>
+        <div>
+          <h2>Unexpected error</h2>
+          <p>
+            We had an error retrieving the available templates, please try
+            again.
+          </p>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <TitleSection>
+        <DynamicLink to={`../`}>
+          <BackButton>
+            <i className="material-icons">keyboard_backspace</i>
+          </BackButton>
+        </DynamicLink>
+        Select a template
+      </TitleSection>
+      <Card.Grid>
+        {templates.map((template) => {
+          return (
+            <Card.Wrapper key={template.name}>
+              <Card.Title>
+                New {template.name} with version:
+                <VersionSelector
+                  value={selectedVersion[template.name]}
+                  options={template.versions}
+                  onChange={(newVersion) => {
+                    setSelectedVersion((prev) => ({
+                      ...prev,
+                      [template.name]: newVersion,
+                    }));
+                  }}
+                />
+              </Card.Title>
+              <Card.Actions>
+                <Card.ActionButton
+                  as={DynamicLink}
+                  to={`settings/${template.name}/${
+                    selectedVersion[template.name]
+                  }`}
+                >
+                  <i className="material-icons-outlined">arrow_forward</i>
+                </Card.ActionButton>
+              </Card.Actions>
+            </Card.Wrapper>
+          );
+        })}
+      </Card.Grid>
+    </>
+  );
+};
+
+export default TemplateSelector;

+ 27 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import Settings from "./_Settings";
+import TemplateSelector from "./_TemplateSelector";
+
+const NewAppResourceRoutes = () => {
+  const { url } = useRouteMatch();
+
+  return (
+    <Switch>
+      <Route path={`${url}/template-selector`}>
+        <TemplateSelector />
+      </Route>
+      <Route path={`${url}/settings/:template_name/:template_version`}>
+        <Settings />
+      </Route>
+      <Route path="/">
+        <Redirect to={`${url}/template-selector`} />
+      </Route>
+      <Route path="*">
+        <Redirect to={url} />
+      </Route>
+    </Switch>
+  );
+};
+
+export default NewAppResourceRoutes;

+ 66 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewEnvGroup.tsx

@@ -0,0 +1,66 @@
+import { AxiosError } from "axios";
+import React, { useContext } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewEnvGroupForm from "../components/NewEnvGroupForm";
+import { CreateStackBody } from "../types";
+import { ExpandedStackStore } from "./Store";
+
+const NewEnvGroup = () => {
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { pushFiltered } = useRouting();
+
+  const createEnvGroup = async (
+    newEnvGroup: CreateStackBody["env_groups"][0]
+  ) => {
+    try {
+      await api.addStackEnvGroup(
+        "<token>",
+        {
+          ...newEnvGroup,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+      pushFiltered("../" + stack.id, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+
+      if (axiosError.code === "404" || axiosError.code === "405") {
+        throw "New env group not implemented";
+      }
+
+      if (axiosError.code === "409") {
+        throw "Name is already in use";
+      }
+
+      if (error?.message) {
+        throw error.message;
+      }
+
+      throw error;
+    }
+  };
+
+  return (
+    <>
+      <NewEnvGroupForm
+        onSubmit={createEnvGroup}
+        onCancel={() => {
+          pushFiltered("../" + stack.id, []);
+        }}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;

+ 99 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx

@@ -0,0 +1,99 @@
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import type { Stack } from "../types";
+
+interface StoreType {
+  stack: Stack;
+  refreshStack: () => Promise<void>;
+}
+
+const defaultValues: StoreType = {
+  stack: {} as Stack,
+  refreshStack: async () => {},
+};
+
+export const ExpandedStackStore = createContext(defaultValues);
+
+const ExpandedStackStoreProvider: React.FC = ({ children }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const [stack, setStack] = useState<Stack>(null);
+  const [isLoading, setIsLoading] = useState(true);
+
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+  const { pushFiltered } = useRouting();
+
+  const getStack = async (props: { subscribed: boolean }) => {
+    setIsLoading(true);
+    api
+      .getStack<Stack>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace,
+          stack_id,
+        }
+      )
+      .then((res) => {
+        if (props.subscribed) {
+          setStack(res.data);
+        }
+      })
+      .catch(() => {
+        if (props.subscribed) {
+          setCurrentError("Couldn't find any stack with the given ID");
+          pushFiltered("/stacks", []);
+        }
+      })
+      .finally(() => {
+        if (props.subscribed) {
+          setIsLoading(false);
+        }
+      });
+  };
+
+  useEffect(() => {
+    let isSubscribed = { subscribed: true };
+
+    getStack(isSubscribed);
+
+    return () => {
+      isSubscribed.subscribed = false;
+    };
+  }, [currentCluster, currentProject, namespace, stack_id]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  return (
+    <ExpandedStackStore.Provider
+      value={{
+        stack,
+        refreshStack: async () => {
+          await getStack({ subscribed: true });
+        },
+      }}
+    >
+      {children}
+    </ExpandedStackStore.Provider>
+  );
+};
+
+export default ExpandedStackStoreProvider;

+ 32 - 27
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -7,6 +7,7 @@ import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
+import { useRouteMatch } from "react-router";
 
 type PopulatedEnvGroup = {
   applications: string[];
@@ -22,6 +23,7 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [envGroups, setEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+  const { url } = useRouteMatch();
 
   const getEnvGroups = async () => {
     const stackEnvGroups = stack.latest_revision.env_groups;
@@ -78,34 +80,37 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   }
 
   return (
-    <Card.Grid style={{ marginTop: "0px" }}>
-      {envGroups.map((envGroup) => {
-        return (
-          <Card.Wrapper variant="unclickable">
-            <Card.Title>
-              <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
-              {envGroup.name}
-            </Card.Title>
+    <>
+      <DynamicLink to={`${url}/new-env-group`}>Add new env group</DynamicLink>
+      <Card.Grid style={{ marginTop: "0px" }}>
+        {envGroups.map((envGroup) => {
+          return (
+            <Card.Wrapper variant="unclickable">
+              <Card.Title>
+                <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
+                {envGroup.name}
+              </Card.Title>
 
-            <Card.Actions>
-              <Card.ActionButton
-                as={DynamicLink}
-                to={{
-                  pathname: "/env-groups",
-                  search: `?namespace=${stack.namespace}&selected_env_group=${
-                    envGroup.name
-                  }&redirect_url=${encodeURIComponent(
-                    window.location.pathname
-                  )}`,
-                }}
-              >
-                <i className="material-icons-outlined">launch</i>
-              </Card.ActionButton>
-            </Card.Actions>
-          </Card.Wrapper>
-        );
-      })}
-    </Card.Grid>
+              <Card.Actions>
+                <Card.ActionButton
+                  as={DynamicLink}
+                  to={{
+                    pathname: "/env-groups",
+                    search: `?namespace=${stack.namespace}&selected_env_group=${
+                      envGroup.name
+                    }&redirect_url=${encodeURIComponent(
+                      window.location.pathname
+                    )}`,
+                  }}
+                >
+                  <i className="material-icons-outlined">launch</i>
+                </Card.ActionButton>
+              </Card.Actions>
+            </Card.Wrapper>
+          );
+        })}
+      </Card.Grid>
+    </>
   );
 };
 

+ 7 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/NewAppResource.tsx

@@ -0,0 +1,7 @@
+import React from "react";
+
+const NewAppResource = () => {
+  return <div>NewAppResource</div>;
+};
+
+export default NewAppResource;

+ 40 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/routes.tsx

@@ -0,0 +1,40 @@
+import React from "react";
+import {
+  Redirect,
+  Route,
+  Switch,
+  useLocation,
+  useRouteMatch,
+} from "react-router";
+
+import ExpandedStack from "./ExpandedStack";
+import NewAppResourceRoutes from "./NewAppResource";
+import NewEnvGroup from "./NewEnvGroup";
+import ExpandedStackStoreProvider from "./Store";
+
+const ExpandedStackRoutes = () => {
+  const { path } = useRouteMatch();
+  const { pathname } = useLocation();
+
+  return (
+    <ExpandedStackStoreProvider>
+      <Switch>
+        <Redirect from="/:url*(/+)" to={pathname.slice(0, -1)} />
+        <Route path={`${path}/new-env-group`} exact>
+          <NewEnvGroup />
+        </Route>
+        <Route path={`${path}/new-app-resource`}>
+          <NewAppResourceRoutes />
+        </Route>
+        <Route path={`${path}`} exact>
+          <ExpandedStack />
+        </Route>
+        <Route path={`*`}>
+          <div>Not found</div>
+        </Route>
+      </Switch>
+    </ExpandedStackStoreProvider>
+  );
+};
+
+export default ExpandedStackRoutes;

+ 312 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx

@@ -0,0 +1,312 @@
+import Loading from "components/Loading";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import TitleSection from "components/TitleSection";
+import _ from "lodash";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import styled from "styled-components";
+import { BackButton, Icon, Polymer } from "../launch/components/styles";
+import { CreateStackBody, SourceConfig } from "../types";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import Heading from "components/form-components/Heading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+
+const parseEnvGroup = (namespace: string) => (
+  envGroup: CreateStackBody["env_groups"][0]
+): PopulatedEnvGroup => {
+  const variables = envGroup?.variables || {};
+  const secretVariables = envGroup?.secret_variables || {};
+
+  return {
+    name: envGroup.name,
+    version: 1,
+    namespace,
+    applications: envGroup.linked_applications,
+    meta_version: 2,
+    variables: {
+      ...variables,
+      ...Object.keys(secretVariables).reduce((acc, key) => {
+        acc[key] = "PORTERSECRET_" + key;
+        return acc;
+      }, {} as any),
+    },
+  };
+};
+
+const NewAppResourceForm = (props: {
+  templateInfo: {
+    name: string;
+    version: string;
+  };
+  namespace: string;
+  sourceConfig: Pick<
+    SourceConfig,
+    "build" | "image_repo_uri" | "image_tag" | "name"
+  >;
+  availableEnvGroups: CreateStackBody["env_groups"];
+  onSubmit: (
+    newApp: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const {
+    availableEnvGroups,
+    sourceConfig,
+    templateInfo,
+    namespace,
+    onCancel,
+    onSubmit,
+  } = props;
+
+  const { currentCluster } = useContext(Context);
+
+  const [hasError, setHasError] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
+  const [saveButtonStatus, setSaveButtonStatus] = useState("");
+
+  const [name, setName] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const handleSubmit = async ({
+    values: rawValues,
+    metadata,
+  }: {
+    values: any;
+    metadata: any;
+  }) => {
+    setSaveButtonStatus("loading");
+    const syncedEnvGroups =
+      metadata["container.env"]?.added?.map(
+        ({ name }: { name: string }) => name
+      ) || [];
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    const stackSourceConfig = sourceConfig;
+    if (!stackSourceConfig) {
+      return;
+    }
+
+    let url = stackSourceConfig.image_repo_uri;
+    let tag = stackSourceConfig.image_tag;
+
+    if (url?.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (!_.isEmpty(stackSourceConfig.build)) {
+      if (template?.metadata?.name === "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
+      default:
+        provider = "";
+    }
+
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (url && tag) {
+      _.set(values, "image.repository", url);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (template?.metadata?.name == "job") {
+      _.set(values, "paused", true);
+    }
+
+    if (name === "") {
+      setSaveButtonStatus("App name cannot be empty");
+      return;
+    }
+    try {
+      await onSubmit(
+        {
+          name: name,
+          source_config_name: sourceConfig?.name || "",
+          template_name: templateInfo.name,
+          template_version: templateInfo.version,
+          values,
+        },
+        [...syncedEnvGroups]
+      );
+
+      setSaveButtonStatus("successful");
+      setTimeout(() => {
+        setSaveButtonStatus("");
+        setName("");
+        setTemplate(undefined);
+      }, 1000);
+    } catch (error) {
+      setSaveButtonStatus(error);
+      setTimeout(() => {
+        setSaveButtonStatus("");
+      }, 2000);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!templateInfo.name || !templateInfo.version) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setHasError(false);
+
+    api
+      .getTemplateInfo<ExpandedPorterTemplate>(
+        "<token>",
+        {},
+        { name: templateInfo.name, version: templateInfo.version }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setTemplate(res.data);
+        }
+      })
+      .catch((err) => {
+        setHasError(true);
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [templateInfo]);
+
+  if (isLoading) {
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+  return (
+    <>
+      <TitleSection>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <Polymer>
+          <Icon src={hardcodedIcons[template.metadata.name]} />
+        </Polymer>
+        Add{" "}
+        {template.metadata.name.charAt(0).toUpperCase() +
+          template.metadata.name.slice(1)}{" "}
+        to Stack
+      </TitleSection>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
+      <InputRow
+        type="string"
+        value={name}
+        setValue={(val: string) => setName(val)}
+        placeholder="ex: perspective-vortex"
+        width="470px"
+      />
+
+      <div style={{ position: "relative" }}>
+        <Heading>Application Settings</Heading>
+        <Helper>Configure settings for this application.</Helper>
+        <PorterFormWrapper
+          formData={template.form}
+          onSubmit={handleSubmit}
+          isLaunch
+          saveValuesStatus={saveButtonStatus}
+          saveButtonText="Add Application"
+          valuesToOverride={{ namespace }}
+          injectedProps={{
+            "key-value-array": {
+              availableSyncEnvGroups: availableEnvGroups.map(
+                parseEnvGroup(namespace)
+              ),
+            },
+          }}
+          includeMetadata
+        />
+      </div>
+    </>
+  );
+};
+
+export default NewAppResourceForm;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Wrapper = styled.div`
+  margin-top: calc(50vh - 150px);
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

+ 165 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx

@@ -0,0 +1,165 @@
+import DynamicLink from "components/DynamicLink";
+import TitleSection from "components/TitleSection";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+import { BackButton, Polymer, SubmitButton } from "../launch/components/styles";
+import sliders from "assets/sliders.svg";
+import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
+import Heading from "components/form-components/Heading";
+import { isAlphanumeric } from "shared/common";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+
+const envArrayToObject = (variables: KeyValueType[]) => {
+  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
+    acc[curr.key] = curr.value;
+    return acc;
+  }, {});
+};
+
+type ProcessedEnvVariables = ReturnType<typeof envArrayToObject>;
+
+const NewEnvGroupForm = (props: {
+  onSubmit: (newEnvGroup: {
+    name: string;
+    variables: ProcessedEnvVariables;
+    secret_variables: ProcessedEnvVariables;
+  }) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const { onSubmit, onCancel } = props;
+
+  const [name, setName] = useState("");
+  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
+  const [submitError, setSubmitError] = useState("");
+
+  const handleOnSubmit = async () => {
+    const variables = envVariables.filter(
+      (variable) => !variable.locked && !variable.hidden
+    );
+    const secret_variables = envVariables.filter(
+      (variable) => variable.locked || variable.hidden
+    );
+
+    try {
+      await onSubmit({
+        name: name,
+        variables: envArrayToObject(variables),
+        secret_variables: envArrayToObject(secret_variables),
+      });
+    } catch (error) {
+      setSubmitError(error);
+      return;
+    }
+
+    setName("");
+    setEnvVariables([]);
+    return;
+  };
+
+  const hasError = useMemo(() => {
+    if (!isAlphanumeric(name) || name === "") {
+      return { message: "Name cannot be empty." };
+    }
+
+    if (!envVariables.length) {
+      return { message: "Please add at least one environment variable." };
+    }
+
+    if (envVariables.some((variable) => !variable.value || !variable.key)) {
+      return { message: "Please fill in all environment variables." };
+    }
+
+    return null;
+  }, [name, envVariables]);
+
+  return (
+    <>
+      <TitleSection>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <Polymer>
+          <SliderIcon src={sliders} />
+        </Polymer>
+        Add a Env Group to Stack
+      </TitleSection>
+      <Heading isAtTop={true}>Name</Heading>
+      <Subtitle>
+        <Warning
+          makeFlush={true}
+          highlight={!isAlphanumeric(name) && name !== ""}
+        >
+          Lowercase letters, numbers, and "-" only.
+        </Warning>
+      </Subtitle>
+      <InputRow
+        type="text"
+        value={name}
+        setValue={(x: string) => {
+          setName(x);
+        }}
+        placeholder="ex: doctor-scientist"
+        width="100%"
+      />
+
+      <Heading>Environment Variables</Heading>
+      <Helper>
+        Set environment variables for your secrets and environment-specific
+        configuration.
+      </Helper>
+      <EnvGroupArray
+        values={envVariables}
+        setValues={(x: any) => setEnvVariables((prev) => [...x])}
+        fileUpload={true}
+        secretOption={true}
+      />
+
+      <SubmitButton
+        onClick={handleOnSubmit}
+        makeFlush
+        clearPosition
+        text="Save env group"
+        disabled={!!hasError}
+        statusPosition="left"
+        status={hasError?.message || submitError || ""}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroupForm;
+
+const SliderIcon = styled.img`
+  width: 25px;
+  margin-right: 16px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 0px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;

+ 20 - 250
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -1,276 +1,46 @@
-import Helper from "components/form-components/Helper";
-import InputRow from "components/form-components/InputRow";
-import Loading from "components/Loading";
-import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import _ from "lodash";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext } from "react";
 import { useParams } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import { ExpandedPorterTemplate } from "shared/types";
 import { StacksLaunchContext } from "./Store";
-import DynamicLink from "components/DynamicLink";
 import styled from "styled-components";
-import Heading from "components/form-components/Heading";
-import TitleSection from "components/TitleSection";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { BackButton, Icon, Polymer } from "./components/styles";
-import { PopulatedEnvGroup } from "components/porter-form/types";
-import { CreateStackBody } from "../types";
+import NewAppResourceForm from "../components/NewAppResourceForm";
 
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 
-const parseEnvGroup = (namespace: string) => (
-  envGroup: CreateStackBody["env_groups"][0]
-): PopulatedEnvGroup => {
-  const variables = envGroup?.variables || {};
-  const secretVariables = envGroup?.secret_variables || {};
-
-  return {
-    name: envGroup.name,
-    version: 1,
-    namespace,
-    applications: envGroup.linked_applications,
-    meta_version: 2,
-    variables: {
-      ...variables,
-      ...Object.keys(secretVariables).reduce((acc, key) => {
-        acc[key] = "PORTERSECRET_" + key;
-        return acc;
-      }, {} as any),
-    },
-  };
-};
-
 const NewApp = () => {
   const { addAppResource, newStack, namespace } = useContext(
     StacksLaunchContext
   );
-  const { currentCluster } = useContext(Context);
 
   const params = useParams<{
     template_name: string;
     version: string;
   }>();
 
-  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [saveButtonStatus, setSaveButtonStatus] = useState("");
-
-  const [appName, setAppName] = useState("");
-
   const { pushFiltered } = useRouting();
 
-  useEffect(() => {
-    let isSubscribed = true;
-    if (!params.template_name || !params.version) {
-      return () => {
-        isSubscribed = false;
-      };
-    }
-
-    setHasError(false);
-
-    api
-      .getTemplateInfo<ExpandedPorterTemplate>(
-        "<token>",
-        {},
-        { name: params.template_name, version: params.version }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setTemplate(res.data);
-        }
-      })
-      .catch((err) => {
-        setHasError(true);
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [params]);
-
-  if (isLoading) {
-    return (
-      <Wrapper>
-        <Loading />
-      </Wrapper>
-    );
-  }
-
-  if (hasError) {
-    return <>Unexpected error</>;
-  }
-
-  const handleSubmit = async ({
-    values: rawValues,
-    metadata,
-  }: {
-    values: any;
-    metadata: any;
-  }) => {
-    setSaveButtonStatus("loading");
-    const syncedEnvGroups =
-      metadata["container.env"]?.added?.map(
-        ({ name }: { name: string }) => name
-      ) || [];
-
-    // Convert dotted keys to nested objects
-    let values: any = {};
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
-    }
-
-    const stackSourceConfig =
-      newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX];
-    if (!stackSourceConfig) {
-      return;
-    }
-
-    let url = stackSourceConfig.image_repo_uri;
-    let tag = stackSourceConfig.image_tag;
-
-    if (url?.includes(":")) {
-      let splits = url.split(":");
-      url = splits[0];
-      tag = splits[1];
-    } else if (!tag) {
-      tag = "latest";
-    }
-
-    if (!_.isEmpty(stackSourceConfig.build)) {
-      if (template?.metadata?.name === "job") {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
-        tag = "latest";
-      } else {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter";
-        tag = "latest";
-      }
-    }
-
-    let provider;
-    switch (currentCluster.service) {
-      case "eks":
-        provider = "aws";
-        break;
-      case "gke":
-        provider = "gcp";
-        break;
-      case "doks":
-        provider = "digitalocean";
-        break;
-      case "aks":
-        provider = "azure";
-        break;
-      case "vke":
-        provider = "vultr";
-        break;
-      default:
-        provider = "";
-    }
-
-    // Check the server URL to see if we can detect the cluster provider.
-    // There's no standard URL format for GCP that's why it's not currently included
-    if (provider === "") {
-      const server = currentCluster.server;
-
-      if (server.includes("eks")) provider = "eks";
-      else if (server.includes("ondigitalocean")) provider = "digitalocean";
-      else if (server.includes("azmk8s")) provider = "azure";
-      else if (server.includes("vultr")) provider = "vultr";
-    }
-
-    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
-    if (url && tag) {
-      _.set(values, "image.repository", url);
-      _.set(values, "image.tag", tag);
-    }
-
-    _.set(values, "ingress.provider", provider);
-
-    // pause jobs automatically
-    if (template?.metadata?.name == "job") {
-      _.set(values, "paused", true);
-    }
-
-    if (appName === "") {
-      setSaveButtonStatus("App name cannot be empty");
-      return;
-    }
-
-    addAppResource(
-      {
-        name: appName,
-        source_config_name: newStack.source_configs[0]?.name || "",
-        template_name: params.template_name,
-        template_version: params.version,
-        values,
-      },
-      [...syncedEnvGroups]
-    );
-
-    setSaveButtonStatus("successful");
-    setTimeout(() => {
-      setSaveButtonStatus("");
-      pushFiltered("/stacks/launch/overview", []);
-    }, 1000);
-  };
-
   return (
     <>
-      <TitleSection>
-        <DynamicLink to={`/stacks/launch/overview`}>
-          <BackButton>
-            <i className="material-icons">keyboard_backspace</i>
-          </BackButton>
-        </DynamicLink>
-        <Polymer>
-          <Icon src={hardcodedIcons[template.metadata.name]} />
-        </Polymer>
-        Add{" "}
-        {template.metadata.name.charAt(0).toUpperCase() +
-          template.metadata.name.slice(1)}{" "}
-        to Stack
-      </TitleSection>
-      <Heading>
-        Application Name <Required>*</Required>
-      </Heading>
-      <InputRow
-        type="string"
-        value={appName}
-        setValue={(val: string) => setAppName(val)}
-        placeholder="ex: perspective-vortex"
-        width="470px"
+      <NewAppResourceForm
+        sourceConfig={
+          newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX]
+        }
+        availableEnvGroups={newStack.env_groups}
+        namespace={namespace}
+        templateInfo={{
+          name: params.template_name,
+          version: params.version,
+        }}
+        onSubmit={async (newApp, syncedEnvGroups) => {
+          addAppResource(newApp, syncedEnvGroups);
+          pushFiltered("/stacks/launch/overview", []);
+          return;
+        }}
+        onCancel={() => {
+          pushFiltered("/stacks/launch/overview", []);
+        }}
       />
-
-      <div style={{ position: "relative" }}>
-        <Heading>Application Settings</Heading>
-        <Helper>Configure settings for this application.</Helper>
-        <PorterFormWrapper
-          formData={template.form}
-          onSubmit={handleSubmit}
-          isLaunch
-          saveValuesStatus={saveButtonStatus}
-          saveButtonText="Add Application"
-          valuesToOverride={{ namespace }}
-          injectedProps={{
-            "key-value-array": {
-              availableSyncEnvGroups: newStack.env_groups.map(
-                parseEnvGroup(namespace)
-              ),
-            },
-          }}
-          includeMetadata
-        />
-      </div>
     </>
   );
 };

+ 16 - 142
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx

@@ -1,156 +1,30 @@
-import DynamicLink from "components/DynamicLink";
-import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
-import InputRow from "components/form-components/InputRow";
-import TitleSection from "components/TitleSection";
-import React, { useContext, useMemo, useState } from "react";
-import { isAlphanumeric } from "shared/common";
+import React, { useContext } from "react";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
-import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
-import { BackButton, Icon, Polymer, SubmitButton } from "./components/styles";
+import NewEnvGroupForm from "../components/NewEnvGroupForm";
 import { StacksLaunchContext } from "./Store";
-import sliders from "assets/sliders.svg";
-
-const envArrayToObject = (variables: KeyValueType[]) => {
-  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
-    acc[curr.key] = curr.value;
-    return acc;
-  }, {});
-};
 
 const NewEnvGroup = () => {
   const { addEnvGroup } = useContext(StacksLaunchContext);
-  const [name, setName] = useState("");
-  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
 
   const { pushFiltered } = useRouting();
 
-  const handleOnSubmit = () => {
-    const variables = envVariables.filter(
-      (variable) => !variable.locked && !variable.hidden
-    );
-    const secret_variables = envVariables.filter(
-      (variable) => variable.locked || variable.hidden
-    );
-
-    addEnvGroup({
-      name,
-      variables: envArrayToObject(variables),
-      secret_variables: envArrayToObject(secret_variables),
-      linked_applications: [],
-    });
-    setName("");
-    setEnvVariables([]);
-    pushFiltered("/stacks/launch/overview", []);
-    return;
-  };
-
-  const hasError = useMemo(() => {
-    if (!isAlphanumeric(name) || name === "") {
-      return { message: "Name cannot be empty." };
-    }
-
-    if (!envVariables.length) {
-      return { message: "Please add at least one environment variable." };
-    }
-
-    if (envVariables.some((variable) => !variable.value || !variable.key)) {
-      return { message: "Please fill in all environment variables." };
-    }
-
-    return null;
-  }, [name, envVariables]);
-
   return (
-    <>
-      <TitleSection>
-        <DynamicLink to={`/stacks/launch/overview`}>
-          <BackButton>
-            <i className="material-icons">keyboard_backspace</i>
-          </BackButton>
-        </DynamicLink>
-        <Polymer>
-          <SliderIcon src={sliders} />
-        </Polymer>
-        Add a Env Group to Stack
-      </TitleSection>
-      <Heading isAtTop={true}>Name</Heading>
-      <Subtitle>
-        <Warning
-          makeFlush={true}
-          highlight={!isAlphanumeric(name) && name !== ""}
-        >
-          Lowercase letters, numbers, and "-" only.
-        </Warning>
-      </Subtitle>
-      <InputRow
-        type="text"
-        value={name}
-        setValue={(x: string) => {
-          setName(x);
-        }}
-        placeholder="ex: doctor-scientist"
-        width="100%"
-      />
-
-      <Heading>Environment Variables</Heading>
-      <Helper>
-        Set environment variables for your secrets and environment-specific
-        configuration.
-      </Helper>
-      <EnvGroupArray
-        values={envVariables}
-        setValues={(x: any) => setEnvVariables((prev) => [...x])}
-        fileUpload={true}
-        secretOption={true}
-      />
-
-      <SubmitButton
-        onClick={handleOnSubmit}
-        makeFlush
-        clearPosition
-        text="Save env group"
-        disabled={!!hasError}
-        statusPosition="left"
-        status={hasError?.message || ""}
-      />
-    </>
+    <NewEnvGroupForm
+      onSubmit={async (newEnvGroup) => {
+        addEnvGroup({
+          ...newEnvGroup,
+          linked_applications: [],
+        });
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+      onCancel={() => {
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+    />
   );
 };
 
 export default NewEnvGroup;
-
-export const SliderIcon = styled.img`
-  width: 25px;
-  margin-right: 16px;
-
-  opacity: 0;
-  animation: floatIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const Subtitle = styled.div`
-  padding: 11px 0px 0px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-  display: flex;
-  align-items: center;
-`;
-
-const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
-  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
-  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
-`;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -157,6 +157,7 @@ export const SelectorStyles = {
     width: 100%;
     max-height: 200px;
     overflow-y: auto;
+    z-index: 999;
   `,
   Option: styled.div`
     min-height: 35px;

+ 4 - 10
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -1,14 +1,8 @@
 import React, { useContext } from "react";
-import {
-  Redirect,
-  Route,
-  Switch,
-  useLocation,
-  useRouteMatch,
-} from "react-router";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import Dashboard from "./Dashboard";
-import ExpandedStack from "./ExpandedStack/ExpandedStack";
+import ExpandedStackRoutes from "./ExpandedStack/routes";
 import LaunchRoutes from "./launch";
 
 const routes = () => {
@@ -25,9 +19,9 @@ const routes = () => {
         <LaunchRoutes />
       </Route>
       <Route path={`${path}/:namespace/:stack_id`}>
-        <ExpandedStack />
+        <ExpandedStackRoutes />
       </Route>
-      <Route path={`${path}/`} exact>
+      <Route path={`${path}`} exact>
         <Dashboard />
       </Route>
       <Route path={`*`}>

+ 62 - 0
dashboard/src/shared/api.tsx

@@ -2087,6 +2087,64 @@ const updateStackSourceConfig = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
 );
 
+const addStackAppResource = baseApi<
+  CreateStackBody["app_resources"][0],
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/add_application`
+);
+
+const removeStackAppResource = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    app_resource_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, app_resource_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_application/${app_resource_name}`
+);
+
+const addStackEnvGroup = baseApi<
+  CreateStackBody["env_groups"][0],
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/add_env_group`
+);
+
+const removeStackEnvGroup = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    env_group_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, env_group_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
+);
+
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -2284,6 +2342,10 @@ export default {
   rollbackStack,
   deleteStack,
   updateStackSourceConfig,
+  addStackAppResource,
+  removeStackAppResource,
+  addStackEnvGroup,
+  removeStackEnvGroup,
 
   // STATUS
   getGithubStatus,