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

[POR-2187] Refactor DB form backend (#4133)

Feroze Mohideen 2 жил өмнө
parent
commit
6c9068a4a4

+ 85 - 0
dashboard/src/lib/hooks/useDatabase.ts

@@ -0,0 +1,85 @@
+import { useCallback, useContext } from "react";
+import { match } from "ts-pattern";
+
+import { type DbFormData } from "main/home/database-dashboard/forms/types";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type DatabaseHook = {
+  createDatabase: (values: DbFormData) => Promise<void>;
+};
+const clientDbToValues = (
+  values: DbFormData
+): { values: object; templateName: string } => {
+  return match(values)
+    .with({ config: { type: "rds-postgres" } }, (values) => ({
+      values: {
+        config: {
+          name: values.name,
+          databaseName: values.config.databaseName,
+          masterUsername: values.config.masterUsername,
+          masterUserPassword: values.config.masterUserPassword,
+          allocatedStorage: values.config.allocatedStorageGigabytes,
+          instanceClass: values.config.instanceClass,
+        },
+      },
+      templateName: "rds-postgresql",
+    }))
+    .with({ config: { type: "rds-postgresql-aurora" } }, (values) => ({
+      values: {
+        config: {
+          name: values.name,
+          databaseName: values.config.databaseName,
+          masterUsername: values.config.masterUsername,
+          masterUserPassword: values.config.masterUserPassword,
+          allocatedStorage: values.config.allocatedStorageGigabytes,
+          instanceClass: values.config.instanceClass,
+        },
+      },
+      templateName: "rds-postgresql-aurora",
+    }))
+    .with({ config: { type: "elasticache-redis" } }, (values) => ({
+      values: {
+        config: {
+          name: values.name,
+          databaseName: values.config.databaseName,
+          masterUsername: values.config.masterUsername,
+          masterUserPassword: values.config.masterUserPassword,
+          instanceClass: values.config.instanceClass,
+        },
+      },
+      templateName: "elasticache-redis",
+    }))
+    .exhaustive();
+};
+
+export const useDatabase = (): DatabaseHook => {
+  const { capabilities, currentProject, currentCluster } = useContext(Context);
+
+  const createDatabase = useCallback(
+    async (data: DbFormData): Promise<void> => {
+      const { values, templateName } = clientDbToValues(data);
+      const name = data.name;
+
+      await api.deployAddon(
+        "<token>",
+        {
+          template_name: templateName,
+          template_version: "latest",
+          values,
+          name,
+        },
+        {
+          id: currentProject?.id || -1,
+          cluster_id: currentCluster?.id || -1,
+          namespace: "ack-system",
+          repo_url: capabilities?.default_addon_helm_repo_url,
+        }
+      );
+    },
+    [currentProject, currentCluster, capabilities]
+  );
+
+  return { createDatabase };
+};

+ 3 - 0
dashboard/src/main/home/Home.tsx

@@ -459,6 +459,9 @@ const Home: React.FC<Props> = (props) => {
                   )}
                 </Route>
 
+                <Route path="/databases/new/:type/:engine">
+                  <CreateDatabase />
+                </Route>
                 <Route path="/databases/new">
                   <CreateDatabase />
                 </Route>

+ 92 - 61
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -1,7 +1,9 @@
-import React, { useState } from "react";
+import React, { useMemo } from "react";
 import _ from "lodash";
+import { withRouter, type RouteComponentProps } from "react-router";
 import styled from "styled-components";
 import { match } from "ts-pattern";
+import { z } from "zod";
 
 import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
@@ -12,75 +14,104 @@ import database from "assets/database.svg";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import { SUPPORTED_DATABASE_TEMPLATES } from "./constants";
-import DatabaseForm from "./forms/DatabaseForm";
-import { type DatabaseTemplate } from "./types";
+import DatabaseFormAuroraPostgres from "./forms/DatabaseFormAuroraPostgres";
+import DatabaseFormElasticacheRedis from "./forms/DatabaseFormElasticacheRedis";
+import DatabaseFormRDSPostgres from "./forms/DatabaseFormRDSPostgres";
+import {
+  DATABASE_ENGINE_POSTGRES,
+  DATABASE_ENGINE_REDIS,
+  DATABASE_TYPE_AURORA,
+  DATABASE_TYPE_ELASTICACHE,
+  DATABASE_TYPE_RDS,
+  type DatabaseTemplate,
+} from "./types";
 
-const CreateDatabase: React.FC = () => {
-  const [selectedTemplate, setSelectedTemplate] = useState<
-    DatabaseTemplate | undefined
-  >(undefined);
+type Props = RouteComponentProps;
+const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
+  const templateMatch: DatabaseTemplate | undefined = useMemo(() => {
+    const { params } = queryMatch;
+    const validParams = z
+      .object({
+        type: z.string(),
+        engine: z.string(),
+      })
+      .safeParse(params);
+
+    if (!validParams.success) {
+      return undefined;
+    }
+
+    return SUPPORTED_DATABASE_TEMPLATES.find(
+      (t) =>
+        !t.disabled &&
+        t.type === validParams.data.type &&
+        t.engine.name === validParams.data.engine
+    );
+  }, [queryMatch]);
 
   return (
     <StyledTemplateComponent>
-      {match(selectedTemplate)
-        .with(undefined, () => {
-          return (
-            <>
-              <Back to="/databases" />
-              <DashboardHeader
-                image={database}
-                title="Create a new database"
-                capitalize={false}
-                disableLineBreak
-              />
-              <Text size={15}>Production datastores</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Fully-managed production-ready datastores.
-              </Text>
-              <Spacer y={0.5} />
-              <TemplateListWrapper>
-                {SUPPORTED_DATABASE_TEMPLATES.map((template) => {
-                  const { name, icon, description, disabled, engine } =
-                    template;
-                  return (
-                    <TemplateBlock
-                      disabled={disabled}
-                      key={name}
-                      onClick={() => {
-                        !disabled && setSelectedTemplate(template);
-                      }}
-                    >
-                      <TemplateHeader>
-                        <Icon src={icon} />
-                        <Spacer inline x={0.5} />
-                        <TemplateTitle>{name}</TemplateTitle>
-                        <Spacer inline x={0.5} />
-                        <Tag hoverable={false}>{engine.displayName}</Tag>
-                      </TemplateHeader>
-                      <Spacer y={0.5} />
-                      <TemplateDescription>{description}</TemplateDescription>
-                      <Spacer y={0.5} />
-                    </TemplateBlock>
-                  );
-                })}
-              </TemplateListWrapper>
-            </>
-          );
-        })
-        .otherwise((tp) => (
-          <DatabaseForm
-            template={tp}
-            onFormExit={() => {
-              setSelectedTemplate(undefined);
-            }}
-          />
+      {match(templateMatch)
+        .with(
+          { type: DATABASE_TYPE_RDS, engine: DATABASE_ENGINE_POSTGRES },
+          (t) => <DatabaseFormRDSPostgres template={t} />
+        )
+        .with(
+          { type: DATABASE_TYPE_AURORA, engine: DATABASE_ENGINE_POSTGRES },
+          (t) => <DatabaseFormAuroraPostgres template={t} />
+        )
+        .with(
+          { type: DATABASE_TYPE_ELASTICACHE, engine: DATABASE_ENGINE_REDIS },
+          (t) => <DatabaseFormElasticacheRedis template={t} />
+        )
+        .otherwise(() => (
+          <>
+            <Back to="/databases" />
+            <DashboardHeader
+              image={database}
+              title="Create a new database"
+              capitalize={false}
+              disableLineBreak
+            />
+            <Text size={15}>Production datastores</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">
+              Fully-managed production-ready datastores.
+            </Text>
+            <Spacer y={0.5} />
+            <TemplateListWrapper>
+              {SUPPORTED_DATABASE_TEMPLATES.map((template) => {
+                const { name, icon, description, disabled, engine, type } =
+                  template;
+                return (
+                  <TemplateBlock
+                    disabled={disabled}
+                    key={`${name}-${engine.name}`}
+                    onClick={() => {
+                      history.push(`/databases/new/${type}/${engine.name}`);
+                    }}
+                  >
+                    <TemplateHeader>
+                      <Icon src={icon} />
+                      <Spacer inline x={0.5} />
+                      <TemplateTitle>{name}</TemplateTitle>
+                      <Spacer inline x={0.5} />
+                      <Tag hoverable={false}>{engine.displayName}</Tag>
+                    </TemplateHeader>
+                    <Spacer y={0.5} />
+                    <TemplateDescription>{description}</TemplateDescription>
+                    <Spacer y={0.5} />
+                  </TemplateBlock>
+                );
+              })}
+            </TemplateListWrapper>
+          </>
         ))}
     </StyledTemplateComponent>
   );
 };
 
-export default CreateDatabase;
+export default withRouter(CreateDatabase);
 
 const Icon = styled.img`
   height: 18px;

+ 2 - 2
dashboard/src/main/home/database-dashboard/DatabaseDashboard.tsx

@@ -181,7 +181,7 @@ const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
 
           <Text color={"helper"}>Get started by creating a database.</Text>
           <Spacer y={1} />
-          <PorterLink to="/databases/new/database">
+          <PorterLink to="/databases/new">
             <Button
               onClick={async () =>
                 // TODO: add analytics
@@ -229,7 +229,7 @@ const DatabaseDashboard: React.FC<Props> = ({ projectId }) => {
           />
 
           <Spacer inline x={2} />
-          <PorterLink to="/databases/new/database">
+          <PorterLink to="/databases/new">
             <Button
               onClick={async () =>
                 // TODO: add analytics

+ 82 - 8
dashboard/src/main/home/database-dashboard/constants.ts

@@ -12,7 +12,7 @@ import {
 } from "./types";
 
 export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
-  {
+  Object.freeze({
     name: "Amazon RDS",
     type: DATABASE_TYPE_RDS,
     engine: DATABASE_ENGINE_POSTGRES,
@@ -20,8 +20,32 @@ export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
     description:
       "Amazon Relational Database Service (RDS) is a web service that makes it easier to set up, operate, and scale a relational database in the cloud.",
     disabled: false,
-  },
-  {
+    instanceTiers: [
+      {
+        tier: "db.t4g.small" as const,
+        label: "Small",
+        cpuCores: 2,
+        ramGigabytes: 2,
+        storageGigabytes: 30,
+      },
+      {
+        tier: "db.t4g.medium" as const,
+        label: "Medium",
+        cpuCores: 2,
+        ramGigabytes: 4,
+        storageGigabytes: 100,
+      },
+      {
+        tier: "db.t4g.large" as const,
+        label: "Large",
+        cpuCores: 4,
+        ramGigabytes: 8,
+        storageGigabytes: 256,
+      },
+    ],
+    formTitle: "Create an RDS PostgreSQL instance",
+  }),
+  Object.freeze({
     name: "Amazon Aurora",
     type: DATABASE_TYPE_AURORA,
     engine: DATABASE_ENGINE_POSTGRES,
@@ -29,8 +53,25 @@ export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
     description:
       "Amazon Aurora PostgreSQL is an ACID–compliant relational database engine that combines the speed, reliability, and manageability of Amazon Aurora with the simplicity and cost-effectiveness of open-source databases.",
     disabled: false,
-  },
-  {
+    instanceTiers: [
+      {
+        tier: "db.t4g.medium" as const,
+        label: "Medium",
+        cpuCores: 2,
+        ramGigabytes: 4,
+        storageGigabytes: 100,
+      },
+      {
+        tier: "db.t4g.large" as const,
+        label: "Large",
+        cpuCores: 4,
+        ramGigabytes: 8,
+        storageGigabytes: 256,
+      },
+    ],
+    formTitle: "Create an Aurora PostgreSQL instance",
+  }),
+  Object.freeze({
     name: "Amazon ElastiCache",
     type: DATABASE_TYPE_ELASTICACHE,
     engine: DATABASE_ENGINE_REDIS,
@@ -38,8 +79,39 @@ export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
     description:
       "Amazon ElastiCache is a web service that makes it easy to deploy, operate, and scale an in-memory data store or cache in the cloud.",
     disabled: false,
-  },
-  {
+    instanceTiers: [
+      {
+        tier: "cache.t4g.micro" as const,
+        label: "Micro",
+        cpuCores: 2,
+        ramGigabytes: 0.5,
+        storageGigabytes: 0,
+      },
+      {
+        tier: "cache.t4g.medium" as const,
+        label: "Medium",
+        cpuCores: 2,
+        ramGigabytes: 3,
+        storageGigabytes: 0,
+      },
+      {
+        tier: "cache.r7g.large" as const,
+        label: "Large",
+        cpuCores: 2,
+        ramGigabytes: 13,
+        storageGigabytes: 0,
+      },
+      {
+        tier: "cache.r7g.xlarge" as const,
+        label: "Extra Large",
+        cpuCores: 4,
+        ramGigabytes: 26,
+        storageGigabytes: 0,
+      },
+    ],
+    formTitle: "Create an ElastiCache Redis instance",
+  }),
+  Object.freeze({
     name: "Amazon ElastiCache",
     type: DATABASE_TYPE_ELASTICACHE,
     engine: DATABASE_ENGINE_MEMCACHED,
@@ -47,5 +119,7 @@ export const SUPPORTED_DATABASE_TEMPLATES: DatabaseTemplate[] = [
     description:
       "Currently unavailable. Please contact support@porter.run for more details.",
     disabled: true,
-  },
+    instanceTiers: [],
+    formTitle: "Create an ElastiCache Memcached instance",
+  }),
 ];

+ 0 - 380
dashboard/src/main/home/database-dashboard/forms/AuroraPostgresForm.tsx

@@ -1,380 +0,0 @@
-import React, { useEffect, useState, useContext } from "react";
-import styled from "styled-components";
-import _ from "lodash";
-import { v4 as uuidv4 } from 'uuid';
-
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { pushFiltered } from "shared/routing";
-
-import Back from "components/porter/Back";
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
-import Input from "components/porter/Input";
-import VerticalSteps from "components/porter/VerticalSteps";
-import Button from "components/porter/Button";
-import { RouteComponentProps, withRouter } from "react-router";
-import Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
-import Container from "components/porter/Container";
-import ClickToCopy from "components/porter/ClickToCopy";
-import { AuroraPostgresFormValues } from "./types";
-
-type Props = RouteComponentProps & {
-  currentTemplate: any;
-  goBack: () => void;
-  repoURL: string | undefined;
-};
-
-const AuroraPostgresForm: React.FC<Props> = ({
-  currentTemplate,
-  goBack,
-  repoURL,
-  ...props
-}) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [currentStep, setCurrentStep] = useState<number>(0);
-  const [name, setName] = useState<string>("");
-  const [buttonStatus, setButtonStatus] = useState<string>("");
-  const [credentialsSaved, setCredentialsSaved] = useState<boolean>(false);
-  const [dbName, setDbName] = useState<string>("postgres");
-  const [dbPassword, setDbPassword] = useState<string>(uuidv4());
-  const [dbUsername, setDbUsername] = useState<string>("postgres");
-  const [storage, setStorage] = useState<number>(0);
-  const [tier, setTier] = useState<string>("");
-  const [hidePassword, setHidePassword] = useState<boolean>(true);
-
-  useEffect(() => {
-    if (currentStep === 1) {
-      setCurrentStep(3);
-    }
-  }, [tier]);
-
-  const waitForHelmRelease = () => {
-    setTimeout(() => {
-      api.getChart(
-        "<token>",
-        {},
-        {
-          id: currentProject?.id || -1,
-          namespace: "ack-system",
-          cluster_id: currentCluster?.id || -1,
-          name,
-          revision: 0,
-        }
-      )
-        .then((res) => {
-          if (res?.data?.version) {
-            setButtonStatus("success");
-            pushFiltered(props, "/databases", ["project_id"], {
-              cluster: currentCluster?.name,
-            });
-          } else {
-            waitForHelmRelease();
-          }
-        })
-        .catch((err) => {
-          waitForHelmRelease();
-        });
-    }, 500);
-  };
-
-  const deploy = async () => {
-    setButtonStatus("loading");
-
-    const values: { config: AuroraPostgresFormValues } = {
-      config: {
-        name: name,
-        databaseName: dbName,
-        masterUsername: dbUsername,
-        masterUserPassword: dbPassword,
-        allocatedStorage: storage,
-        instanceClass: tier,
-      }
-    }
-
-    api
-      .deployAddon(
-        "<token>",
-        {
-          template_name: "rds-postgresql-aurora",
-          template_version: "latest",
-          values,
-          name,
-        },
-        {
-          id: currentProject?.id || -1,
-          cluster_id: currentCluster?.id || -1,
-          namespace: "ack-system",
-          repo_url: repoURL,
-        }
-      )
-      .then((_) => {
-        waitForHelmRelease();
-      })
-      .catch((err) => {
-        let parsedErr = err?.response?.data?.error;
-        err = parsedErr || err.message || JSON.stringify(err);
-        setButtonStatus(err);
-        return;
-      });
-  };
-
-  const getStatus = () => {
-    if (!buttonStatus) {
-      return;
-    }
-    if (buttonStatus === "loading" || buttonStatus === "success") {
-      return buttonStatus;
-    } else {
-      return (
-        <Error message={buttonStatus} />
-      );
-    }
-  };
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back onClick={goBack} />
-          <DashboardHeader
-            prefix={
-              <Icon 
-                src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
-              />
-            }
-            title="Create an Aurora PostgreSQL instance"
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <VerticalSteps
-            currentStep={currentStep}
-            steps={[
-              <>
-                <Text size={16}>Database name</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and "-" only.
-                </Text>
-                <Spacer height="20px" />
-                <Input
-                  placeholder="ex: my-database"
-                  value={name}
-                  width="300px"
-                  setValue={(e) => {
-                    if (e) {
-                      credentialsSaved ? setCurrentStep(2) : setCurrentStep(1);
-                    } else {
-                      setCurrentStep(0);
-                    }
-                    setName(e);
-                  }}
-                />
-              </>,
-              <>
-                <Text size={16}>Database resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Specify your database CPU, RAM, and storage.
-                </Text>
-                <Spacer y={.5} />
-                <Text>
-                  Select an instance tier:
-                </Text>
-                <Spacer height="20px" />
-                <ResourceOption
-                  selected={tier === "db.t4g.medium"}
-                  onClick={() => {
-                    setStorage(100);
-                    setTier("db.t4g.medium");
-                  }}
-                >
-                  <Container row>
-                    <Text>Medium</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU, 4 GB RAM</Text>
-                  </Container>
-                  <StorageTag>100 GB Storage</StorageTag>
-                </ResourceOption>
-                <Spacer height="15px" />
-                <ResourceOption
-                  selected={tier === "db.t4g.large"}
-                  onClick={() => {
-                    setStorage(256);
-                    setTier("db.t4g.large");
-                  }}
-                >
-                  <Container row>
-                    <Text>Large</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU, 8 GB RAM</Text>
-                  </Container>
-                  <StorageTag>256 GB Storage</StorageTag>
-                </ResourceOption>
-              </>,
-              <>
-                <Text size={16}>Database credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. You will be able to automatically import these credentials from any app.
-                </Text>
-                <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Postgres DB name</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {dbName}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres username</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {dbUsername}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres password</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {hidePassword ? (
-                      <>
-                        <Blur>{dbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(false)}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {dbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(true)}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
-              </>,
-              <>
-                <Text size={16}>Provision a database</Text>
-                <Spacer y={0.5} />
-                <Button
-                  onClick={deploy}
-                  disabled={buttonStatus === "loading"}
-                  status={getStatus()}
-                >
-                  Create database
-                </Button>
-              </>
-            ]}
-          />
-          <Spacer height="80px" />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(AuroraPostgresForm);
-
-const RevealButton = styled.div`
-  background: ${props => props.theme.fg};
-  padding: 5px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-const Blur = styled.div`
-  filter: blur(5px);
-  -webkit-filter: blur(5px);
-  position: relative;
-  margin-left: -5px;
-  font-family: monospace;
-`;
-
-const StorageTag = styled.div`
-  background: #202227;
-  color: #aaaabb;
-  border-radius: 5px;
-  padding: 5px 10px;
-  font-size: 13px;
-  margin-left: 5px;
-`;
-
-const ResourceOption = styled.div<{ selected?: boolean }>`
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid ${props => props.selected ? "#ffffff" : props.theme.border};
-  width: 350px;
-  padding: 10px 15px;
-  border-radius: 5px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #ffffff;
-  }
-`;
-
-const Div = styled.div`
-  width: 100%;
-  max-width: 900px;
-`;
-
-const CenterWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -5px;
-`;
-
-const Icon = styled.img`
-  margin-right: 15px;
-  height: 30px;
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const StyledConfigureTemplate = styled.div`
-  height: 100%;
-`;

+ 180 - 45
dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx

@@ -1,48 +1,183 @@
-import React, { useContext } from "react";
-import { match } from "ts-pattern";
-
-import { Context } from "shared/Context";
-
-import {
-  DATABASE_TYPE_AURORA,
-  DATABASE_TYPE_ELASTICACHE,
-  DATABASE_TYPE_RDS,
-  type DatabaseTemplate,
-} from "../types";
-import AuroraPostgresForm from "./AuroraPostgresForm";
-import ElasticacheRedisForm from "./ElasticacheRedisForm";
-import RDSForm from "./RDSForm";
-
-type Props = {
-  template: DatabaseTemplate;
-  onFormExit: () => void;
+import React, { useMemo, useState } from "react";
+import axios from "axios";
+import { FormProvider, type UseFormReturn } from "react-hook-form";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled, { keyframes } from "styled-components";
+
+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 { useDatabase } from "lib/hooks/useDatabase";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import { type DbFormData } from "./types";
+
+type Props = RouteComponentProps & {
+  steps: React.ReactNode[];
+  currentStep: number;
+  form: UseFormReturn<DbFormData>;
 };
-const DatabaseForm: React.FC<Props> = ({ template, onFormExit }) => {
-  const { capabilities } = useContext(Context);
-
-  return match(template)
-    .with({ type: DATABASE_TYPE_RDS }, () => (
-      <RDSForm
-        currentTemplate={template}
-        goBack={onFormExit}
-        repoURL={capabilities?.default_addon_helm_repo_url}
-      />
-    ))
-    .with({ type: DATABASE_TYPE_AURORA }, () => (
-      <AuroraPostgresForm
-        currentTemplate={template}
-        goBack={onFormExit}
-        repoURL={capabilities?.default_addon_helm_repo_url}
-      />
-    ))
-    .with({ type: DATABASE_TYPE_ELASTICACHE }, () => (
-      <ElasticacheRedisForm
-        currentTemplate={template}
-        goBack={onFormExit}
-        repoURL={capabilities?.default_addon_helm_repo_url}
-      />
-    ))
-    .exhaustive();
+
+const DatabaseForm: React.FC<Props> = ({
+  steps,
+  currentStep,
+  form,
+  history,
+}) => {
+  const [isCreating, setIsCreating] = useState<boolean>(false);
+  const { createDatabase } = useDatabase();
+  const { showIntercomWithMessage } = useIntercom();
+
+  const {
+    formState: { isSubmitting: isValidating, errors },
+    handleSubmit,
+    setError,
+    clearErrors,
+  } = form;
+
+  const submitBtnStatus = useMemo(() => {
+    if (isValidating || isCreating) {
+      return "loading";
+    }
+
+    if (Object.keys(errors).length) {
+      return <Error message={"Please address errors and resubmit."} />;
+    }
+
+    return "";
+  }, [isValidating, errors]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setIsCreating(true);
+    clearErrors();
+    try {
+      await createDatabase(data);
+      history.push(`/databases`);
+    } catch (err) {
+      const errorMessage =
+        axios.isAxiosError(err) && err.response?.data?.error
+          ? err.response.data.error
+          : "An error occurred while creating your database. Please try again.";
+      setError("root", { message: errorMessage });
+      showIntercomWithMessage({
+        message: "I am having trouble creating a database.",
+      });
+    } finally {
+      setIsCreating(false);
+    }
+  });
+
+  return (
+    <FormProvider {...form}>
+      <form onSubmit={onSubmit}>
+        <VerticalSteps
+          currentStep={currentStep}
+          steps={[
+            ...steps,
+            <>
+              <Text size={16}>Create database instance</Text>
+              <Spacer y={0.5} />
+              <Button
+                type="submit"
+                status={submitBtnStatus}
+                loadingText={"Creating..."}
+                disabled={isCreating}
+              >
+                Create
+              </Button>
+              {errors.root?.message && (
+                <AppearingErrorContainer>
+                  <Spacer y={0.5} />
+                  <Error message={errors.root.message} />
+                </AppearingErrorContainer>
+              )}
+            </>,
+          ]}
+        />
+        <Spacer height="80px" />
+      </form>
+    </FormProvider>
+  );
 };
 
-export default DatabaseForm;
+export default withRouter(DatabaseForm);
+
+export const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+export const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+export const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+export const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+export const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+export const floatIn = keyframes`
+  0% {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+`;
+
+export const AppearingErrorContainer = styled.div`
+  animation: ${floatIn} 0.5s;
+  animation-fill-mode: forwards;
+`;
+
+export const RevealButton = styled.div`
+  background: ${(props) => props.theme.fg};
+  padding: 5px 10px;
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+export const Blur = styled.div`
+  filter: blur(5px);
+  -webkit-filter: blur(5px);
+  position: relative;
+  margin-left: -5px;
+  font-family: monospace;
+`;

+ 210 - 0
dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx

@@ -0,0 +1,210 @@
+import React, { useEffect, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import _ from "lodash";
+import { useForm } from "react-hook-form";
+import { withRouter, type RouteComponentProps } from "react-router";
+import { v4 as uuidv4 } from "uuid";
+
+import Back from "components/porter/Back";
+import ClickToCopy from "components/porter/ClickToCopy";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import Resources from "../tabs/Resources";
+import { type DatabaseTemplate } from "../types";
+import DatabaseForm, {
+  AppearingErrorContainer,
+  Blur,
+  CenterWrapper,
+  DarkMatter,
+  Div,
+  Icon,
+  RevealButton,
+  StyledConfigureTemplate,
+} from "./DatabaseForm";
+import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
+
+type Props = RouteComponentProps & {
+  template: DatabaseTemplate;
+};
+
+const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
+
+  const dbForm = useForm<DbFormData>({
+    resolver: zodResolver(dbFormValidator),
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      config: {
+        type: "rds-postgresql-aurora",
+        databaseName: "postgres",
+        masterUsername: "postgres",
+        masterUserPassword: uuidv4(),
+      },
+    },
+  });
+
+  const {
+    setValue,
+    formState: { errors },
+    register,
+    watch,
+  } = dbForm;
+
+  const watchName = watch("name", "");
+  const watchTier = watch("config.instanceClass", "unspecified");
+
+  const watchDbName = watch("config.databaseName");
+  const watchDbUsername = watch("config.masterUsername");
+  const watchDbPassword = watch("config.masterUserPassword");
+
+  useEffect(() => {
+    let newStep = 0;
+    if (watchName) {
+      newStep = 1;
+    }
+    if (watchTier !== "unspecified") {
+      newStep = 3;
+    }
+    setCurrentStep(Math.max(newStep, currentStep));
+  }, [watchName, watchTier]);
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back
+            onClick={() => {
+              history.push(`/databases/new`);
+            }}
+          />
+          <DashboardHeader
+            prefix={<Icon src={template.icon} />}
+            title={template.formTitle}
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <DatabaseForm
+            steps={[
+              <>
+                <Text size={16}>Specify database name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and &quot;-&quot; only.
+                </Text>
+                <Spacer height="20px" />
+                <ControlledInput
+                  placeholder="ex: academic-sophon-db"
+                  type="text"
+                  width="300px"
+                  error={errors.name?.message}
+                  {...register("name")}
+                />
+              </>,
+              <>
+                <Text size={16}>Specify database resources</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Specify your database CPU, RAM, and storage.
+                </Text>
+                {errors.config?.instanceClass?.message && (
+                  <AppearingErrorContainer>
+                    <Spacer y={0.5} />
+                    <Error message={errors.config.instanceClass.message} />
+                  </AppearingErrorContainer>
+                )}
+                <Spacer y={0.5} />
+                <Text>Select an instance tier:</Text>
+                <Spacer height="20px" />
+                <Resources
+                  options={template.instanceTiers}
+                  selected={watchTier}
+                  onSelect={(option: ResourceOption) => {
+                    setValue("config.instanceClass", option.tier);
+                    setValue(
+                      "config.allocatedStorageGigabytes",
+                      option.storageGigabytes
+                    );
+                  }}
+                  highlight={"storage"}
+                />
+              </>,
+              <>
+                <Text size={16}>View database credentials</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  These credentials never leave your own cloud environment. You
+                  will be able to automatically import these credentials from
+                  any app.
+                </Text>
+                <Spacer height="20px" />
+                <Fieldset>
+                  <Text>Postgres DB name</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {watchDbName}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres username</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {watchDbUsername}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres password</Text>
+                  <Spacer y={0.5} />
+                  <Container row>
+                    {isPasswordHidden ? (
+                      <>
+                        <Blur>{watchDbPassword}</Blur>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(false);
+                          }}
+                        >
+                          Reveal
+                        </RevealButton>
+                      </>
+                    ) : (
+                      <>
+                        <ClickToCopy color="helper">
+                          {watchDbPassword}
+                        </ClickToCopy>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(true);
+                          }}
+                        >
+                          Hide
+                        </RevealButton>
+                      </>
+                    )}
+                  </Container>
+                </Fieldset>
+              </>,
+            ]}
+            currentStep={currentStep}
+            form={dbForm}
+          />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(DatabaseFormAuroraPostgres);

+ 191 - 0
dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx

@@ -0,0 +1,191 @@
+import React, { useEffect, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import _ from "lodash";
+import { useForm } from "react-hook-form";
+import { withRouter, type RouteComponentProps } from "react-router";
+import { v4 as uuidv4 } from "uuid";
+
+import Back from "components/porter/Back";
+import ClickToCopy from "components/porter/ClickToCopy";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import Resources from "../tabs/Resources";
+import { type DatabaseTemplate } from "../types";
+import DatabaseForm, {
+  AppearingErrorContainer,
+  Blur,
+  CenterWrapper,
+  DarkMatter,
+  Div,
+  Icon,
+  RevealButton,
+  StyledConfigureTemplate,
+} from "./DatabaseForm";
+import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
+
+type Props = RouteComponentProps & {
+  template: DatabaseTemplate;
+};
+
+const DatabaseFormElasticacheRedis: React.FC<Props> = ({
+  history,
+  template,
+}) => {
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
+
+  const dbForm = useForm<DbFormData>({
+    resolver: zodResolver(dbFormValidator),
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      config: {
+        type: "elasticache-redis",
+        databaseName: "postgres",
+        masterUsername: "postgres",
+        masterUserPassword: uuidv4(),
+      },
+    },
+  });
+
+  const {
+    setValue,
+    formState: { errors },
+    register,
+    watch,
+  } = dbForm;
+
+  const watchName = watch("name", "");
+  const watchTier = watch("config.instanceClass", "unspecified");
+
+  const watchDbPassword = watch("config.masterUserPassword");
+
+  useEffect(() => {
+    let newStep = 0;
+    if (watchName) {
+      newStep = 1;
+    }
+    if (watchTier !== "unspecified") {
+      newStep = 2;
+    }
+    setCurrentStep(Math.max(newStep, currentStep));
+  }, [watchName, watchTier]);
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back
+            onClick={() => {
+              history.push(`/databases/new`);
+            }}
+          />
+          <DashboardHeader
+            prefix={<Icon src={template.icon} />}
+            title={template.formTitle}
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <DatabaseForm
+            steps={[
+              <>
+                <Text size={16}>Specify database name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and &quot;-&quot; only.
+                </Text>
+                <Spacer height="20px" />
+                <ControlledInput
+                  placeholder="ex: academic-sophon-db"
+                  type="text"
+                  width="300px"
+                  error={errors.name?.message}
+                  {...register("name")}
+                />
+              </>,
+              <>
+                <Text size={16}>Specify database resources</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">Specify your database CPU and RAM.</Text>
+                {errors.config?.instanceClass?.message && (
+                  <AppearingErrorContainer>
+                    <Spacer y={0.5} />
+                    <Error message={errors.config.instanceClass.message} />
+                  </AppearingErrorContainer>
+                )}
+                <Spacer y={0.5} />
+                <Text>Select an instance tier:</Text>
+                <Spacer height="20px" />
+                <Resources
+                  options={template.instanceTiers}
+                  selected={watchTier}
+                  onSelect={(option: ResourceOption) => {
+                    setValue("config.instanceClass", option.tier);
+                    setValue(
+                      "config.allocatedStorageGigabytes",
+                      option.storageGigabytes
+                    );
+                  }}
+                  highlight={"ram"}
+                />
+              </>,
+              <>
+                <Text size={16}>Database credentials</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  These credentials never leave your own cloud environment. You
+                  will be able to automatically import these credentials from
+                  any app.
+                </Text>
+                <Spacer height="20px" />
+                <Fieldset>
+                  <Text>Redis token</Text>
+                  <Spacer y={0.5} />
+                  <Container row>
+                    {isPasswordHidden ? (
+                      <>
+                        <Blur>{watchDbPassword}</Blur>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(false);
+                          }}
+                        >
+                          Reveal
+                        </RevealButton>
+                      </>
+                    ) : (
+                      <>
+                        <ClickToCopy color="helper">
+                          {watchDbPassword}
+                        </ClickToCopy>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(true);
+                          }}
+                        >
+                          Hide
+                        </RevealButton>
+                      </>
+                    )}
+                  </Container>
+                </Fieldset>
+              </>,
+            ]}
+            currentStep={currentStep}
+            form={dbForm}
+          />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(DatabaseFormElasticacheRedis);

+ 210 - 0
dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx

@@ -0,0 +1,210 @@
+import React, { useEffect, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import _ from "lodash";
+import { useForm } from "react-hook-form";
+import { withRouter, type RouteComponentProps } from "react-router";
+import { v4 as uuidv4 } from "uuid";
+
+import Back from "components/porter/Back";
+import ClickToCopy from "components/porter/ClickToCopy";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
+import Fieldset from "components/porter/Fieldset";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
+import Resources from "../tabs/Resources";
+import { type DatabaseTemplate } from "../types";
+import DatabaseForm, {
+  AppearingErrorContainer,
+  Blur,
+  CenterWrapper,
+  DarkMatter,
+  Div,
+  Icon,
+  RevealButton,
+  StyledConfigureTemplate,
+} from "./DatabaseForm";
+import { dbFormValidator, type DbFormData, type ResourceOption } from "./types";
+
+type Props = RouteComponentProps & {
+  template: DatabaseTemplate;
+};
+
+const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
+  const [currentStep, setCurrentStep] = useState<number>(0);
+  const [isPasswordHidden, setIsPasswordHidden] = useState<boolean>(true);
+
+  const dbForm = useForm<DbFormData>({
+    resolver: zodResolver(dbFormValidator),
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      config: {
+        type: "rds-postgres",
+        databaseName: "postgres",
+        masterUsername: "postgres",
+        masterUserPassword: uuidv4(),
+      },
+    },
+  });
+
+  const {
+    setValue,
+    formState: { errors },
+    register,
+    watch,
+  } = dbForm;
+
+  const watchName = watch("name", "");
+  const watchTier = watch("config.instanceClass", "unspecified");
+
+  const watchDbName = watch("config.databaseName");
+  const watchDbUsername = watch("config.masterUsername");
+  const watchDbPassword = watch("config.masterUserPassword");
+
+  useEffect(() => {
+    let newStep = 0;
+    if (watchName) {
+      newStep = 1;
+    }
+    if (watchTier !== "unspecified") {
+      newStep = 2;
+    }
+    setCurrentStep(Math.max(newStep, currentStep));
+  }, [watchName, watchTier]);
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back
+            onClick={() => {
+              history.push(`/databases/new`);
+            }}
+          />
+          <DashboardHeader
+            prefix={<Icon src={template.icon} />}
+            title={template.formTitle}
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <DatabaseForm
+            steps={[
+              <>
+                <Text size={16}>Specify database name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and &quot;-&quot; only.
+                </Text>
+                <Spacer height="20px" />
+                <ControlledInput
+                  placeholder="ex: academic-sophon-db"
+                  type="text"
+                  width="300px"
+                  error={errors.name?.message}
+                  {...register("name")}
+                />
+              </>,
+              <>
+                <Text size={16}>Specify database resources</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Specify your database CPU, RAM, and storage.
+                </Text>
+                {errors.config?.instanceClass?.message && (
+                  <AppearingErrorContainer>
+                    <Spacer y={0.5} />
+                    <Error message={errors.config.instanceClass.message} />
+                  </AppearingErrorContainer>
+                )}
+                <Spacer y={0.5} />
+                <Text>Select an instance tier:</Text>
+                <Spacer height="20px" />
+                <Resources
+                  options={template.instanceTiers}
+                  selected={watchTier}
+                  onSelect={(option: ResourceOption) => {
+                    setValue("config.instanceClass", option.tier);
+                    setValue(
+                      "config.allocatedStorageGigabytes",
+                      option.storageGigabytes
+                    );
+                  }}
+                  highlight={"storage"}
+                />
+              </>,
+              <>
+                <Text size={16}>View database credentials</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  These credentials never leave your own cloud environment. You
+                  will be able to automatically import these credentials from
+                  any app.
+                </Text>
+                <Spacer height="20px" />
+                <Fieldset>
+                  <Text>Postgres DB name</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {watchDbName}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres username</Text>
+                  <Spacer y={0.5} />
+                  <Text
+                    additionalStyles="font-family: monospace;"
+                    color="helper"
+                  >
+                    {watchDbUsername}
+                  </Text>
+                  <Spacer y={1} />
+                  <Text>Postgres password</Text>
+                  <Spacer y={0.5} />
+                  <Container row>
+                    {isPasswordHidden ? (
+                      <>
+                        <Blur>{watchDbPassword}</Blur>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(false);
+                          }}
+                        >
+                          Reveal
+                        </RevealButton>
+                      </>
+                    ) : (
+                      <>
+                        <ClickToCopy color="helper">
+                          {watchDbPassword}
+                        </ClickToCopy>
+                        <Spacer inline width="10px" />
+                        <RevealButton
+                          onClick={() => {
+                            setIsPasswordHidden(true);
+                          }}
+                        >
+                          Hide
+                        </RevealButton>
+                      </>
+                    )}
+                  </Container>
+                </Fieldset>
+              </>,
+            ]}
+            currentStep={currentStep}
+            form={dbForm}
+          />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(DatabaseFormRDSPostgres);

+ 0 - 385
dashboard/src/main/home/database-dashboard/forms/ElasticacheRedisForm.tsx

@@ -1,385 +0,0 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-import { v4 as uuidv4 } from 'uuid';
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { pushFiltered } from "shared/routing";
-
-import Back from "components/porter/Back";
-import Button from "components/porter/Button";
-import ClickToCopy from "components/porter/ClickToCopy";
-import Container from "components/porter/Container";
-import Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
-import Input from "components/porter/Input";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import VerticalSteps from "components/porter/VerticalSteps";
-import { RouteComponentProps, withRouter } from "react-router";
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import { ElasticacheRedisFormValues } from "./types";
-
-type Props = RouteComponentProps & {
-  currentTemplate: any;
-  goBack: () => void;
-  repoURL: string | undefined;
-};
-
-const ElasticacheRedisForm: React.FC<Props> = ({
-  currentTemplate,
-  goBack,
-  repoURL,
-  ...props
-}) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [currentStep, setCurrentStep] = useState<number>(0);
-  const [name, setName] = useState<string>("");
-  const [buttonStatus, setButtonStatus] = useState<string>("");
-  const [credentialsSaved, setCredentialsSaved] = useState<boolean>(false);
-  const [dbName, setDbName] = useState<string>("postgres");
-  const [dbPassword, setDbPassword] = useState<string>(uuidv4());
-  const [dbUsername, setDbUsername] = useState<string>("postgres");
-  const [tier, setTier] = useState<string>("");
-  const [hidePassword, setHidePassword] = useState<boolean>(true);
-
-  useEffect(() => {
-    if (currentStep === 1) {
-      setCurrentStep(3);
-    }
-  }, [tier]);
-
-  const waitForHelmRelease = () => {
-    setTimeout(() => {
-      api.getChart(
-        "<token>",
-        {},
-        {
-          id: currentProject?.id || -1,
-          namespace: "ack-system",
-          cluster_id: currentCluster?.id || -1,
-          name,
-          revision: 0,
-        }
-      )
-        .then((res) => {
-          if (res?.data?.version) {
-            setButtonStatus("success");
-            pushFiltered(props, "/databases", ["project_id"], {
-              cluster: currentCluster?.name,
-            });
-          } else {
-            waitForHelmRelease();
-          }
-        })
-        .catch((err) => {
-          waitForHelmRelease();
-        });
-    }, 500);
-  };
-
-  const deploy = async () => {
-    setButtonStatus("loading");
-
-    const values: { config: ElasticacheRedisFormValues } = {
-      config: {
-        name: name,
-        databaseName: dbName,
-        masterUsername: dbUsername,
-        masterUserPassword: dbPassword,
-        instanceClass: tier,
-      }
-    }
-
-    api
-      .deployAddon(
-        "<token>",
-        {
-          template_name: "elasticache-redis",
-          template_version: "latest",
-          values,
-          name,
-        },
-        {
-          id: currentProject?.id || -1,
-          cluster_id: currentCluster?.id || -1,
-          namespace: "ack-system",
-          repo_url: repoURL,
-        }
-      )
-      .then((_) => {
-        waitForHelmRelease();
-      })
-      .catch((err) => {
-        let parsedErr = err?.response?.data?.error;
-        err = parsedErr || err.message || JSON.stringify(err);
-        setButtonStatus(err);
-        return;
-      });
-  };
-
-  const getStatus = () => {
-    if (!buttonStatus) {
-      return;
-    }
-    if (buttonStatus === "loading" || buttonStatus === "success") {
-      return buttonStatus;
-    } else {
-      return (
-        <Error message={buttonStatus} />
-      );
-    }
-  };
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back onClick={goBack} />
-          <DashboardHeader
-            prefix={
-              <Icon 
-                src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
-              />
-            }
-            title="Create an Elasticache Redis cluster"
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <VerticalSteps
-            currentStep={currentStep}
-            steps={[
-              <>
-                <Text size={16}>Redis name</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and "-" only.
-                </Text>
-                <Spacer height="20px" />
-                <Input
-                  placeholder="ex: my-redis"
-                  value={name}
-                  width="300px"
-                  setValue={(e) => {
-                    if (e) {
-                      credentialsSaved ? setCurrentStep(2) : setCurrentStep(1);
-                    } else {
-                      setCurrentStep(0);
-                    }
-                    setName(e);
-                  }}
-                />
-              </>,
-              <>
-                <Text size={16}>Redis resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Specify your database CPU, RAM.
-                </Text>
-                <Spacer y={.5} />
-                <Text>
-                  Select an instance tier:
-                </Text>
-                <Spacer height="20px" />
-                <ResourceOption
-                  selected={tier === "cache.t4g.micro"}
-                  onClick={() => {
-                    setTier("cache.t4g.micro");
-                  }}
-                >
-                  <Container row>
-                    <Text>Micro</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU</Text>
-                  </Container>
-                  <StorageTag>0.5 GB RAM</StorageTag>
-                </ResourceOption>
-                <Spacer height="20px" />
-                <ResourceOption
-                  selected={tier === "cache.t4g.medium"}
-                  onClick={() => {
-                    setTier("cache.t4g.medium");
-                  }}
-                >
-                  <Container row>
-                    <Text>Medium</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU</Text>
-                  </Container>
-                  <StorageTag>3 GB RAM</StorageTag>
-                </ResourceOption>
-                <Spacer height="15px" />
-                <ResourceOption
-                  selected={tier === "cache.r7g.large"}
-                  onClick={() => {
-                    setTier("cache.r7g.large");
-                  }}
-                >
-                  <Container row>
-                    <Text>Large</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 cpu</Text>
-                  </Container>
-                  <StorageTag>13 GB RAM</StorageTag>
-                </ResourceOption>
-                <Spacer height="15px" />
-                <ResourceOption
-                  selected={tier === "cache.r7g.xlarge"}
-                  onClick={() => {
-                    setTier("cache.r7g.xlarge");
-                  }}
-                >
-                  <Container row>
-                    <Text>Extra Large</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 4 cpu</Text>
-                  </Container>
-                  <StorageTag>26 GB RAM</StorageTag>
-                </ResourceOption>
-              </>,
-              <>
-                <Text size={16}>Database credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. You will be able to automatically import these credentials from any app.
-                </Text>
-                <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Redis token</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {hidePassword ? (
-                      <>
-                        <Blur>{dbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(false)}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {dbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(true)}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
-              </>,
-              <>
-                <Text size={16}>Provision a redis cluster</Text>
-                <Spacer y={0.5} />
-                <Button
-                  onClick={deploy}
-                  disabled={buttonStatus === "loading"}
-                  status={getStatus()}
-                >
-                  Create redis cluster
-                </Button>
-              </>
-            ]}
-          />
-          <Spacer height="80px" />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(ElasticacheRedisForm);
-
-const RevealButton = styled.div`
-  background: ${props => props.theme.fg};
-  padding: 5px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-const Blur = styled.div`
-  filter: blur(5px);
-  -webkit-filter: blur(5px);
-  position: relative;
-  margin-left: -5px;
-  font-family: monospace;
-`;
-
-const StorageTag = styled.div`
-  background: #202227;
-  color: #aaaabb;
-  border-radius: 5px;
-  padding: 5px 10px;
-  font-size: 13px;
-  margin-left: 5px;
-`;
-
-const ResourceOption = styled.div<{ selected?: boolean }>`
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid ${props => props.selected ? "#ffffff" : props.theme.border};
-  width: 350px;
-  padding: 10px 15px;
-  border-radius: 5px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #ffffff;
-  }
-`;
-
-const Div = styled.div`
-  width: 100%;
-  max-width: 900px;
-`;
-
-const CenterWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -5px;
-`;
-
-const Icon = styled.img`
-  margin-right: 15px;
-  height: 30px;
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const StyledConfigureTemplate = styled.div`
-  height: 100%;
-`;

+ 0 - 406
dashboard/src/main/home/database-dashboard/forms/RDSForm.tsx

@@ -1,406 +0,0 @@
-import React, { useEffect, useState, useContext } from "react";
-import styled from "styled-components";
-import _ from "lodash";
-import { v4 as uuidv4 } from 'uuid';
-
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { pushFiltered } from "shared/routing";
-
-import Back from "components/porter/Back";
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
-import Input from "components/porter/Input";
-import VerticalSteps from "components/porter/VerticalSteps";
-import Button from "components/porter/Button";
-import { RouteComponentProps, withRouter } from "react-router";
-import Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
-import Container from "components/porter/Container";
-import ClickToCopy from "components/porter/ClickToCopy";
-import { RdsFormValues } from "./types";
-
-type Props = RouteComponentProps & {
-  currentTemplate: any;
-  goBack: () => void;
-  repoURL: string | undefined;
-};
-
-const RDSForm: React.FC<Props> = ({
-  currentTemplate,
-  goBack,
-  repoURL,
-  ...props
-}) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [currentStep, setCurrentStep] = useState<number>(0);
-  const [name, setName] = useState<string>("");
-  const [buttonStatus, setButtonStatus] = useState<string>("");
-  const [credentialsSaved, setCredentialsSaved] = useState<boolean>(false);
-  const [dbName, setDbName] = useState<string>("postgres");
-  const [dbPassword, setDbPassword] = useState<string>(uuidv4());
-  const [dbUsername, setDbUsername] = useState<string>("postgres");
-  const [storage, setStorage] = useState<number>(0);
-  const [tier, setTier] = useState<string>("");
-  const [hidePassword, setHidePassword] = useState<boolean>(true);
-
-  useEffect(() => {
-    if (currentStep === 1) {
-      setCurrentStep(3);
-    }
-  }, [tier]);
-
-  const waitForHelmRelease = () => {
-    setTimeout(() => {
-      api.getChart(
-        "<token>",
-        {},
-        {
-          id: currentProject?.id || -1,
-          namespace: "ack-system",
-          cluster_id: currentCluster?.id || -1,
-          name,
-          revision: 0,
-        }
-      )
-        .then((res) => {
-          if (res?.data?.version) {
-            setButtonStatus("success");
-            pushFiltered(props, "/databases", ["project_id"], {
-              cluster: currentCluster?.name,
-            });
-          } else {
-            waitForHelmRelease();
-          }
-        })
-        .catch((err) => {
-          waitForHelmRelease();
-        });
-    }, 500);
-  };
-
-  const deploy = async (wildcard?: any) => {
-    setButtonStatus("loading");
-
-    let values: { config: RdsFormValues } = {
-      config: {
-        name: name,
-        databaseName: dbName,
-        masterUsername: dbUsername,
-        masterUserPassword: dbPassword,
-        allocatedStorage: storage,
-        instanceClass: tier,
-      }
-    }
-
-    api
-      .deployAddon(
-        "<token>",
-        {
-          template_name: "rds-postgresql",
-          template_version: "latest",
-          values: values,
-          name,
-        },
-        {
-          id: currentProject?.id || -1,
-          cluster_id: currentCluster?.id || -1,
-          namespace: "ack-system",
-          repo_url: repoURL,
-        }
-      )
-      .then((_) => {
-        window.analytics?.track("Deployed RDS", {
-          name,
-          namespace: "ack-system",
-          values: values,
-        });
-        waitForHelmRelease();
-      })
-      .catch((err) => {
-        let parsedErr = err?.response?.data?.error;
-        err = parsedErr || err.message || JSON.stringify(err);
-        setButtonStatus(err);
-        window.analytics?.track("Failed to Deploy RDS", {
-          name,
-          namespace: "ack-system",
-          values: values,
-          error: err,
-        });
-        return;
-      });
-  };
-
-  const getStatus = () => {
-    if (!buttonStatus) {
-      return;
-    }
-    if (buttonStatus === "loading" || buttonStatus === "success") {
-      return buttonStatus;
-    } else {
-      return (
-        <Error message={buttonStatus} />
-      );
-    }
-  };
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back onClick={goBack} />
-          <DashboardHeader
-            prefix={
-              <Icon 
-                src={hardcodedIcons[currentTemplate.name] || currentTemplate.icon}
-              />
-            }
-            title="Create an RDS PostgreSQL instance"
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <VerticalSteps
-            currentStep={currentStep}
-            steps={[
-              <>
-                <Text size={16}>Database name</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Lowercase letters, numbers, and "-" only.
-                </Text>
-                <Spacer height="20px" />
-                <Input
-                  placeholder="ex: academic-sophon"
-                  value={name}
-                  width="300px"
-                  setValue={(e) => {
-                    if (e) {
-                      credentialsSaved ? setCurrentStep(2) : setCurrentStep(1);
-                    } else {
-                      setCurrentStep(0);
-                    }
-                    setName(e);
-                  }}
-                />
-              </>,
-              <>
-                <Text size={16}>Database resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Specify your database CPU, RAM, and storage.
-                </Text>
-                <Spacer y={.5} />
-                <Text>
-                  Select an instance tier:
-                </Text>
-                <Spacer height="20px" />
-                <ResourceOption
-                  selected={tier === "db.t4g.small"}
-                  onClick={() => {
-                    setStorage(30);
-                    setTier("db.t4g.small");
-                  }}
-                >
-                  <Container row>
-                    <Text>Small</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU, 2 GB RAM</Text>
-                  </Container>
-                  <StorageTag>30 GB Storage</StorageTag>
-                </ResourceOption>
-                <Spacer height="15px" />
-                <ResourceOption
-                  selected={tier === "db.t4g.medium"}
-                  onClick={() => {
-                    setStorage(100);
-                    setTier("db.t4g.medium");
-                  }}
-                >
-                  <Container row>
-                    <Text>Medium</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU, 4 GB RAM</Text>
-                  </Container>
-                  <StorageTag>100 GB Storage</StorageTag>
-                </ResourceOption>
-                <Spacer height="15px" />
-                <ResourceOption
-                  selected={tier === "db.t4g.large"}
-                  onClick={() => {
-                    setStorage(256);
-                    setTier("db.t4g.large");
-                  }}
-                >
-                  <Container row>
-                    <Text>Large</Text>
-                    <Spacer inline width="5px" />
-                    <Text color="helper">- 2 CPU, 8 GB RAM</Text>
-                  </Container>
-                  <StorageTag>256 GB Storage</StorageTag>
-                </ResourceOption>
-              </>,
-              <>
-                <Text size={16}>Database credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. You will be able to automatically import these credentials from any app.
-                </Text>
-                <Spacer height="20px" />
-                <Fieldset>
-                  <Text>Postgres DB name</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {dbName}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres username</Text>
-                  <Spacer y={0.5} />
-                  <Text
-                    additionalStyles="font-family: monospace;"
-                    color="helper"
-                  >
-                    {dbUsername}
-                  </Text>
-                  <Spacer y={1} />
-                  <Text>Postgres password</Text>
-                  <Spacer y={0.5} />
-                  <Container row>
-                    {hidePassword ? (
-                      <>
-                        <Blur>{dbPassword}</Blur>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(false)}
-                        >
-                          Reveal
-                        </RevealButton>
-                      </>
-                    ) : (
-                      <>
-                        <ClickToCopy color="helper">
-                          {dbPassword}
-                        </ClickToCopy>
-                        <Spacer inline width="10px" />
-                        <RevealButton
-                          onClick={() => setHidePassword(true)}
-                        >
-                          Hide
-                        </RevealButton>
-                      </>
-                    )}
-                  </Container>
-                </Fieldset>
-              </>,
-              <>
-                <Text size={16}>Provision a database</Text>
-                <Spacer y={0.5} />
-                <Button
-                  onClick={deploy}
-                  disabled={buttonStatus === "loading"}
-                  status={getStatus()}
-                >
-                  Create database
-                </Button>
-              </>
-            ]}
-          />
-          <Spacer height="80px" />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(RDSForm);
-
-const RevealButton = styled.div`
-  background: ${props => props.theme.fg};
-  padding: 5px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-const Blur = styled.div`
-  filter: blur(5px);
-  -webkit-filter: blur(5px);
-  position: relative;
-  margin-left: -5px;
-  font-family: monospace;
-`;
-
-const StorageTag = styled.div`
-  background: #202227;
-  color: #aaaabb;
-  border-radius: 5px;
-  padding: 5px 10px;
-  font-size: 13px;
-  margin-left: 5px;
-`;
-
-const ResourceOption = styled.div<{ selected?: boolean }>`
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid ${props => props.selected ? "#ffffff" : props.theme.border};
-  width: 350px;
-  padding: 10px 15px;
-  border-radius: 5px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  cursor: pointer;
-  :hover {
-    border: 1px solid #ffffff;
-  }
-`;
-
-const Div = styled.div`
-  width: 100%;
-  max-width: 900px;
-`;
-
-const CenterWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -5px;
-`;
-
-const Icon = styled.img`
-  margin-right: 15px;
-  height: 30px;
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const StyledConfigureTemplate = styled.div`
-  height: 100%;
-`;

+ 116 - 19
dashboard/src/main/home/database-dashboard/forms/types.ts

@@ -1,25 +1,122 @@
-export type RdsFormValues = {
-    name: string,
-    databaseName: string,
-    masterUsername: string,
-    masterUserPassword: string,
-    allocatedStorage: number,
-    instanceClass: string,
-};
+import { z } from "zod";
 
 export type AuroraPostgresFormValues = {
-    name: string,
-    databaseName: string,
-    masterUsername: string,
-    masterUserPassword: string,
-    allocatedStorage: number,
-    instanceClass: string,
+  name: string;
+  databaseName: string;
+  masterUsername: string;
+  masterUserPassword: string;
+  allocatedStorage: number;
+  instanceClass: string;
 };
 
 export type ElasticacheRedisFormValues = {
-    name: string,
-    databaseName: string,
-    masterUsername: string,
-    masterUserPassword: string,
-    instanceClass: string,
+  name: string;
+  databaseName: string;
+  masterUsername: string;
+  masterUserPassword: string;
+  instanceClass: string;
+};
+const instanceTierValidator = z.enum([
+  "unspecified",
+  "db.t4g.small",
+  "db.t4g.medium",
+  "db.t4g.large",
+  "cache.t4g.micro",
+  "cache.t4g.medium",
+  "cache.r7g.large",
+  "cache.r7g.xlarge",
+]);
+export type InstanceTier = z.infer<typeof instanceTierValidator>;
+
+const rdsPostgresConfigValidator = z.object({
+  type: z.literal("rds-postgres"),
+  instanceClass: instanceTierValidator
+    .default("unspecified")
+    .refine((val) => val !== "unspecified", {
+      message: "Instance class is required",
+    }),
+  allocatedStorageGigabytes: z
+    .number()
+    .int()
+    .positive("Allocated storage must be a positive integer")
+    .default(30),
+  // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
+  databaseName: z
+    .string()
+    .nonempty("Database name is required")
+    .default("postgres"),
+  masterUsername: z
+    .string()
+    .nonempty("Master username is required")
+    .default("postgres"),
+  masterUserPassword: z
+    .string()
+    .nonempty("Master password is required")
+    .default(""),
+});
+
+const auroraPostgresConfigValidator = z.object({
+  type: z.literal("rds-postgresql-aurora"),
+  instanceClass: instanceTierValidator
+    .default("unspecified")
+    .refine((val) => val !== "unspecified", {
+      message: "Instance class is required",
+    }),
+  allocatedStorageGigabytes: z
+    .number()
+    .int()
+    .positive("Allocated storage must be a positive integer")
+    .default(30),
+  // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
+  databaseName: z.string().nonempty("Database name is required").default(""),
+  masterUsername: z
+    .string()
+    .nonempty("Master username is required")
+    .default(""),
+  masterUserPassword: z
+    .string()
+    .nonempty("Master password is required")
+    .default(""),
+});
+
+const elasticacheRedisConfigValidator = z.object({
+  type: z.literal("elasticache-redis"),
+  instanceClass: instanceTierValidator
+    .default("unspecified")
+    .refine((val) => val !== "unspecified", {
+      message: "Instance class is required",
+    }),
+  // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
+  databaseName: z.string().nonempty("Database name is required").default(""),
+  masterUsername: z
+    .string()
+    .nonempty("Master username is required")
+    .default(""),
+  masterUserPassword: z
+    .string()
+    .nonempty("Master password is required")
+    .default(""),
+});
+
+export const dbFormValidator = z.object({
+  name: z
+    .string()
+    .nonempty("Name is required")
+    .regex(/^[a-z0-9-]+$/, {
+      message: "Lowercase letters, numbers, and “-” only.",
+    }),
+  config: z.discriminatedUnion("type", [
+    rdsPostgresConfigValidator,
+    auroraPostgresConfigValidator,
+    elasticacheRedisConfigValidator,
+  ]),
+});
+export type DbFormData = z.infer<typeof dbFormValidator>;
+
+export type ResourceOption = {
+  tier: InstanceTier;
+  label: string;
+  cpuCores: number;
+  ramGigabytes: number;
+  storageGigabytes: number;
 };

+ 93 - 0
dashboard/src/main/home/database-dashboard/tabs/Resources.tsx

@@ -0,0 +1,93 @@
+import React from "react";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import { type InstanceTier, type ResourceOption } from "../forms/types";
+
+type Props = {
+  options: ResourceOption[];
+  selected: InstanceTier;
+  onSelect: (option: ResourceOption) => void;
+  highlight: "storage" | "ram";
+};
+
+const Resources: React.FC<Props> = ({
+  options,
+  selected,
+  onSelect,
+  highlight,
+}) => {
+  return (
+    <ResourcesContainer>
+      {options.map((o) => {
+        return (
+          <StyledResourceOption
+            key={o.tier}
+            selected={o.tier === selected}
+            onClick={() => {
+              onSelect(o);
+            }}
+          >
+            {highlight === "storage" ? (
+              <>
+                <Container row>
+                  <Text>{o.label}</Text>
+                  <Spacer inline width="5px" />
+                  <Text color="helper">
+                    - {o.cpuCores} CPU, {o.ramGigabytes} GB RAM
+                  </Text>
+                </Container>
+                <StorageTag>{o.storageGigabytes} GB Storage</StorageTag>
+              </>
+            ) : (
+              <>
+                <Container row>
+                  <Text>{o.label}</Text>
+                  <Spacer inline width="5px" />
+                  <Text color="helper">- {o.cpuCores} CPU</Text>
+                </Container>
+                <StorageTag>{o.ramGigabytes} GB RAM</StorageTag>
+              </>
+            )}
+          </StyledResourceOption>
+        );
+      })}
+    </ResourcesContainer>
+  );
+};
+
+export default Resources;
+
+const ResourcesContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+`;
+
+const StyledResourceOption = styled.div<{ selected?: boolean }>`
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid
+    ${(props) => (props.selected ? "#ffffff" : props.theme.border)};
+  width: 350px;
+  padding: 10px 15px;
+  border-radius: 5px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #ffffff;
+  }
+`;
+
+const StorageTag = styled.div`
+  background: #202227;
+  color: #aaaabb;
+  border-radius: 5px;
+  padding: 5px 10px;
+  font-size: 13px;
+  margin-left: 5px;
+`;

+ 4 - 0
dashboard/src/main/home/database-dashboard/types.ts

@@ -1,5 +1,7 @@
 import { z } from "zod";
 
+import { type ResourceOption } from "./forms/types";
+
 export const datastoreEnvValidator = z.object({
   name: z.string(),
   linked_applications: z.string().array().default([]),
@@ -88,4 +90,6 @@ export type DatabaseTemplate = {
   name: string;
   description: string;
   disabled: boolean;
+  instanceTiers: ResourceOption[];
+  formTitle: string;
 };