Răsfoiți Sursa

Merge pull request #814 from porter-dev/0.5.0-forms-refactor

[0.5.0] [Frontend, Forms] Forms Refactor
jusrhee 4 ani în urmă
părinte
comite
7ba5260bdb
90 a modificat fișierele cu 3013 adăugiri și 2001 ștergeri
  1. BIN
      dashboard/src/assets/node.png
  2. 1 1
      dashboard/src/components/PageNotFound.tsx
  3. 26 5
      dashboard/src/components/SaveButton.tsx
  4. 27 40
      dashboard/src/components/TabRegion.tsx
  5. 1 1
      dashboard/src/components/Table.tsx
  6. 0 0
      dashboard/src/components/form-components/CheckboxList.tsx
  7. 0 0
      dashboard/src/components/form-components/CheckboxRow.tsx
  8. 0 0
      dashboard/src/components/form-components/Heading.tsx
  9. 0 0
      dashboard/src/components/form-components/Helper.tsx
  10. 0 0
      dashboard/src/components/form-components/InputRow.tsx
  11. 0 0
      dashboard/src/components/form-components/KeyValueArray.tsx
  12. 0 0
      dashboard/src/components/form-components/SelectRow.tsx
  13. 0 0
      dashboard/src/components/form-components/TextArea.tsx
  14. 0 0
      dashboard/src/components/form-components/UploadArea.tsx
  15. 537 0
      dashboard/src/components/porter-form/FormDebugger.tsx
  16. 229 0
      dashboard/src/components/porter-form/PorterForm.tsx
  17. 410 0
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  18. 86 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  19. 74 53
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  20. 68 0
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  21. 94 0
      dashboard/src/components/porter-form/field-components/Input.tsx
  22. 510 0
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  23. 0 0
      dashboard/src/components/porter-form/field-components/MultiSelect.tsx
  24. 32 0
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  25. 99 0
      dashboard/src/components/porter-form/field-components/Select.tsx
  26. 23 0
      dashboard/src/components/porter-form/field-components/ServiceIPList.tsx
  27. 0 0
      dashboard/src/components/porter-form/field-components/ServiceRow.tsx
  28. 3 3
      dashboard/src/components/porter-form/field-components/VeleroForm.tsx
  29. 85 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  30. 243 0
      dashboard/src/components/porter-form/types.ts
  31. 1 1
      dashboard/src/components/repo-selector/ActionDetails.tsx
  32. 1 1
      dashboard/src/components/repo-selector/NewGHAction.tsx
  33. 1 1
      dashboard/src/components/repo-selector/RepoList.tsx
  34. 0 99
      dashboard/src/components/values-form/Base64InputRow.tsx
  35. 0 323
      dashboard/src/components/values-form/FormDebugger.tsx
  36. 0 519
      dashboard/src/components/values-form/FormWrapper.tsx
  37. 0 69
      dashboard/src/components/values-form/RangeSlider.tsx
  38. 0 412
      dashboard/src/components/values-form/ValuesForm.tsx
  39. 1 1
      dashboard/src/main/home/Home.tsx
  40. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  41. 3 3
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  42. 1 1
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx
  43. 64 120
      dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx
  44. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  45. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  46. 18 14
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  47. 70 56
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  48. 61 58
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  49. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  50. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  51. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  52. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  53. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  54. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  55. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx
  56. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  57. 1 1
      dashboard/src/main/home/dashboard/ClusterList.tsx
  58. 1 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  59. 0 69
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  60. 1 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  61. 2 2
      dashboard/src/main/home/integrations/Integrations.tsx
  62. 56 38
      dashboard/src/main/home/integrations/SlackIntegrationList.tsx
  63. 1 1
      dashboard/src/main/home/integrations/create-integration/DockerHubForm.tsx
  64. 3 3
      dashboard/src/main/home/integrations/create-integration/ECRForm.tsx
  65. 4 4
      dashboard/src/main/home/integrations/create-integration/EKSForm.tsx
  66. 4 4
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  67. 4 4
      dashboard/src/main/home/integrations/create-integration/GKEForm.tsx
  68. 1 1
      dashboard/src/main/home/integrations/edit-integration/DockerHubForm.tsx
  69. 3 3
      dashboard/src/main/home/integrations/edit-integration/ECRForm.tsx
  70. 4 4
      dashboard/src/main/home/integrations/edit-integration/EKSForm.tsx
  71. 4 4
      dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx
  72. 4 4
      dashboard/src/main/home/integrations/edit-integration/GKEForm.tsx
  73. 1 1
      dashboard/src/main/home/launch/Launch.tsx
  74. 1 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  75. 18 11
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  76. 4 8
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  77. 2 2
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  78. 1 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  79. 1 1
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  80. 1 1
      dashboard/src/main/home/modals/NamespaceModal.tsx
  81. 1 1
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  82. 2 2
      dashboard/src/main/home/new-project/NewProject.tsx
  83. 3 3
      dashboard/src/main/home/project-settings/InviteList.tsx
  84. 3 3
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  85. 6 6
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  86. 6 6
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  87. 7 7
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  88. 1 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  89. 70 0
      docs/developing/forms.md
  90. 7 6
      internal/models/templates.go

BIN
dashboard/src/assets/node.png


+ 1 - 1
dashboard/src/components/PageNotFound.tsx

@@ -147,7 +147,7 @@ const StyledPageNotFound = styled.div`
   color: #6f6f6f;
   font-size: 16px;
   user-select: none;
-  padding-bottom: 20px;
+  margin-top: -80px;
   width: 100%;
   height: 100%;
   display: flex;

+ 26 - 5
dashboard/src/components/SaveButton.tsx

@@ -3,11 +3,12 @@ import styled from "styled-components";
 import loading from "assets/loading.gif";
 
 type PropsType = {
-  text: string;
+  text?: string;
   onClick: () => void;
   disabled?: boolean;
   status?: string | null;
   color?: string;
+  rounded?: boolean;
   helper?: string | null;
 
   // Makes flush with corner if not within a modal
@@ -78,11 +79,12 @@ export default class SaveButton extends Component<PropsType, StateType> {
           <div>{this.renderStatus()}</div>
         )}
         <Button
+          rounded={this.props.rounded}
           disabled={this.props.disabled}
           onClick={this.props.onClick}
           color={this.props.color || "#616FEEcc"}
         >
-          {this.props.text}
+          {this.props.children || this.props.text}
         </Button>
         {this.props.statusPosition === "right" && (
           <div>{this.renderStatus()}</div>
@@ -180,17 +182,22 @@ const ButtonWrapper = styled.div`
   }}
 `;
 
-const Button = styled.button`
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
   height: 35px;
   font-size: 13px;
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: white;
-  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
   padding: 6px 20px 7px 20px;
   text-align: left;
   border: 0;
-  border-radius: 5px;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
   box-shadow: ${(props) =>
     !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
@@ -202,4 +209,18 @@ const Button = styled.button`
   :hover {
     filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    margin-left: -5px;
+    justify-content: center;
+  }
 `;

+ 27 - 40
dashboard/src/components/TabRegion.tsx

@@ -4,13 +4,19 @@ import styled from "styled-components";
 import TabSelector from "./TabSelector";
 import Loading from "./Loading";
 
+export interface TabOption {
+  label: string;
+  value: string;
+}
+
 type PropsType = {
-  options: { label: string; value: string }[];
+  options: TabOption[];
   currentTab: string;
   setCurrentTab: (x: string) => void;
   defaultTab?: string;
   addendum?: any;
   color?: string | null;
+  suppressAnimation?: boolean;
 };
 
 type StateType = {};
@@ -33,49 +39,29 @@ export default class TabRegion extends Component<PropsType, StateType> {
     }
   }
 
-  renderContents = () => {
-    if (!this.props.currentTab) {
-      return <Loading />;
-    }
-
+  render() {
     return (
-      <Div>
-        <TabSelector
-          options={this.props.options}
-          color={this.props.color}
-          currentTab={this.props.currentTab}
-          setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
-          addendum={this.props.addendum}
-        />
-        <Gap />
-        <TabContents>{this.props.children}</TabContents>
-      </Div>
+      <StyledTabRegion suppressAnimation={this.props.suppressAnimation}>
+        {!this.props.currentTab ? (
+          <Loading />
+        ) : (
+          <>
+            <TabSelector
+              options={this.props.options}
+              color={this.props.color}
+              currentTab={this.props.currentTab}
+              setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
+              addendum={this.props.addendum}
+            />
+            <Gap />
+            <TabContents>{this.props.children}</TabContents>
+          </>
+        )}
+      </StyledTabRegion>
     );
-  };
-
-  render() {
-    return <StyledTabRegion>{this.renderContents()}</StyledTabRegion>;
   }
 }
 
-const Placeholder = styled.div`
-  width: 100%;
-  height: 200px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #ffffff11;
-  border-radius: 5px;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
-const Div = styled.div`
-  width: 100%;
-  height: 100%;
-  animation: fadeIn 0.25s 0s;
-`;
-
 const TabContents = styled.div`
   height: calc(100% - 65px);
 `;
@@ -86,9 +72,10 @@ const Gap = styled.div`
   height: 30px;
 `;
 
-const StyledTabRegion = styled.div`
+const StyledTabRegion = styled.div<{ suppressAnimation: boolean }>`
   width: 100%;
   height: 100%;
+  animation: ${(props) => (props.suppressAnimation ? "" : "fadeIn 0.25s 0s")};
   position: relative;
   overflow-y: auto;
   overflow: visible;

+ 1 - 1
dashboard/src/components/Table.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import styled from "styled-components";
 import { Column, Row, useGlobalFilter, useTable } from "react-table";
-import InputRow from "./values-form/InputRow";
+import InputRow from "./form-components/InputRow";
 import Loading from "components/Loading";
 
 const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {

+ 0 - 0
dashboard/src/components/values-form/CheckboxList.tsx → dashboard/src/components/form-components/CheckboxList.tsx


+ 0 - 0
dashboard/src/components/values-form/CheckboxRow.tsx → dashboard/src/components/form-components/CheckboxRow.tsx


+ 0 - 0
dashboard/src/components/values-form/Heading.tsx → dashboard/src/components/form-components/Heading.tsx


+ 0 - 0
dashboard/src/components/values-form/Helper.tsx → dashboard/src/components/form-components/Helper.tsx


+ 0 - 0
dashboard/src/components/values-form/InputRow.tsx → dashboard/src/components/form-components/InputRow.tsx


+ 0 - 0
dashboard/src/components/values-form/KeyValueArray.tsx → dashboard/src/components/form-components/KeyValueArray.tsx


+ 0 - 0
dashboard/src/components/values-form/SelectRow.tsx → dashboard/src/components/form-components/SelectRow.tsx


+ 0 - 0
dashboard/src/components/values-form/TextArea.tsx → dashboard/src/components/form-components/TextArea.tsx


+ 0 - 0
dashboard/src/components/values-form/UploadArea.tsx → dashboard/src/components/form-components/UploadArea.tsx


+ 537 - 0
dashboard/src/components/porter-form/FormDebugger.tsx

@@ -0,0 +1,537 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import AceEditor from "react-ace";
+import PorterFormWrapper from "./PorterFormWrapper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import InputRow from "components/form-components/InputRow";
+import yaml from "js-yaml";
+
+import "shared/ace-porter-theme";
+import "ace-builds/src-noconflict/mode-text";
+
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+
+type PropsType = {
+  goBack: () => void;
+};
+
+type StateType = {
+  rawYaml: string;
+  showBonusTabs: boolean;
+  showStateDebugger: boolean;
+  valuesToOverride: any;
+  checkbox_a: boolean;
+  input_a: string;
+  isReadOnly: boolean;
+};
+
+const tabOptions = [
+  { value: "a", label: "Bonus Tab A" },
+  { value: "b", label: "Bonus Tab B" },
+];
+
+export default class FormDebugger extends Component<PropsType, StateType> {
+  state = {
+    rawYaml: initYaml,
+    showBonusTabs: false,
+    showStateDebugger: true,
+    valuesToOverride: {
+      checkbox_a: {
+        value: true,
+      },
+    } as any,
+    checkbox_a: true,
+    input_a: "",
+    isReadOnly: false,
+  };
+
+  renderTabContents = (currentTab: string) => {
+    return (
+      <TabWrapper>
+        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
+      </TabWrapper>
+    );
+  };
+
+  aceEditorRef = React.createRef<AceEditor>();
+  render() {
+    let formData = {};
+    try {
+      formData = yaml.load(this.state.rawYaml);
+    } catch (err: any) {
+      console.log("YAML parsing error.");
+    }
+    return (
+      <StyledFormDebugger>
+        <Button onClick={this.props.goBack}>
+          <i className="material-icons">keyboard_backspace</i>
+          Back
+        </Button>
+        <Heading isAtTop={true}>✨ Form.yaml Editor</Heading>
+        <Helper>Write and test form.yaml free of consequence.</Helper>
+
+        <EditorWrapper>
+          <AceEditor
+            ref={this.aceEditorRef}
+            mode="yaml"
+            value={this.state.rawYaml}
+            theme="porter"
+            onChange={(e: string) => this.setState({ rawYaml: e })}
+            name="codeEditor"
+            editorProps={{ $blockScrolling: true }}
+            height="450px"
+            width="100%"
+            style={{
+              borderRadius: "5px",
+              border: "1px solid #ffffff22",
+              marginTop: "27px",
+              marginBottom: "27px",
+            }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+          />
+        </EditorWrapper>
+
+        <CheckboxRow
+          label="Show form state debugger"
+          checked={this.state.showStateDebugger}
+          toggle={() =>
+            this.setState({ showStateDebugger: !this.state.showStateDebugger })
+          }
+        />
+        <CheckboxRow
+          label="Read-only"
+          checked={this.state.isReadOnly}
+          toggle={() =>
+            this.setState({
+              isReadOnly: !this.state.isReadOnly,
+            })
+          }
+        />
+        <CheckboxRow
+          label="Include non-form dummy tabs"
+          checked={this.state.showBonusTabs}
+          toggle={() =>
+            this.setState({ showBonusTabs: !this.state.showBonusTabs })
+          }
+        />
+        <CheckboxRow
+          label="checkbox_a"
+          checked={this.state.checkbox_a}
+          toggle={() =>
+            this.setState({
+              checkbox_a: !this.state.checkbox_a,
+
+              // Override the form value for checkbox_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                checkbox_a: {
+                  value: !this.state.checkbox_a,
+                },
+              },
+            })
+          }
+        />
+        <InputRow
+          type="string"
+          value={this.state.input_a}
+          setValue={(x: string) =>
+            this.setState({
+              input_a: x,
+
+              // Override the form value for input_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                input_a: {
+                  value: x,
+                },
+              },
+            })
+          }
+          label={"input_a"}
+          placeholder="ex: override text"
+        />
+
+        <Heading>🎨 Rendered Form</Heading>
+        <Br />
+        <PorterFormWrapper
+          showStateDebugger={this.state.showStateDebugger}
+          formData={formData}
+          valuesToOverride={{
+            input_a: this.state.valuesToOverride?.input_a?.value,
+          }}
+          isReadOnly={this.state.isReadOnly}
+          onSubmit={(vars) => {
+            alert("check console output");
+            console.log(vars);
+          }}
+          rightTabOptions={this.state.showBonusTabs ? tabOptions : []}
+          renderTabContents={this.renderTabContents}
+          saveButtonText={"Test Submit"}
+        />
+      </StyledFormDebugger>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 12px;
+`;
+
+const TabWrapper = styled.div`
+  background: #ffffff11;
+  height: 200px;
+  width: 100%;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  overflow: auto;
+  padding: 50px;
+`;
+
+const EditorWrapper = styled.div`
+  .ace_editor,
+  .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const StyledFormDebugger = styled.div`
+  position: relative;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  width: 85px;
+  float: right;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const initYaml = `name: Web
+hasSource: true
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Main
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: Container Settings
+    - type: variable
+      variable: showStartCommand
+      settings:
+        default: true
+  - name: command
+    show_if: showStartCommand
+    contents:
+    - type: subtitle
+      name: command_description
+      label: (Optional) Set a start command for this service.
+    - type: string-input
+      label: Start Command
+      placeholder: "ex: sh ./script.sh"
+      variable: container.command
+  - name: section_one_cont
+    contents:
+    - type: subtitle
+      label: Specify the port your application is running on.
+    - type: number-input
+      variable: container.port
+      label: Container Port
+      placeholder: "ex: 80"
+      settings:
+        default: 80
+    - type: heading
+      label: Deploy Webhook
+    - type: checkbox
+      variable: auto_deploy
+      label: Auto-deploy when webhook is called.
+      settings:
+        default: true
+  - name: network
+    contents:
+    - type: heading
+      label: Network Settings
+    - type: subtitle
+      label: For containers that you do not want to expose to external traffic (e.g. databases and add-ons), you may make them accessible only to other internal services running within the same cluster. 
+    - type: checkbox
+      variable: ingress.enabled
+      label: Expose to external traffic
+      settings:
+        default: true
+  - name: domain_toggle
+    show_if: ingress.enabled
+    contents:
+    - type: subtitle
+      label: Assign custom domain to your deployment. You must first create an A record in your domain provider that points to your cluster load balancer's IP address for this.
+    - type: checkbox
+      variable: ingress.custom_domain
+      label: Configure Custom Domain
+      settings:
+        default: false 
+  - name: domain_name
+    show_if: ingress.custom_domain
+    contents:
+    - type: array-input
+      variable: ingress.hosts
+      label: Domain Name
+  - name: do_wildcard
+    show_if: 
+      and:
+      - ingress.custom_domain
+      - currentCluster.service.is_do
+    contents:
+    - type: subtitle
+      label: If you're hosting on Digital Ocean and have enabled the wildcard domains from the 'HTTPS Issuer', you can use a wildcard certificate.
+    - type: checkbox
+      variable: ingress.wildcard
+      label: Use Wildcard Certificate
+- name: resources
+  label: Resources
+  sections:
+  - name: main_section
+    contents:
+    - type: heading
+      label: Resources
+    - type: subtitle
+      label: Configure resources assigned to this container.
+    - type: number-input
+      label: RAM
+      variable: resources.requests.memory
+      placeholder: "ex: 256"
+      settings:
+        unit: Mi
+        default: 256
+    - type: number-input
+      label: CPU
+      variable: resources.requests.cpu
+      placeholder: "ex: 100"
+      settings:
+        unit: m
+        default: 100
+    - type: number-input
+      label: Replicas
+      variable: replicaCount
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: checkbox
+      variable: autoscaling.enabled
+      label: Enable autoscaling
+      settings:
+        default: false
+  - name: autoscaler
+    show_if: autoscaling.enabled
+    contents:
+    - type: number-input
+      label: Minimum Replicas
+      variable: autoscaling.minReplicas
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: number-input
+      label: Maximum Replicas
+      variable: autoscaling.maxReplicas
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+    - type: number-input
+      label: Target CPU Utilization
+      variable: autoscaling.targetCPUUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+    - type: number-input
+      label: Target RAM Utilization
+      variable: autoscaling.targetMemoryUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+- name: env
+  label: Environment
+  sections:
+  - name: env_vars
+    contents:
+    - type: heading
+      label: Environment Variables
+    - type: subtitle
+      label: Set environment variables for your secrets and environment-specific configuration.
+    - type: env-key-value-array
+      label: 
+      variable: container.env.normal
+- name: advanced
+  label: Advanced
+  sections:
+  - name: ingress_annotations
+    contents:
+    - type: heading
+      label: Ingress Custom Annotations
+    - type: subtitle
+      label: Assign custom annotations to Ingress. These annotations will overwrite the annotations Porter assigns by default.
+    - type: key-value-array
+      variable: ingress.annotations
+      settings:
+        default: {}
+  - name: health_check
+    contents:
+    - type: heading
+      label: Custom Health Checks
+    - type: subtitle
+      label: Define custom health check endpoints to ensure zero down-time deployments.
+    - type: checkbox
+      variable: health.enabled
+      label: Enable Custom Health Checks
+      settings:
+        default: false
+  - name: health_check_endpoint
+    show_if: health.enabled
+    contents:
+    - type: string-input
+      label: Health Check Endpoint
+      variable: health.path
+      placeholder: "ex: /healthz"
+      settings:
+        default: /healthz
+    - type: heading
+      label: Custom Health Check Rules
+    - type: subtitle
+      label: Configure how many times a health check will be performed before deeming the container as failed. 
+    - type: number-input
+      label: Failure Threshold
+      variable: health.failureThreshold
+      placeholder: "ex: 3"
+    - type: subtitle
+      label: Configure the interval at which health check is repeated in the case of failure.
+    - type: number-input
+      label: Repeat Interval
+      variable: health.periodSeconds
+      placeholder: "ex: 30"
+  - name: persistence_toggle
+    contents:
+    - type: heading
+      label: Persistent Disks
+    - type: subtitle
+      label: Attach persistent disks to your deployment to retain data across releases.
+    - type: checkbox
+      label: Enable Persistence
+      variable: pvc.enabled
+  - name: persistent_storage
+    show_if: pvc.enabled
+    contents:
+    - type: number-input
+      label: Persistent Storage
+      variable: pvc.storage
+      placeholder: "ex: 20"
+      settings:
+        unit: Gi
+        default: 20
+    - type: string-input
+      label: Mount Path
+      variable: pvc.mountPath
+      placeholder: "ex: /mypath"
+      settings:
+        default: /mypath
+  - name: termination_grace_period
+    contents:
+    - type: heading
+      label: Termination Grace Period
+    - type: subtitle
+      label: Specify how much time app processes have to gracefully shut down on SIGTERM.
+    - type: number-input
+      label: Termination Grace Period (seconds)
+      variable: terminationGracePeriodSeconds
+      placeholder: "ex: 30"
+      settings:
+        default: 30
+  - name: container_hooks
+    contents:
+    - type: heading
+      label: Container hooks
+    - type: subtitle
+      label: (Optional) Set post start or pre stop commands for this service.
+    - type: string-input
+      label: Post start command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.postStart
+    - type: string-input
+      label: Pre stop command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.preStop
+  - name: cloud_sql_toggle
+    show_if: currentCluster.service.is_gcp
+    contents:
+    - type: heading
+      label: Google Cloud SQL
+    - type: subtitle
+      label: Securely connect to Google Cloud SQL (GKE only).
+    - type: checkbox
+      variable: cloudsql.enabled
+      label: Enable Google Cloud SQL Proxy
+      settings:
+        default: false
+  - name: cloud_sql_contents
+    show_if: cloudsql.enabled
+    contents:
+    - type: string-input
+      label: Instance Connection Name
+      variable: cloudsql.connectionName
+      placeholder: "ex: project-123:us-east1:pachyderm"
+    - type: number-input
+      label: DB Port
+      variable: cloudsql.dbPort
+      placeholder: "ex: 5432"
+      settings:
+        default: 5432
+    - type: string-input
+      label: Service Account JSON
+      variable: cloudsql.serviceAccountJSON
+      placeholder: "ex: { <SERVICE_ACCOUNT_JSON> }"`;

+ 229 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -0,0 +1,229 @@
+import React, { useContext, useState } from "react";
+import {
+  Section,
+  FormField,
+  InputField,
+  CheckboxField,
+  KeyValueArrayField,
+  ArrayInputField,
+  SelectField,
+  ServiceIPListField,
+  ResourceListField,
+} from "./types";
+import TabRegion, { TabOption } from "../TabRegion";
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+import Input from "./field-components/Input";
+import { PorterFormContext } from "./PorterFormContextProvider";
+import Checkbox from "./field-components/Checkbox";
+import KeyValueArray from "./field-components/KeyValueArray";
+import styled from "styled-components";
+import SaveButton from "../SaveButton";
+import ArrayInput from "./field-components/ArrayInput";
+import Select from "./field-components/Select";
+import ServiceIPList from "./field-components/ServiceIPList";
+import ResourceList from "./field-components/ResourceList";
+import VeleroForm from "./field-components/VeleroForm";
+
+interface Props {
+  leftTabOptions?: TabOption[];
+  rightTabOptions?: TabOption[];
+  renderTabContents?: (
+    currentTab: string,
+    submitValues?: any
+  ) => React.ReactElement;
+  saveButtonText?: string;
+  isReadOnly?: boolean;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+  currentTab: string;
+  setCurrentTab: (nt: string) => void;
+}
+
+const PorterForm: React.FC<Props> = (props) => {
+  const {
+    formData,
+    isReadOnly,
+    validationInfo,
+    onSubmit,
+    formState,
+  } = useContext(PorterFormContext);
+
+  const { currentTab, setCurrentTab } = props;
+
+  const renderSectionField = (field: FormField): JSX.Element => {
+    const bundledProps = {
+      ...field,
+      isReadOnly,
+    };
+    switch (field.type) {
+      case "heading":
+        return <Heading>{field.label}</Heading>;
+      case "subtitle":
+        return <Helper>{field.label}</Helper>;
+      case "input":
+        return <Input {...(bundledProps as InputField)} />;
+      case "checkbox":
+        return <Checkbox {...(bundledProps as CheckboxField)} />;
+      case "key-value-array":
+        return <KeyValueArray {...(bundledProps as KeyValueArrayField)} />;
+      case "array-input":
+        return <ArrayInput {...(bundledProps as ArrayInputField)} />;
+      case "select":
+        return <Select {...(bundledProps as SelectField)} />;
+      case "service-ip-list":
+        return <ServiceIPList {...(bundledProps as ServiceIPListField)} />;
+      case "resource-list":
+        return <ResourceList {...(bundledProps as ResourceListField)} />;
+      case "velero-create-backup":
+        return <VeleroForm />;
+    }
+    return <p>Not Implemented: {(field as any).type}</p>;
+  };
+
+  const renderSection = (section: Section): JSX.Element => {
+    return (
+      <>
+        {section.contents?.map((field, i) => {
+          return (
+            <React.Fragment key={field.id}>
+              {renderSectionField(field)}
+            </React.Fragment>
+          );
+        })}
+      </>
+    );
+  };
+
+  const getTabOptions = (): TabOption[] => {
+    let options = (props.leftTabOptions || [])
+      .concat(
+        formData?.tabs?.map((tab) => {
+          return { label: tab.label, value: tab.name };
+        })
+      )
+      .concat(props.rightTabOptions || []);
+    return options.filter((x) => x !== undefined);
+  };
+
+  const showSaveButton = (): boolean => {
+    if (props.isReadOnly) {
+      return false;
+    }
+
+    let returnVal = true;
+    props.leftTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+    props.rightTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+
+    return returnVal;
+  };
+
+  const renderTab = (): JSX.Element => {
+    if (!formData) {
+      return props.renderTabContents(currentTab);
+    }
+
+    const tab = formData.tabs?.filter((tab) => tab.name == currentTab)[0];
+
+    // Handle external tab
+    if (!tab) {
+      return props.renderTabContents ? (
+        props.renderTabContents(currentTab)
+      ) : (
+        <></>
+      );
+    }
+
+    return (
+      <StyledPorterForm showSave={showSaveButton()}>
+        {tab.sections?.map((section) => {
+          return (
+            <React.Fragment key={section.name}>
+              {renderSection(section)}
+            </React.Fragment>
+          );
+        })}
+      </StyledPorterForm>
+    );
+  };
+
+  const isDisabled = () => {
+    if (props.saveValuesStatus == "loading") {
+      return true;
+    }
+
+    return isReadOnly || !validationInfo.validated;
+  };
+
+  const renderSaveStatus = (): string => {
+    if (isDisabled() && props.saveValuesStatus !== "loading") {
+      return "Missing required fields";
+    }
+    return props.saveValuesStatus;
+  };
+
+  return (
+    <>
+      <TabRegion
+        addendum={props.addendum}
+        color={props.color}
+        options={getTabOptions()}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+        suppressAnimation={true}
+      >
+        {renderTab()}
+      </TabRegion>
+      <br />
+      {showSaveButton() && (
+        <SaveButton
+          text={props.saveButtonText || "Deploy"}
+          onClick={onSubmit}
+          makeFlush={!props.isInModal}
+          status={
+            validationInfo.validated ? renderSaveStatus() : validationInfo.error
+          }
+          disabled={isDisabled()}
+        />
+      )}
+      {props.showStateDebugger && (
+        <Pre>{JSON.stringify(formState, undefined, 2)}</Pre>
+      )}
+      <Spacer />
+    </>
+  );
+};
+
+export default PorterForm;
+
+const Pre = styled.pre`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const Spacer = styled.div`
+  height: 50px;
+`;
+
+const StyledPorterForm = styled.div<{ showSave?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 0px 35px 25px;
+  position: relative;
+  border-radius: 5px;
+  font-size: 13px;
+  overflow: auto;
+`;

+ 410 - 0
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -0,0 +1,410 @@
+import React, { createContext, useContext, useReducer } from "react";
+import {
+  PorterFormData,
+  PorterFormState,
+  PorterFormAction,
+  PorterFormVariableList,
+  PorterFormValidationInfo,
+  GetFinalVariablesFunction,
+} from "./types";
+import { ShowIf, ShowIfAnd, ShowIfNot, ShowIfOr } from "../../shared/types";
+import { getFinalVariablesForStringInput } from "./field-components/Input";
+import { getFinalVariablesForKeyValueArray } from "./field-components/KeyValueArray";
+import { Context } from "../../shared/Context";
+import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
+import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
+import { getFinalVariablesForSelect } from "./field-components/Select";
+
+interface Props {
+  rawFormData: PorterFormData;
+  onSubmit: (vars: PorterFormVariableList) => void;
+  initialVariables?: PorterFormVariableList;
+  overrideVariables?: PorterFormVariableList;
+  isReadOnly?: boolean;
+  doDebug?: boolean;
+}
+
+interface ContextProps {
+  formData: PorterFormData;
+  formState: PorterFormState;
+  onSubmit: () => void;
+  dispatchAction: (event: PorterFormAction) => void;
+  validationInfo: PorterFormValidationInfo;
+  isReadOnly?: boolean;
+}
+
+export const PorterFormContext = createContext<ContextProps | undefined>(
+  undefined!
+);
+const { Provider } = PorterFormContext;
+
+export const PorterFormContextProvider: React.FC<Props> = (props) => {
+  const context = useContext(Context);
+
+  const handleAction = (
+    state: PorterFormState,
+    action: PorterFormAction
+  ): PorterFormState => {
+    switch (action.type) {
+      case "init-field":
+        if (!(action.id in state.components)) {
+          return {
+            ...state,
+            variables: {
+              ...state.variables,
+              ...action.initVars,
+            },
+            components: {
+              ...state.components,
+              [action.id]: {
+                state: action.initValue,
+                validation: {
+                  ...{
+                    validated: false,
+                  },
+                  ...action.initValidation,
+                },
+              },
+            },
+          };
+        }
+        break;
+      case "update-field":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...props.overrideVariables,
+          },
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+              state: {
+                ...state.components[action.id].state,
+                ...action.updateFunc(state.components[action.id].state),
+              },
+            },
+          },
+        };
+      case "update-validation":
+        return {
+          ...state,
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+              validation: action.updateFunc(
+                state.components[action.id].validation
+              ),
+            },
+          },
+        };
+      case "mutate-vars":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...action.mutateFunc(state.variables),
+            ...props.overrideVariables,
+          },
+        };
+    }
+    return state;
+  };
+
+  // get variables initiated by variable field
+  const getInitialVariables = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (field.type == "variable") {
+            ret[field.variable] = field.settings?.default;
+          }
+        })
+      )
+    );
+    return ret;
+  };
+
+  const [state, dispatch] = useReducer(handleAction, {
+    components: {},
+    variables: {
+      ...props.initialVariables,
+      ...getInitialVariables(props.rawFormData),
+      ...props.overrideVariables,
+    },
+  });
+
+  const evalShowIf = (
+    vals: ShowIf,
+    variables: PorterFormVariableList
+  ): boolean => {
+    if (!vals) {
+      return false;
+    }
+    if (typeof vals == "string") {
+      return !!variables[vals];
+    }
+    if ((vals as ShowIfOr).or) {
+      vals = vals as ShowIfOr;
+      for (let i = 0; i < vals.or?.length; i++) {
+        if (evalShowIf(vals.or[i], variables)) {
+          return true;
+        }
+      }
+      return false;
+    }
+    if ((vals as ShowIfAnd).and) {
+      vals = vals as ShowIfAnd;
+      for (let i = 0; i < vals.and?.length; i++) {
+        if (!evalShowIf(vals.and[i], variables)) {
+          return false;
+        }
+      }
+      return true;
+    }
+    if ((vals as ShowIfNot).not) {
+      vals = vals as ShowIfNot;
+      return !evalShowIf(vals.not, variables);
+    }
+
+    return false;
+  };
+
+  /*
+    Takes in old form data and changes it to use newer fields
+    For example, number-input becomes input with a setting that makes it
+    a number input
+   */
+  const restructureToNewFields = (data: PorterFormData) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab) => {
+        return {
+          ...tab,
+          sections: tab.sections?.map((section) => {
+            return {
+              ...section,
+              contents: section.contents
+                ?.map((field: any) => {
+                  if (field.type == "number-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "number",
+                      },
+                    };
+                  }
+                  if (field.type == "string-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "string",
+                      },
+                    };
+                  }
+                  if (field.type == "string-input-password") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "password",
+                      },
+                    };
+                  }
+                  if (field.type == "provider-select") {
+                    return {
+                      ...field,
+                      type: "select",
+                      settings: {
+                        ...field.settings,
+                        type: "provider",
+                      },
+                    };
+                  }
+                  if (field.type == "env-key-value-array") {
+                    return {
+                      ...field,
+                      type: "key-value-array",
+                      secretOption: true,
+                      envLoader: true,
+                      fileUpload: true,
+                      settings: {
+                        type: "env",
+                      },
+                    };
+                  }
+                  if (field.type == "variable") return null;
+                  return field;
+                })
+                .filter((x) => x != null),
+            };
+          }),
+        };
+      }),
+    };
+  };
+
+  /*
+  We don't want to have the actual <PorterForm> component to do as little form
+  logic as possible, so this structures the form object based on show_if statements
+  and assigns a unique id to each field
+
+  This computed structure also later lets us figure out which fields should be required
+  */
+  const computeFormStructure = (
+    data: PorterFormData,
+    variables: PorterFormVariableList
+  ) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab, i) => {
+        return {
+          ...tab,
+          sections: tab.sections
+            ?.map((section, j) => {
+              return {
+                ...section,
+                contents: section.contents?.map((field, k) => {
+                  return {
+                    ...field,
+                    id: `${i}-${j}-${k}`,
+                  };
+                }),
+              };
+            })
+            .filter((section) => {
+              return !section.show_if || evalShowIf(section.show_if, variables);
+            }),
+        };
+      }),
+    };
+  };
+
+  /*
+    compute a list of field ids who's input is required and a map from a variable value
+    to a list of fields that set it
+  */
+  const computeRequiredVariables = (
+    data: PorterFormData
+  ): [string[], Record<string, string[]>] => {
+    const requiredIds: string[] = [];
+    const mapping: Record<string, string[]> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (
+            field.type == "heading" ||
+            field.type == "subtitle" ||
+            field.type == "resource-list" ||
+            field.type == "service-ip-list" ||
+            field.type == "velero-create-backup"
+          )
+            return;
+          // fields that have defaults can't be required since we can always
+          // compute their value
+          if (field.required && !field.settings?.default) {
+            requiredIds.push(field.id);
+          }
+          if (!mapping[field.variable]) {
+            mapping[field.variable] = [];
+          }
+          mapping[field.variable].push(field.id);
+        })
+      )
+    );
+    return [requiredIds, mapping];
+  };
+
+  /*
+    Validate the form based on a list of required ids
+   */
+  const doValidation = (requiredIds: string[]) =>
+    requiredIds
+      ?.map((id) => state.components[id]?.validation.validated)
+      .every((x) => x);
+
+  const formData = computeFormStructure(
+    restructureToNewFields(props.rawFormData),
+    state.variables
+  );
+  const [requiredIds, varMapping] = computeRequiredVariables(formData);
+  const isValidated = doValidation(requiredIds);
+
+  /*
+  Handle submit
+  This involves going through all the (currently active) fields in the form and
+  using functions for each input to finalize the variables
+  This can take care of things like appending units to strings
+ */
+  const onSubmitWrapper = () => {
+    // we start off with a base list of the current variables for fields
+    // that don't need any processing on top (for example: checkbox)
+    // the assign here is important because that way state.variable isn't mutated
+    const varList: PorterFormVariableList[] = [
+      Object.assign({}, state.variables),
+    ];
+    const finalFunctions: Record<string, GetFinalVariablesFunction> = {
+      input: getFinalVariablesForStringInput,
+      "array-input": getFinalVariablesForArrayInput,
+      checkbox: getFinalVariablesForCheckbox,
+      "key-value-array": getFinalVariablesForKeyValueArray,
+      select: getFinalVariablesForSelect,
+    };
+
+    const data = props.rawFormData.includeHiddenFields
+      ? restructureToNewFields(props.rawFormData)
+      : formData;
+
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (finalFunctions[field.type])
+            varList.push(
+              finalFunctions[field.type](
+                state.variables,
+                field,
+                state.components[field.id]?.state,
+                context
+              )
+            );
+        })
+      )
+    );
+    if (props.doDebug) console.log(Object.assign.apply({}, varList));
+    props.onSubmit(Object.assign.apply({}, varList));
+  };
+
+  if (props.doDebug) {
+    console.group("Validation Info:");
+    console.log(requiredIds);
+    console.log(varMapping);
+    console.log(isValidated);
+    console.groupEnd();
+  }
+
+  return (
+    <Provider
+      value={{
+        formData: formData,
+        formState: state,
+        dispatchAction: dispatch,
+        isReadOnly: props.isReadOnly,
+        validationInfo: {
+          validated: isValidated,
+          error: isValidated ? null : "Missing required fields",
+        },
+        onSubmit: onSubmitWrapper,
+      }}
+    >
+      {props.children}
+    </Provider>
+  );
+};

+ 86 - 0
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -0,0 +1,86 @@
+import React, { useState } from "react";
+
+import PorterForm from "./PorterForm";
+import { PorterFormData } from "./types";
+import { PorterFormContextProvider } from "./PorterFormContextProvider";
+
+type PropsType = {
+  formData: any;
+  valuesToOverride?: any;
+  isReadOnly?: boolean;
+  onSubmit?: (values: any) => void;
+  renderTabContents?: (currentTab: string, submitValues?: any) => any;
+  leftTabOptions?: { value: string; label: string }[];
+  rightTabOptions?: { value: string; label: string }[];
+  saveButtonText?: string;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+};
+
+const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
+  formData,
+  valuesToOverride,
+  isReadOnly,
+  onSubmit,
+  renderTabContents,
+  leftTabOptions,
+  rightTabOptions,
+  saveButtonText,
+  isInModal,
+  color,
+  addendum,
+  saveValuesStatus,
+  showStateDebugger,
+}) => {
+  const hashCode = (s: string) => {
+    return s.split("").reduce(function (a, b) {
+      a = (a << 5) - a + b.charCodeAt(0);
+      return a & a;
+    }, 0);
+  };
+
+  const getInitialTab = (): string => {
+    if (leftTabOptions?.length > 0) {
+      return leftTabOptions[0].value;
+    } else if (formData?.tabs?.length > 0) {
+      return formData?.tabs[0].name;
+    } else if (rightTabOptions?.length > 0) {
+      return rightTabOptions[0].value;
+    } else {
+      return "";
+    }
+  };
+
+  const [currentTab, setCurrentTab] = useState(getInitialTab());
+
+  return (
+    <React.Fragment key={hashCode(JSON.stringify(formData))}>
+      <PorterFormContextProvider
+        rawFormData={formData as PorterFormData}
+        overrideVariables={valuesToOverride}
+        isReadOnly={isReadOnly}
+        onSubmit={onSubmit}
+      >
+        <PorterForm
+          showStateDebugger={showStateDebugger}
+          addendum={addendum}
+          isReadOnly={isReadOnly}
+          leftTabOptions={leftTabOptions}
+          rightTabOptions={rightTabOptions}
+          renderTabContents={renderTabContents}
+          saveButtonText={saveButtonText}
+          isInModal={isInModal}
+          color={color}
+          saveValuesStatus={saveValuesStatus}
+          currentTab={currentTab}
+          setCurrentTab={setCurrentTab}
+        />
+      </PorterFormContextProvider>
+    </React.Fragment>
+  );
+};
+
+export default PorterFormWrapper;

+ 74 - 53
dashboard/src/components/values-form/InputArray.tsx → dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -1,33 +1,36 @@
-import React, { Component } from "react";
+import React from "react";
 import styled from "styled-components";
-
-type PropsType = {
-  label?: string;
-  values: string[];
-  setValues: (x: string[]) => void;
-  width?: string;
-  disabled?: boolean;
-};
-
-type StateType = {};
-
-export default class InputArray extends Component<PropsType, StateType> {
-  dict2arr = (dict: Record<string, any>) => {
-    let arr = [];
-    for (let key in dict) {
-      arr.push(`${key}: ${dict[key]}`);
+import {
+  ArrayInputField,
+  ArrayInputFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
+import useFormField from "../hooks/useFormField";
+
+const ArrayInput: React.FC<ArrayInputField> = (props) => {
+  const { state, variables, setVars } = useFormField<ArrayInputFieldState>(
+    props.id,
+    {
+      initVars: {
+        [props.variable]: props.value ? props.value[0] : [],
+      },
     }
-    return arr;
-  };
+  );
+
+  if (state == undefined) return <></>;
 
-  renderDeleteButton = (values: string[], i: number) => {
-    if (!this.props.disabled) {
+  const renderDeleteButton = (values: string[], i: number) => {
+    if (!props.isReadOnly) {
       return (
         <DeleteButton
           onClick={() => {
-            let v = [...values];
-            v.splice(i, 1);
-            this.props.setValues(v);
+            setVars((prev) => {
+              return {
+                [props.variable]: prev[props.variable]
+                  .slice(0, i)
+                  .concat(prev[props.variable].slice(i + 1)),
+              };
+            });
           }}
         >
           <i className="material-icons">cancel</i>
@@ -36,7 +39,7 @@ export default class InputArray extends Component<PropsType, StateType> {
     }
   };
 
-  renderInputList = (values: string[]) => {
+  const renderInputList = (values: string[]) => {
     return (
       <>
         {values.map((value: string, i: number) => {
@@ -47,13 +50,20 @@ export default class InputArray extends Component<PropsType, StateType> {
                 width="270px"
                 value={value}
                 onChange={(e: any) => {
-                  let v = [...values];
-                  v[i] = e.target.value;
-                  this.props.setValues(v);
+                  e.persist();
+                  setVars((prev) => {
+                    return {
+                      [props.variable]: prev[props.variable].map(
+                        (t: string, j: number) => {
+                          return i == j ? e.target.value : t;
+                        }
+                      ),
+                    };
+                  });
                 }}
-                disabled={this.props.disabled}
+                disabled={props.isReadOnly}
               />
-              {this.renderDeleteButton(values, i)}
+              {renderDeleteButton(values, i)}
             </InputWrapper>
           );
         })}
@@ -61,30 +71,41 @@ export default class InputArray extends Component<PropsType, StateType> {
     );
   };
 
-  render() {
-    let { values } = this.props;
-
-    if (!Array.isArray(values)) {
-      values = this.dict2arr(values);
-    }
+  return (
+    <StyledInputArray>
+      <Label>{props.label}</Label>
+      {variables[props.variable] === 0 ? (
+        <></>
+      ) : (
+        renderInputList(variables[props.variable])
+      )}
+      <AddRowButton
+        onClick={() => {
+          setVars((prev) => {
+            return {
+              [props.variable]: [...prev[props.variable], ""],
+            };
+          });
+        }}
+      >
+        <i className="material-icons">add</i> Add Row
+      </AddRowButton>
+    </StyledInputArray>
+  );
+};
 
-    return (
-      <StyledInputArray>
-        <Label>{this.props.label}</Label>
-        {values.length === 0 ? <></> : this.renderInputList(values)}
-        <AddRowButton
-          onClick={() => {
-            let v = [...values];
-            v.push("");
-            this.props.setValues(v);
-          }}
-        >
-          <i className="material-icons">add</i> Add Row
-        </AddRowButton>
-      </StyledInputArray>
-    );
-  }
-}
+export default ArrayInput;
+
+export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
+  vars,
+  props: ArrayInputField
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: [],
+      };
+};
 
 const AddRowButton = styled.div`
   display: flex;

+ 68 - 0
dashboard/src/components/porter-form/field-components/Checkbox.tsx

@@ -0,0 +1,68 @@
+import React from "react";
+import {
+  ArrayInputField,
+  CheckboxField,
+  CheckboxFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
+import CheckboxRow from "../../form-components/CheckboxRow";
+import useFormField from "../hooks/useFormField";
+
+interface Props extends CheckboxField {
+  id: string;
+}
+
+const Checkbox: React.FC<Props> = ({
+  id,
+  label,
+  required,
+  variable,
+  isReadOnly,
+  settings,
+  value,
+}) => {
+  const { state, variables, setVars } = useFormField<CheckboxFieldState>(id, {
+    initState: {},
+    initValidation: {
+      validated: !required,
+    },
+    initVars: {
+      [variable]: value ? value[0] : !!settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  return (
+    <CheckboxRow
+      isRequired={required}
+      checked={variables[variable]}
+      toggle={() => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: !vars[variable],
+          };
+        });
+      }}
+      label={label}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export default Checkbox;
+
+export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
+  vars,
+  props: CheckboxField
+) => {
+  if (vars[props.variable] === false) {
+    return { [props.variable]: false };
+  } else if (vars[props.variable] === true) {
+    return { [props.variable]: true };
+  }
+  return { [props.variable]: !!props.settings?.default };
+};

+ 94 - 0
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -0,0 +1,94 @@
+import React from "react";
+import InputRow from "../../form-components/InputRow";
+import useFormField from "../hooks/useFormField";
+import {
+  GenericInputField,
+  GetFinalVariablesFunction,
+  InputField,
+  StringInputFieldState,
+} from "../types";
+
+const Input: React.FC<InputField> = ({
+  id,
+  variable,
+  label,
+  required,
+  placeholder,
+  info,
+  settings,
+  isReadOnly,
+  value,
+}) => {
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<StringInputFieldState>(id, {
+    initValidation: {
+      validated: value
+        ? value[0] !== undefined
+        : settings?.default != undefined,
+    },
+    initVars: {
+      [variable]: value ? value[0] : settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  const curValue =
+    settings?.type == "number"
+      ? !isNaN(parseFloat(variables[variable]))
+        ? parseFloat(variables[variable])
+        : ""
+      : variables[variable] || "";
+
+  return (
+    <InputRow
+      width="100%"
+      type={settings?.type || "text"}
+      value={curValue}
+      unit={settings?.unit}
+      setValue={(x: string | number) => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: x,
+          };
+        });
+        setValidation((prev) => {
+          return {
+            ...prev,
+            validated:
+              settings?.type == "number"
+                ? !isNaN(x as number)
+                : !!(x as string).trim(),
+          };
+        });
+      }}
+      label={label}
+      isRequired={required}
+      placeholder={placeholder}
+      info={info}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
+  vars,
+  props: InputField
+) => {
+  const val = vars[props.variable] || props.settings?.default;
+  return {
+    [props.variable]:
+      props.settings?.unit && props.settings?.omitUnitFromValue === false
+        ? val + props.settings.unit
+        : val,
+  };
+};
+
+export default Input;

+ 510 - 0
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -0,0 +1,510 @@
+import React from "react";
+import {
+  GetFinalVariablesFunction,
+  InputField,
+  KeyValueArrayField,
+  KeyValueArrayFieldState,
+} from "../types";
+import sliders from "../../../assets/sliders.svg";
+import upload from "../../../assets/upload.svg";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import Modal from "../../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
+import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
+
+interface Props extends KeyValueArrayField {
+  id: string;
+}
+
+const KeyValueArray: React.FC<Props> = (props) => {
+  const { state, setState, variables } = useFormField<KeyValueArrayFieldState>(
+    props.id,
+    {
+      initState: {
+        values:
+          props.value && props.value[0]
+            ? (Object.entries(props.value[0]).map(([k, v]) => {
+                return { key: k, value: v };
+              }) as any[])
+            : [],
+        showEnvModal: false,
+        showEditorModal: false,
+      },
+    }
+  );
+
+  if (state == undefined) return <></>;
+
+  const parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  const readFile = (env: string) => {
+    let envObj = parseEnv(env, null);
+    let push = true;
+
+    for (let key in envObj) {
+      for (var i = 0; i < state.values.length; i++) {
+        let existingKey = state.values[i]["key"];
+        if (key === existingKey) {
+          state.values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        setState((prev) => {
+          return {
+            values: [...prev.values, { key, value: envObj[key] }],
+          };
+        });
+      }
+    }
+  };
+
+  const renderEditorModal = () => {
+    if (state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEditorModal: false };
+            })
+          }
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() =>
+              setState(() => {
+                return { showEditorModal: false };
+              })
+            }
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const renderEnvModal = () => {
+    if (state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEnvModal: false };
+            })
+          }
+          width="765px"
+          height="542px"
+        >
+          <LoadEnvGroupModal
+            existingValues={variables}
+            namespace={variables.namespace}
+            clusterId={variables.clusterId}
+            closeModal={() =>
+              setState(() => {
+                return {
+                  showEnvModal: false,
+                };
+              })
+            }
+            setValues={(values) => {
+              setState((prev) => {
+                return {
+                  // might be broken
+                  values: {
+                    ...prev.values,
+                    ...Object.entries(values).map(([k, v]) => {
+                      return {
+                        key: k,
+                        value: v,
+                      };
+                    }),
+                  },
+                };
+              });
+            }}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const renderDeleteButton = (i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            state.values.splice(i, 1);
+            setState((prev) => {
+              return {
+                values: prev.values
+                  .slice(0, i + 1)
+                  .concat(prev.values.slice(i + 1, prev.values.length)),
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderHiddenOption = (hidden: boolean, i: number) => {
+    if (props.secretOption && hidden) {
+      return (
+        <HideButton>
+          <i className="material-icons">lock</i>
+        </HideButton>
+      );
+    }
+  };
+
+  const renderInputList = () => {
+    return (
+      <>
+        {state.values.map((entry: any, i: number) => {
+          // Preprocess non-string env values set via raw Helm values
+          let { value } = entry;
+          if (typeof value === "object") {
+            value = JSON.stringify(value);
+          } else if (typeof value === "number" || typeof value === "boolean") {
+            value = value.toString();
+          }
+
+          return (
+            <InputWrapper key={i}>
+              <Input
+                placeholder="ex: key"
+                width="270px"
+                value={entry.key}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            key: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                spellCheck={false}
+              />
+              <Spacer />
+              <Input
+                placeholder="ex: value"
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            value: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                spellCheck={false}
+              />
+              {renderDeleteButton(i)}
+              {renderHiddenOption(value.includes("PORTERSECRET"), i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{props.label}</Label>
+        {state.values.length === 0 ? <></> : renderInputList()}
+        {props.isReadOnly ? (
+          <></>
+        ) : (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                setState((prev) => {
+                  return {
+                    values: [...prev.values, { key: "", value: "" }],
+                  };
+                });
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer />
+            {variables.namespace && props.envLoader && (
+              <LoadButton
+                onClick={() =>
+                  setState((prev) => {
+                    return {
+                      showEnvModal: !prev.showEnvModal,
+                    };
+                  })
+                }
+              >
+                <img src={sliders} /> Load from Env Group
+              </LoadButton>
+            )}
+            {props.fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setState((prev) => {
+                    return {
+                      showEditorModal: true,
+                    };
+                  });
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
+            )}
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {renderEnvModal()}
+      {renderEditorModal()}
+    </>
+  );
+};
+
+export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
+  vars,
+  props: KeyValueArrayField,
+  state: KeyValueArrayFieldState
+) => {
+  if (!state)
+    return {
+      [props.variable]: {},
+    };
+
+  let obj = {} as any;
+  const rg = /(?:^|[^\\])(\\n)/g;
+  const fixNewlines = (s: string) => {
+    while (rg.test(s)) {
+      s = s.replace(rg, (str) => {
+        if (str.length == 2) return "\n";
+        if (str[0] != "\\") return str[0] + "\n";
+        return "\\n";
+      });
+    }
+    return s;
+  };
+  const isNumber = (s: string) => {
+    return !isNaN(!s ? NaN : Number(String(s).trim()));
+  };
+  state.values.forEach((entry: any, i: number) => {
+    if (isNumber(entry.value)) {
+      obj[entry.key] = entry.value;
+    } else {
+      obj[entry.key] = fixNewlines(entry.value);
+    }
+  });
+  return {
+    [props.variable]: obj,
+  };
+};
+
+export default KeyValueArray;
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  margin-left: 10px;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: default;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 0 - 0
dashboard/src/components/values-form/MultiSelect.tsx → dashboard/src/components/porter-form/field-components/MultiSelect.tsx


+ 32 - 0
dashboard/src/components/porter-form/field-components/ResourceList.tsx

@@ -0,0 +1,32 @@
+import React from "react";
+import { ResourceListField } from "../types";
+import ExpandableResource from "../../ExpandableResource";
+import styled from "styled-components";
+
+const ResourceList: React.FC<ResourceListField> = (props) => {
+  return (
+    <ResourceListWrapper>
+      {props.value?.map((resource: any, i: number) => {
+        if (resource.data) {
+          return (
+            <ExpandableResource
+              key={i}
+              resource={resource}
+              isLast={i === props.value.length - 1}
+              roundAllCorners={true}
+            />
+          );
+        }
+      })}
+    </ResourceListWrapper>
+  );
+};
+
+export default ResourceList;
+
+const ResourceListWrapper = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 5px;
+  overflow: hidden;
+`;

+ 99 - 0
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -0,0 +1,99 @@
+import React, { useContext } from "react";
+import {
+  CheckboxField,
+  GetFinalVariablesFunction,
+  SelectField,
+  SelectFieldState,
+} from "../types";
+import Selector from "../../Selector";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import { Context } from "../../../shared/Context";
+
+const Select: React.FC<SelectField> = (props) => {
+  const { currentCluster } = useContext(Context);
+  const { variables, setVars } = useFormField<SelectFieldState>(props.id, {
+    initVars: {
+      [props.variable]: props.value
+        ? props.value[0]
+        : props.settings.default
+        ? props.settings.default
+        : props.settings.type == "provider"
+        ? ({
+            gke: "gcp",
+            eks: "aws",
+            doks: "do",
+          } as Record<string, string>)[currentCluster.service] || "aws"
+        : props.settings.options[0].value,
+    },
+  });
+
+  const providerOptions = [
+    { value: "aws", label: "Amazon Web Services (AWS)" },
+    { value: "gcp", label: "Google Cloud Platform (GCP)" },
+    { value: "do", label: "DigitalOcean" },
+  ];
+
+  return (
+    <StyledSelectRow>
+      <Label>{props.label}</Label>
+      <SelectWrapper>
+        <Selector
+          activeValue={variables[props.variable]}
+          setActiveValue={(val) => {
+            setVars(() => {
+              return {
+                [props.variable]: val,
+              };
+            });
+          }}
+          options={
+            props.settings.type == "provider"
+              ? providerOptions
+              : props.settings.options
+          }
+          dropdownLabel={props.dropdownLabel}
+          width={props.width || "270px"}
+          dropdownWidth={props.width}
+          dropdownMaxHeight={props.dropdownMaxHeight}
+        />
+      </SelectWrapper>
+    </StyledSelectRow>
+  );
+};
+
+export default Select;
+
+export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
+  vars,
+  props: SelectField,
+  state,
+  context
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: props.settings.default
+          ? props.settings.default
+          : props.settings.type == "provider"
+          ? ({
+              gke: "gcp",
+              eks: "aws",
+              doks: "do",
+            } as Record<string, string>)[context.currentCluster?.service] ||
+            "aws"
+          : props.settings.options[0].value,
+      };
+};
+
+const SelectWrapper = styled.div``;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledSelectRow = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 23 - 0
dashboard/src/components/porter-form/field-components/ServiceIPList.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { ServiceIPListField } from "../types";
+import ServiceRow from "./ServiceRow";
+import styled from "styled-components";
+
+const ServiceIPList: React.FC<ServiceIPListField> = (props) => {
+  return (
+    <ResourceList>
+      {props.value?.map((service: any, i: number) => {
+        return <ServiceRow service={service} key={i} />;
+      })}
+    </ResourceList>
+  );
+};
+
+export default ServiceIPList;
+
+const ResourceList = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 5px;
+  overflow: hidden;
+`;

+ 0 - 0
dashboard/src/components/values-form/ServiceRow.tsx → dashboard/src/components/porter-form/field-components/ServiceRow.tsx


+ 3 - 3
dashboard/src/components/forms/VeleroForm.tsx → dashboard/src/components/porter-form/field-components/VeleroForm.tsx

@@ -1,8 +1,8 @@
 import React, { Component } from "react";
 
-import Heading from "../values-form/Heading";
-import InputRow from "../values-form/InputRow";
-import MultiSelect from "../values-form/MultiSelect";
+import Heading from "../../form-components/Heading";
+import InputRow from "../../form-components/InputRow";
+import MultiSelect from "./MultiSelect";
 
 type PropsType = {};
 

+ 85 - 0
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -0,0 +1,85 @@
+import { useContext, useEffect } from "react";
+import { PorterFormContext } from "../PorterFormContextProvider";
+import {
+  PorterFormFieldFieldState,
+  PorterFormFieldValidationState,
+  PorterFormVariableList,
+} from "../types";
+
+interface FormFieldData<T> {
+  state: T;
+  variables: PorterFormVariableList;
+  setState: (setFunc: (prev: T) => Partial<T>) => void;
+  setVars: (
+    setFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => void;
+  setValidation: (
+    setFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => void;
+}
+
+interface Options<T> {
+  initState?: T;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+const useFormField = <T extends PorterFormFieldFieldState>(
+  fieldId: string,
+  { initState, initVars, initValidation }: Options<T>
+): FormFieldData<T> => {
+  const { dispatchAction, formState } = useContext(PorterFormContext);
+
+  useEffect(() => {
+    dispatchAction({
+      type: "init-field",
+      id: fieldId,
+      initValue: initState || {},
+      initValidation: initValidation || {
+        validated: false,
+      },
+      initVars: initVars || {},
+    });
+  }, []);
+
+  const setState = (updateFunc: (prev: T) => Partial<T>) => {
+    dispatchAction({
+      type: "update-field",
+      id: fieldId,
+      updateFunc,
+    });
+  };
+
+  const setVars = (
+    mutateFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => {
+    dispatchAction({
+      type: "mutate-vars",
+      mutateFunc,
+    });
+  };
+
+  const setValidation = (
+    updateFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => {
+    dispatchAction({
+      id: fieldId,
+      type: "update-validation",
+      updateFunc,
+    });
+  };
+
+  return {
+    state: formState.components[fieldId]?.state as T,
+    variables: formState.variables,
+    setState,
+    setVars,
+    setValidation,
+  };
+};
+
+export default useFormField;

+ 243 - 0
dashboard/src/components/porter-form/types.ts

@@ -0,0 +1,243 @@
+/*
+  Interfaces for the form YAML
+  Will be merged with shared types later
+*/
+
+// YAML Field interfaces
+
+import { ContextProps } from "../../shared/types";
+
+export interface GenericField {
+  id: string;
+}
+
+export interface GenericInputField extends GenericField {
+  isReadOnly?: boolean;
+  required?: boolean;
+  variable: string;
+  settings?: any;
+
+  // Read in value from Helm for existing revisions
+  value?: any[];
+}
+
+export interface HeadingField extends GenericField {
+  type: "heading";
+  label: string;
+}
+
+export interface SubtitleField extends GenericField {
+  type: "subtitle";
+  label: string;
+}
+
+export interface ServiceIPListField extends GenericField {
+  type: "service-ip-list";
+  value: any[];
+}
+
+export interface ResourceListField extends GenericField {
+  type: "resource-list";
+  value: any[];
+}
+
+export interface VeleroBackupField extends GenericField {
+  type: "velero-create-backup";
+}
+
+export interface InputField extends GenericInputField {
+  type: "input";
+  label?: string;
+  placeholder?: string;
+  info?: string;
+  settings?: {
+    type?: "text" | "password" | "number";
+    unit?: string;
+    omitUnitFromValue?: boolean;
+    default: string | number;
+  };
+}
+
+export interface CheckboxField extends GenericInputField {
+  type: "checkbox";
+  label?: string;
+  settings?: {
+    default: boolean;
+  };
+}
+
+export interface KeyValueArrayField extends GenericInputField {
+  type: "key-value-array";
+  label?: string;
+  secretOption?: boolean;
+  envLoader?: boolean;
+  fileUpload?: boolean;
+  settings?: {
+    type: "env" | "normal";
+  };
+}
+
+export interface ArrayInputField extends GenericInputField {
+  type: "array-input";
+  label?: string;
+}
+
+export interface SelectField extends GenericInputField {
+  type: "select";
+  settings:
+    | {
+        type: "normal";
+        options: { value: string; label: string }[];
+        default?: string;
+      }
+    | {
+        type: "provider";
+        default?: string;
+      };
+  width: string;
+  label?: string;
+  dropdownLabel?: string;
+  dropdownWidth?: number;
+  dropdownMaxHeight?: string;
+}
+
+export interface VariableField extends GenericInputField {
+  type: "variable";
+  settings?: {
+    default: any;
+  };
+}
+
+export type FormField =
+  | HeadingField
+  | SubtitleField
+  | InputField
+  | CheckboxField
+  | KeyValueArrayField
+  | ArrayInputField
+  | SelectField
+  | ServiceIPListField
+  | ResourceListField
+  | VeleroBackupField
+  | VariableField;
+
+export interface ShowIfAnd {
+  and: ShowIf[];
+}
+
+export interface ShowIfOr {
+  or: ShowIf[];
+}
+
+export interface ShowIfNot {
+  not: ShowIf;
+}
+
+export type ShowIf = string | ShowIfAnd | ShowIfOr | ShowIfNot;
+
+export interface Section {
+  name: string;
+  show_if?: ShowIf;
+  contents: FormField[];
+}
+
+export interface Tab {
+  name: string;
+  label: string;
+  sections: Section[];
+}
+
+export interface PorterFormData {
+  name: string;
+  hasSource: boolean;
+  includeHiddenFields: boolean;
+  tabs: Tab[];
+}
+
+export interface PorterFormValidationInfo {
+  validated: boolean;
+  error?: string;
+}
+
+// internal field state interfaces
+export interface StringInputFieldState {}
+export interface CheckboxFieldState {}
+export interface KeyValueArrayFieldState {
+  values: {
+    key: string;
+    value: string;
+  }[];
+  showEnvModal: boolean;
+  showEditorModal: boolean;
+}
+export interface ArrayInputFieldState {}
+export interface SelectFieldState {}
+
+export type PorterFormFieldFieldState =
+  | StringInputFieldState
+  | CheckboxFieldState
+  | KeyValueArrayField
+  | ArrayInputFieldState
+  | SelectFieldState;
+
+// reducer interfaces
+
+export interface PorterFormFieldValidationState {
+  validated: boolean;
+}
+
+export interface PorterFormVariableList {
+  [key: string]: any;
+}
+
+export interface PorterFormState {
+  components: {
+    [key: string]: {
+      state: PorterFormFieldFieldState;
+      validation: PorterFormFieldValidationState;
+    };
+  };
+  variables: PorterFormVariableList;
+}
+
+export interface PorterFormInitFieldAction {
+  type: "init-field";
+  id: string;
+  initValue: PorterFormFieldFieldState;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+export interface PorterFormUpdateFieldAction {
+  type: "update-field";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldFieldState
+  ) => Partial<PorterFormFieldFieldState>;
+}
+
+export interface PorterFormUpdateValidationAction {
+  type: "update-validation";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldValidationState
+  ) => PorterFormFieldValidationState;
+}
+
+export interface PorterFormMutateVariablesAction {
+  type: "mutate-vars";
+  mutateFunc: (prev: PorterFormVariableList) => PorterFormVariableList;
+}
+
+export type PorterFormAction =
+  | PorterFormInitFieldAction
+  | PorterFormUpdateFieldAction
+  | PorterFormMutateVariablesAction
+  | PorterFormUpdateValidationAction;
+
+export type GetFinalVariablesFunction = (
+  vars: PorterFormVariableList,
+  props: FormField,
+  state: PorterFormFieldFieldState,
+  context: Partial<ContextProps>
+) => PorterFormVariableList;

+ 1 - 1
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -7,7 +7,7 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
-import InputRow from "../values-form/InputRow";
+import InputRow from "../form-components/InputRow";
 import InfoTooltip from "components/InfoTooltip";
 
 type PropsType = {

+ 1 - 1
dashboard/src/components/repo-selector/NewGHAction.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { ChartType } from "shared/types";
 import { Context } from "shared/Context";
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 
 import Loading from "../Loading";
 

+ 1 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -8,7 +8,7 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 import SearchBar from "../SearchBar";
-import Helper from "../values-form/Helper";
+import Helper from "../form-components/Helper";
 
 interface GithubAppAccessData {
   has_access: boolean;

+ 0 - 99
dashboard/src/components/values-form/Base64InputRow.tsx

@@ -1,99 +0,0 @@
-import React, { ChangeEvent, Component } from "react";
-import styled from "styled-components";
-
-type PropsType = {
-  label?: string;
-  type: string;
-  value: string | number;
-  setValue: (x: string | number) => void;
-  unit?: string;
-  placeholder?: string;
-  width?: string;
-  disabled?: boolean;
-  isRequired?: boolean;
-};
-
-type StateType = {
-  readOnly: boolean;
-};
-
-export default class InputRow extends Component<PropsType, StateType> {
-  state = {
-    readOnly: true,
-  };
-
-  handleChange = (e: ChangeEvent<HTMLInputElement>) => {
-    this.props.setValue(e.target.value);
-  };
-
-  render() {
-    let { label, value, type, unit, placeholder, width } = this.props;
-    value = value.toString();
-    value = atob(value);
-    return (
-      <StyledInputRow>
-        <Label>
-          {label} <Required>{this.props.isRequired ? " *" : null}</Required>
-        </Label>
-        <InputWrapper>
-          <Input
-            readOnly={this.state.readOnly}
-            onFocus={() => this.setState({ readOnly: false })}
-            disabled={this.props.disabled}
-            placeholder={placeholder}
-            width={width}
-            type={type}
-            value={value}
-            onChange={this.handleChange}
-          />
-          {unit ? <Unit>{unit}</Unit> : null}
-        </InputWrapper>
-      </StyledInputRow>
-    );
-  }
-}
-
-const Required = styled.div`
-  margin-left: 8px;
-  color: #fc4976;
-`;
-
-const Unit = styled.div`
-  margin-right: 8px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  margin-bottom: -1px;
-  align-items: center;
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { disabled: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
-  padding: 5px 10px;
-  margin-right: 8px;
-  height: 30px;
-`;
-
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-`;
-
-const StyledInputRow = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;

+ 0 - 323
dashboard/src/components/values-form/FormDebugger.tsx

@@ -1,323 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import AceEditor from "react-ace";
-import FormWrapper from "components/values-form/FormWrapper";
-import CheckboxRow from "components/values-form/CheckboxRow";
-import InputRow from "components/values-form/InputRow";
-import yaml from "js-yaml";
-
-import "shared/ace-porter-theme";
-import "ace-builds/src-noconflict/mode-text";
-
-import Heading from "./Heading";
-import Helper from "./Helper";
-
-type PropsType = {
-  goBack: () => void;
-};
-
-type StateType = {
-  rawYaml: string;
-  showBonusTabs: boolean;
-  showStateDebugger: boolean;
-  valuesToOverride: any;
-  checkbox_a: boolean;
-  input_a: string;
-  isReadOnly: boolean;
-};
-
-const tabOptions = [
-  { value: "a", label: "Bonus Tab A" },
-  { value: "b", label: "Bonus Tab B" },
-];
-
-export default class FormDebugger extends Component<PropsType, StateType> {
-  state = {
-    rawYaml: initYaml,
-    showBonusTabs: false,
-    showStateDebugger: true,
-    valuesToOverride: {
-      checkbox_a: {
-        value: true,
-      },
-    } as any,
-    checkbox_a: true,
-    input_a: "",
-    isReadOnly: false,
-  };
-
-  renderTabContents = (currentTab: string) => {
-    return (
-      <TabWrapper>
-        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
-      </TabWrapper>
-    );
-  };
-
-  aceEditorRef = React.createRef<AceEditor>();
-  render() {
-    let formData = {};
-    try {
-      formData = yaml.load(this.state.rawYaml);
-    } catch (err: any) {
-      console.log("YAML parsing error.");
-    }
-    return (
-      <StyledFormDebugger>
-        <Button onClick={this.props.goBack}>
-          <i className="material-icons">keyboard_backspace</i>
-          Back
-        </Button>
-        <Heading>✨ Form.yaml Editor</Heading>
-        <Helper>Write and test form.yaml free of consequence.</Helper>
-
-        <EditorWrapper>
-          <AceEditor
-            ref={this.aceEditorRef}
-            mode="yaml"
-            value={this.state.rawYaml}
-            theme="porter"
-            onChange={(e: string) => this.setState({ rawYaml: e })}
-            name="codeEditor"
-            editorProps={{ $blockScrolling: true }}
-            height="450px"
-            width="100%"
-            style={{
-              borderRadius: "5px",
-              border: "1px solid #ffffff22",
-              marginTop: "27px",
-              marginBottom: "27px",
-            }}
-            showPrintMargin={false}
-            showGutter={true}
-            highlightActiveLine={true}
-          />
-        </EditorWrapper>
-
-        <CheckboxRow
-          label="Show form state debugger"
-          checked={this.state.showStateDebugger}
-          toggle={() =>
-            this.setState({ showStateDebugger: !this.state.showStateDebugger })
-          }
-        />
-        <CheckboxRow
-          label="Read-only"
-          checked={this.state.isReadOnly}
-          toggle={() =>
-            this.setState({
-              isReadOnly: !this.state.isReadOnly,
-            })
-          }
-        />
-        <CheckboxRow
-          label="Include non-form dummy tabs"
-          checked={this.state.showBonusTabs}
-          toggle={() =>
-            this.setState({ showBonusTabs: !this.state.showBonusTabs })
-          }
-        />
-        <CheckboxRow
-          label="checkbox_a"
-          checked={this.state.checkbox_a}
-          toggle={() =>
-            this.setState({
-              checkbox_a: !this.state.checkbox_a,
-
-              // Override the form value for checkbox_a
-              valuesToOverride: {
-                ...this.state.valuesToOverride,
-                checkbox_a: {
-                  value: !this.state.checkbox_a,
-                },
-              },
-            })
-          }
-        />
-        <InputRow
-          type="string"
-          value={this.state.input_a}
-          setValue={(x: string) =>
-            this.setState({
-              input_a: x,
-
-              // Override the form value for input_a
-              valuesToOverride: {
-                ...this.state.valuesToOverride,
-                input_a: {
-                  value: x,
-                },
-              },
-            })
-          }
-          label={"input_a"}
-          placeholder="ex: override text"
-        />
-
-        <Heading>🎨 Rendered Form</Heading>
-        <Br />
-        <FormWrapper
-          valuesToOverride={this.state.valuesToOverride}
-          clearValuesToOverride={() =>
-            this.setState({ valuesToOverride: null })
-          }
-          showStateDebugger={this.state.showStateDebugger}
-          formData={formData}
-          isReadOnly={this.state.isReadOnly}
-          tabOptions={this.state.showBonusTabs ? tabOptions : []}
-          renderTabContents={
-            this.state.showBonusTabs ? this.renderTabContents : null
-          }
-          onSubmit={(values: any) => {
-            alert("Check console output.");
-            console.log("Raw submission values:");
-            console.log(values);
-          }}
-        />
-      </StyledFormDebugger>
-    );
-  }
-}
-
-const Br = styled.div`
-  width: 100%;
-  height: 12px;
-`;
-
-const TabWrapper = styled.div`
-  background: #ffffff11;
-  height: 200px;
-  width: 100%;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  overflow: auto;
-  padding: 50px;
-`;
-
-const EditorWrapper = styled.div`
-  .ace_editor,
-  .ace_editor * {
-    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
-      monospace !important;
-    font-size: 12px !important;
-    font-weight: 400 !important;
-    letter-spacing: 0 !important;
-  }
-`;
-
-const StyledFormDebugger = styled.div`
-  position: relative;
-`;
-
-const Button = styled.div`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  margin-left: -2px;
-  padding: 0px 8px;
-  width: 85px;
-  float: right;
-  padding-bottom: 1px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: pointer;
-  border: 2px solid #969fbbaa;
-  :hover {
-    background: #ffffff11;
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    color: #969fbbaa;
-    font-weight: 600;
-    font-size: 14px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const initYaml = `name: Porter Example
-hasSource: true
-tabs:
-- name: main
-  label: Main
-  sections:
-  - name: header
-    contents: 
-    - type: heading
-      label: 🍺 Porter Demo Form
-    - type: subtitle
-      name: command_description
-      label: Basic form demonstrating some of the features of form.yaml
-    - type: string-input
-      placeholder: "ex: pilsner"
-      label: Required Field A
-      required: true
-      variable: field_a
-      info: This is some info
-    - type: string-input
-      placeholder: "ex: sapporo"
-      required: true
-      label: Required Field B
-      variable: field_b
-    - type: subtitle
-      label: "Note: Hidden required fields aren't supported yet (global only)"
-  - name: controlled-by-external
-    show_if:
-      or:
-        - checkbox_a
-        - not_a_variable
-    contents:
-    - type: heading
-      label: Conditional Display (A)
-    - type: subtitle
-      label: This section can be externally controlled by the value of checkbox_a
-    - type: string-input
-      variable: input_a
-      placeholder: "Override w/ input_a"
-  - name: domain_name
-    show_if: ingress.custom_domain
-    contents:
-    - type: array-input
-      variable: ingress.hosts
-      label: Domain Name
-- name: env
-  label: Environment
-  sections:
-  - name: env_vars
-    contents:
-    - type: heading
-      label: Environment Variables
-    - type: subtitle
-      label: Set environment variables for your secrets and environment-specific configuration.
-    - type: env-key-value-array
-      label: 
-      variable: container.env.normal
-- name: advanced
-  label: Advanced
-  sections:
-  - name: advanced
-    contents:
-    - type: heading
-      label: Some Header
-    - type: subtitle
-      label: Some helper text
-`;

+ 0 - 519
dashboard/src/components/values-form/FormWrapper.tsx

@@ -1,519 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import _ from "lodash";
-
-import { Section, FormElement } from "shared/types";
-import { Context } from "shared/Context";
-import TabRegion from "components/TabRegion";
-import ValuesForm from "components/values-form/ValuesForm";
-import SaveButton from "../SaveButton";
-
-type PropsType = {
-  formData: any;
-  onSubmit?: (formValues: any) => void;
-  saveValuesStatus?: string | null;
-  saveButtonText?: string | null;
-
-  // Handle additional non-form tabs
-  // TODO: find cleaner way to share submitValues w/ rerun jobs button
-  renderTabContents?: (currentTab: string, submitValues?: any) => any;
-  tabOptions?: any[];
-  tabOptionsOnly?: boolean;
-
-  // Allow external control of state
-  valuesToOverride?: any;
-  clearValuesToOverride?: () => void;
-
-  // External values made available to all child components
-  externalValues?: any;
-
-  // Display and debugger settings
-  isInModal?: boolean;
-  isReadOnly?: boolean;
-  showStateDebugger?: boolean;
-
-  // TabRegion props to pass through
-  color?: string;
-  addendum?: any;
-};
-
-type StateType = {
-  metaState: any;
-  requiredFields: string[];
-  currentTab: string;
-  tabOptions: { value: string; label: string }[];
-};
-
-/**
- * Renders from raw JSON form data and manages form state.
- *
- * To control values using external state prop in "valuesToOverride" (refer to
- * FormDebugger or LaunchTemplate for example usage).
- *
- * TODO: Handle passing in valuesToOverride at same time as formData
- */
-export default class FormWrapper extends Component<PropsType, StateType> {
-  state = {
-    metaState: {} as any,
-    requiredFields: [] as string[],
-    currentTab: "",
-    tabOptions: [] as { value: string; label: string }[],
-  };
-
-  updateTabs = (resetState?: boolean, callback?: any) => {
-    if (resetState) {
-      let tabOptions = [] as { value: string; label: string }[];
-      let tabs = this.props.formData?.tabs;
-      let requiredFields = [] as string[];
-      let metaState: any = {
-        "currentCluster.service.is_gcp": {
-          value: this.context.currentCluster.service == "gke",
-        },
-        "currentCluster.service.is_aws": {
-          value: this.context.currentCluster.service == "eks",
-        },
-        "currentCluster.service.is_do": {
-          value: this.context.currentCluster.service == "doks",
-        },
-      };
-      tabs?.forEach((tab: any, i: number) => {
-        // Exclude value if omitFromLaunch is set
-        let omit =
-          tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
-        if (tab?.name && tab.label && !omit) {
-          // If a tab is valid, extract state
-          tab.sections?.forEach((section: Section, i: number) => {
-            section?.contents?.forEach((item: FormElement, i: number) => {
-              if (item === null || item === undefined) {
-                return;
-              }
-
-              if (
-                item.type === "variable" &&
-                item.variable &&
-                item.settings?.default
-              ) {
-                metaState[item.variable] = { value: item.settings.default };
-                return;
-              }
-
-              // If no name is assigned use values.yaml variable as identifier
-              let key = item.name || item.variable;
-
-              let def =
-                item.settings &&
-                item.settings.unit &&
-                !item.settings.omitUnitFromValue
-                  ? `${item.settings.default}${item.settings.unit}`
-                  : item.settings?.default;
-              def = (item.value && item.value[0]) || def;
-
-              if (item.type === "checkbox") {
-                def = item.value && item.value[0];
-              }
-
-              // Handle add to list of required fields
-              if (item.required && key) {
-                requiredFields.push(key);
-              }
-
-              let value: any = def;
-              switch (item.type) {
-                case "checkbox":
-                  value = def || false;
-                  break;
-                case "string-input":
-                  value = def || "";
-                  break;
-                case "string-input-password":
-                  value = def || item.settings.default;
-                case "array-input":
-                  value = def || [];
-                  break;
-                case "env-key-value-array":
-                  value = def || {};
-                  break;
-                case "key-value-array":
-                  value = def || {};
-                  break;
-                case "number-input":
-                  value = def?.toString() ? def : "";
-                  break;
-                case "select":
-                  value = def || item.settings.options[0].value;
-                  break;
-                case "provider-select":
-                  let providerMap: any = {
-                    gke: "gcp",
-                    eks: "aws",
-                    doks: "do",
-                  };
-                  def = providerMap[this.context.currentCluster.service];
-                  value = def || "aws";
-                  break;
-                case "base-64":
-                  value = def || "";
-                case "base-64-password":
-                  value = def || "";
-                default:
-              }
-              if (value !== null && value !== undefined) {
-                metaState[key] = { value };
-              }
-            });
-          });
-          if (!this.props.tabOptionsOnly) {
-            tabOptions.push({ value: tab.name, label: tab.label });
-          }
-        }
-      });
-
-      if (this.props.tabOptions?.length > 0) {
-        let prependTabs = [] as { value: string; label: string }[];
-        let appendTabs = [] as { value: string; label: string }[];
-        this.props.tabOptions.forEach(
-          (tab: { value: string; label: string }) => {
-            if (tab.value === "status" || tab.value === "metrics") {
-              prependTabs.push(tab);
-            } else {
-              appendTabs.push(tab);
-            }
-          }
-        );
-        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
-      }
-
-      if (tabOptions.length > 0) {
-        this.setState(
-          {
-            tabOptions: tabOptions,
-            currentTab:
-              this.state.currentTab === ""
-                ? tabOptions[0].value
-                : this.state.currentTab,
-            metaState,
-            requiredFields: requiredFields,
-          },
-          callback
-        );
-      } else {
-        this.setState({ tabOptions }, callback);
-      }
-    } else {
-      // TODO: refactor by consolidating w/ above
-      // Handle change only to external tabs (e.g. DevOps mode toggle)
-      let tabOptions = [] as { value: string; label: string }[];
-      let tabs = this.props.formData?.tabs;
-      tabs?.forEach((tab: any, i: number) => {
-        if (tab?.name && tab.label) {
-          tabOptions.push({ value: tab.name, label: tab.label });
-        }
-      });
-
-      if (this.props.tabOptions?.length > 0) {
-        let prependTabs = [] as { value: string; label: string }[];
-        let appendTabs = [] as { value: string; label: string }[];
-        this.props.tabOptions.forEach(
-          (tab: { value: string; label: string }) => {
-            if (tab.value === "status" || tab.value === "metrics") {
-              prependTabs.push(tab);
-            } else {
-              appendTabs.push(tab);
-            }
-          }
-        );
-        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
-      }
-      this.setState({ tabOptions }, callback);
-    }
-  };
-
-  componentDidMount() {
-    this.updateTabs(true, () => {
-      this.setState(
-        {
-          metaState: {
-            ...this.state.metaState,
-            ...this.props.valuesToOverride,
-          },
-        },
-        () => {
-          this.props.clearValuesToOverride &&
-            this.props.clearValuesToOverride();
-        }
-      );
-    });
-  }
-
-  componentDidUpdate(prevProps: any) {
-    // Override metaState values set from outside FormWrapper
-    if (
-      this.props.valuesToOverride &&
-      !_.isEmpty(this.props.valuesToOverride) &&
-      !_.isEqual(prevProps.valuesToOverride, this.props.valuesToOverride)
-    ) {
-      this.setState(
-        {
-          metaState: {
-            ...this.state.metaState,
-            ...this.props.valuesToOverride,
-          },
-        },
-        () => {
-          // Seems redundant with below but need to ensure no leaked state updates
-          if (
-            !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
-            !_.isEqual(prevProps.formData, this.props.formData)
-          ) {
-            let formHasChanged = !_.isEqual(
-              prevProps.formData,
-              this.props.formData
-            );
-            this.updateTabs(formHasChanged);
-          }
-          this.props.clearValuesToOverride &&
-            this.props.clearValuesToOverride();
-        }
-      );
-    } else if (
-      !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
-      !_.isEqual(prevProps.formData, this.props.formData)
-    ) {
-      if (
-        prevProps.tabOptions?.length === 0 &&
-        !_.isEqual(prevProps.tabOptions, this.props.tabOptions)
-      ) {
-        this.setState({ currentTab: "status" });
-      }
-      let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
-      this.updateTabs(formHasChanged);
-    }
-  }
-
-  isSet = (value: any) => {
-    if (
-      value === null ||
-      value === undefined ||
-      value === "" ||
-      value === false
-    ) {
-      return false;
-    }
-    return true;
-  };
-
-  isDisabled = () => {
-    if (this.props.saveValuesStatus == "loading") {
-      return true;
-    }
-
-    let requiredMissing = false;
-    this.state.requiredFields?.forEach((requiredKey: string, i: number) => {
-      if (!this.isSet(this.state.metaState[requiredKey]?.value)) {
-        requiredMissing = true;
-      }
-    });
-    return requiredMissing;
-  };
-
-  renderTabContents = () => {
-    let tabs = this.props.formData?.tabs;
-    if (tabs) {
-      let matchedTab = null as any;
-      tabs.forEach((tab: any, i: number) => {
-        if (tab?.name === this.state.currentTab) {
-          matchedTab = tab;
-        }
-      });
-      if (matchedTab) {
-        return (
-          <ValuesForm
-            externalValues={this.props.externalValues}
-            disabled={this.props.isReadOnly}
-            metaState={this.state.metaState}
-            setMetaState={(key: string, value: any) => {
-              let metaState: any = this.state.metaState;
-              metaState[key] = { value };
-              this.setState({ metaState });
-            }}
-            sections={matchedTab.sections}
-          />
-        );
-      }
-    }
-
-    // If no form tabs match, check against external tabs
-    if (this.props.renderTabContents) {
-      // TODO: find a cleaner way to share submissionValues w/ rerun button
-      let submissionValues: any = {};
-      Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
-        submissionValues[key] = this.state.metaState[key]?.value;
-      });
-
-      return this.props.renderTabContents(
-        this.state.currentTab,
-        submissionValues
-      );
-    }
-    return <div>No matched tabs found.</div>;
-  };
-
-  renderStateDebugger = () => {
-    if (this.props.showStateDebugger) {
-      return (
-        <>
-          <StateDisplay>
-            <Header>FormWrapper State</Header>
-            <ScrollWrapper>
-              {JSON.stringify(this.state.metaState, undefined, 2)}
-            </ScrollWrapper>
-          </StateDisplay>
-        </>
-      );
-    }
-  };
-
-  handleSubmit = () => {
-    // Extract metaState values
-    let submissionValues: any = {};
-    Object.keys(this.state.metaState)?.forEach((key: string, i: number) => {
-      submissionValues[key] = this.state.metaState[key]?.value;
-    });
-
-    this.props.onSubmit && this.props.onSubmit(submissionValues);
-  };
-
-  showSaveButton = (): boolean => {
-    if (this.props.isReadOnly || this.state.tabOptions?.length === 0) {
-      return false;
-    }
-
-    let tabs = this.props.formData?.tabs;
-    if (tabs) {
-      let matchedTab = null as any;
-      tabs.forEach((tab: any, i: number) => {
-        if (tab?.name === this.state.currentTab) {
-          matchedTab = tab;
-        }
-      });
-      if (matchedTab) {
-        return true;
-      }
-    }
-
-    // Check if current tab is among non-form tab options
-    let nonFormTabValues = this.props.tabOptions?.map((tab: any, i: number) => {
-      return tab.value;
-    });
-    if (nonFormTabValues && nonFormTabValues.includes(this.state.currentTab)) {
-      return false;
-    }
-    return true;
-  };
-
-  renderContents = (showSave: boolean) => {
-    return (
-      <>
-        <TabRegion
-          options={this.state.tabOptions}
-          currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-          addendum={this.props.addendum}
-          color={this.props.color}
-        >
-          {this.renderTabContents()}
-        </TabRegion>
-        {showSave && (
-          <SaveButton
-            disabled={this.isDisabled()}
-            text={this.props.saveButtonText || "Deploy"}
-            onClick={this.handleSubmit}
-            status={
-              this.isDisabled() && this.props.saveValuesStatus != "loading"
-                ? "Missing required fields"
-                : this.props.saveValuesStatus
-            }
-            makeFlush={!this.props.isInModal}
-          />
-        )}
-        {this.renderStateDebugger()}
-      </>
-    );
-  };
-
-  render() {
-    let showSave = this.showSaveButton();
-    return (
-      <>
-        {this.props.isInModal || !showSave ? (
-          <StyledValuesWrapper showSave={showSave}>
-            {this.renderContents(showSave)}
-          </StyledValuesWrapper>
-        ) : (
-          <PaddedWrapper>
-            <StyledValuesWrapper showSave={showSave}>
-              {this.renderContents(showSave)}
-            </StyledValuesWrapper>
-          </PaddedWrapper>
-        )}
-      </>
-    );
-  }
-}
-
-FormWrapper.contextType = Context;
-
-const Spacer = styled.div`
-  width: 100%;
-  height: 200px;
-  background: red;
-  position: relative;
-`;
-
-const TabWrapper = styled.div`
-  min-height: 100px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-`;
-
-const ScrollWrapper = styled.div`
-  padding: 20px;
-  overflow-y: auto;
-  max-height: 300px;
-  padding-top: 15px;
-`;
-
-const Header = styled.div`
-  width: 100%;
-  height: 40px;
-  color: #ffffff;
-  font-weight: 500;
-  padding-left: 17px;
-  background: #00000022;
-  display: flex;
-  align-items: center;
-`;
-
-const StateDisplay = styled.pre`
-  width: 100%;
-  font-size: 13px;
-  display:
-  overflow: hidden;
-  border-radius: 5px;
-  position: relative;
-  line-height: 1.5em;
-  color: #aaaabb;
-  background: #ffffff11;
-`;
-
-const StyledValuesWrapper = styled.div<{ showSave: boolean }>`
-  width: 100%;
-  padding: 0;
-  height: ${(props) => (props.showSave ? "calc(100% - 55px)" : "100%")};
-`;
-
-const PaddedWrapper = styled.div`
-  padding-bottom: 65px;
-  position: relative;
-`;

+ 0 - 69
dashboard/src/components/values-form/RangeSlider.tsx

@@ -1,69 +0,0 @@
-import React, { ChangeEvent, Component } from "react";
-import Slider from "@material-ui/core/Slider";
-import styled from "styled-components";
-
-type PropsType = {};
-
-type StateType = {};
-
-export default class RangeSelector extends Component<PropsType, StateType> {
-  state = {};
-
-  render() {
-    return (
-      <StyledInputRow>
-        <Label>XYZ</Label>
-        <Slider
-          value={12}
-          onChange={() => console.log("xyz")}
-          valueLabelDisplay="auto"
-          aria-labelledby="range-slider"
-        />
-      </StyledInputRow>
-    );
-  }
-}
-
-const Required = styled.div`
-  margin-left: 8px;
-  color: #fc4976;
-`;
-
-const Unit = styled.div`
-  margin-left: 8px;
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  margin-bottom: -1px;
-  align-items: center;
-`;
-
-const Input = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: #ffffff11;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { disabled: boolean; width: string }) =>
-    props.width ? props.width : "270px"};
-  color: ${(props: { disabled: boolean; width: string }) =>
-    props.disabled ? "#ffffff44" : "white"};
-  padding: 5px 10px;
-  height: 35px;
-`;
-
-const Label = styled.div`
-  color: #ffffff;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-`;
-
-const StyledInputRow = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;

+ 0 - 412
dashboard/src/components/values-form/ValuesForm.tsx

@@ -1,412 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import {
-  Section,
-  FormElement,
-  ShowIf,
-  ShowIfOr,
-  ShowIfAnd,
-  ShowIfNot,
-} from "shared/types";
-import { Context } from "shared/Context";
-
-import CheckboxRow from "./CheckboxRow";
-import InputRow from "./InputRow";
-import Base64InputRow from "./Base64InputRow";
-import SelectRow from "./SelectRow";
-import Helper from "./Helper";
-import Heading from "./Heading";
-import ExpandableResource from "../ExpandableResource";
-import ServiceRow from "./ServiceRow";
-import VeleroForm from "../forms/VeleroForm";
-import InputArray from "./InputArray";
-import KeyValueArray from "./KeyValueArray";
-
-type PropsType = {
-  sections?: Section[];
-  metaState?: any;
-  setMetaState?: (key: string, value: any) => void;
-  handleEnvChange?: (x: any) => void;
-  disabled?: boolean;
-  externalValues?: any;
-};
-
-type StateType = any;
-
-// Requires an internal representation unlike other values components because metaState value underdetermines input order
-export default class ValuesForm extends Component<PropsType, StateType> {
-  getInputValue = (item: FormElement) => {
-    if (item) {
-      let key = item.name || item.variable;
-      let value = this.props.metaState[key]?.value;
-
-      if (
-        item.settings &&
-        item.settings.unit &&
-        value &&
-        value.includes &&
-        !item.settings.omitUnitFromValue
-      ) {
-        value = value.split(item.settings.unit)[0];
-      }
-      return value;
-    }
-  };
-
-  renderSection = (section: Section) => {
-    return section.contents?.map((item: FormElement, i: number) => {
-      if (!item) {
-        return;
-      }
-
-      // If no name is assigned use values.yaml variable as identifier
-      let key = item.name || item.variable;
-      let isDisabled =
-        item.settings?.disableAfterLaunch &&
-        !this.props.externalValues?.isLaunch;
-      isDisabled = isDisabled || this.props.disabled;
-
-      switch (item.type) {
-        case "heading":
-          return (
-            <Heading key={i} docs={item.settings?.docs}>
-              {item.label}
-            </Heading>
-          );
-        case "subtitle":
-          return <Helper key={i}>{item.label}</Helper>;
-        case "service-ip-list":
-          if (Array.isArray(item.value)) {
-            return (
-              <ResourceList key={key}>
-                {item.value?.map((service: any, i: number) => {
-                  return <ServiceRow service={service} key={i} />;
-                })}
-              </ResourceList>
-            );
-          }
-        case "resource-list":
-          if (Array.isArray(item.value)) {
-            return (
-              <ResourceList key={key}>
-                {item.value?.map((resource: any, i: number) => {
-                  if (resource.data) {
-                    return (
-                      <ExpandableResource
-                        key={i}
-                        resource={resource}
-                        isLast={i === item.value.length - 1}
-                        roundAllCorners={true}
-                      />
-                    );
-                  }
-                })}
-              </ResourceList>
-            );
-          }
-        case "checkbox":
-          return (
-            <CheckboxRow
-              key={key}
-              disabled={isDisabled}
-              isRequired={item.required}
-              checked={this.props.metaState[key]?.value}
-              toggle={() =>
-                this.props.setMetaState(key, !this.props.metaState[key]?.value)
-              }
-              label={item.label}
-            />
-          );
-        case "env-key-value-array":
-          return (
-            <KeyValueArray
-              key={key}
-              width="100%"
-              envLoader={true}
-              externalValues={this.props.externalValues}
-              values={this.props.metaState[key]?.value}
-              setValues={(x: any) => {
-                this.props.setMetaState(key, x);
-
-                // Need to pull env vars out of form.yaml for createGHA build env vars
-                if (
-                  this.props.handleEnvChange &&
-                  key === "container.env.normal"
-                ) {
-                  // this.props.handleEnvChange(x);
-                }
-              }}
-              label={item.label}
-              disabled={isDisabled}
-              secretOption={true}
-            />
-          );
-        case "key-value-array":
-          return (
-            <KeyValueArray
-              key={key}
-              width="100%"
-              externalValues={this.props.externalValues}
-              values={this.props.metaState[key]?.value}
-              setValues={(x: any) => this.props.setMetaState(key, x)}
-              label={item.label}
-              disabled={isDisabled}
-            />
-          );
-        case "array-input":
-          return (
-            <InputArray
-              key={key}
-              width="100%"
-              values={this.props.metaState[key]?.value}
-              setValues={(x: string[]) => {
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              disabled={isDisabled}
-            />
-          );
-        case "string-input":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              placeholder={item.placeholder}
-              isRequired={item.required}
-              type="text"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              info={item.info}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "string-input-password":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              placeholder={item.placeholder}
-              isRequired={item.required}
-              type="password"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, x);
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "number-input":
-          return (
-            <InputRow
-              key={key}
-              width="100%"
-              isRequired={item.required}
-              placeholder={item.placeholder}
-              type="number"
-              value={this.getInputValue(item)}
-              setValue={(x: number) => {
-                let val: string | number = x;
-                if (Number.isNaN(x)) {
-                  val = "";
-                }
-
-                // Convert to string if unit is set
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  val = x.toString();
-                  val = val + item.settings.unit;
-                }
-
-                this.props.setMetaState(key, val);
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "select":
-          return (
-            <SelectRow
-              key={key}
-              value={this.props.metaState[key]?.value}
-              setActiveValue={(val) => this.props.setMetaState(key, val)}
-              options={item.settings.options}
-              dropdownLabel=""
-              label={item.label}
-            />
-          );
-        case "provider-select":
-          return (
-            <SelectRow
-              key={key}
-              value={this.props.metaState[key]?.value}
-              setActiveValue={(val) => this.props.setMetaState(key, val)}
-              options={[
-                { value: "aws", label: "Amazon Web Services (AWS)" },
-                { value: "gcp", label: "Google Cloud Platform (GCP)" },
-                { value: "do", label: "DigitalOcean" },
-              ]}
-              dropdownLabel=""
-              label={item.label}
-            />
-          );
-        case "velero-create-backup":
-          return <VeleroForm />;
-        case "base-64":
-          return (
-            <Base64InputRow
-              key={key}
-              width="100%"
-              isRequired={item.required}
-              type="text"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, btoa(x));
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        case "base-64-password":
-          return (
-            <Base64InputRow
-              key={key}
-              isRequired={item.required}
-              type="password"
-              value={this.getInputValue(item)}
-              setValue={(x: string) => {
-                if (
-                  item.settings &&
-                  item.settings.unit &&
-                  x !== "" &&
-                  !item.settings.omitUnitFromValue
-                ) {
-                  x = x + item.settings.unit;
-                }
-                this.props.setMetaState(key, btoa(x));
-              }}
-              label={item.label}
-              unit={item.settings ? item.settings.unit : null}
-              disabled={isDisabled}
-            />
-          );
-        default:
-      }
-    });
-  };
-
-  evalShowIf = (vals: ShowIf): boolean => {
-    if (!vals) {
-      return false;
-    }
-    if (typeof vals == "string") {
-      return !!this.props.metaState[vals]?.value;
-    }
-    if ((vals as ShowIfOr).or) {
-      vals = vals as ShowIfOr;
-      for (let i = 0; i < vals.or.length; i++) {
-        if (this.evalShowIf(vals.or[i])) {
-          return true;
-        }
-      }
-      return false;
-    }
-    if ((vals as ShowIfAnd).and) {
-      vals = vals as ShowIfAnd;
-      for (let i = 0; i < vals.and.length; i++) {
-        if (!this.evalShowIf(vals.and[i])) {
-          return false;
-        }
-      }
-      return true;
-    }
-    if ((vals as ShowIfNot).not) {
-      vals = vals as ShowIfNot;
-      return !this.evalShowIf(vals.not);
-    }
-
-    return false;
-  };
-
-  renderFormContents = () => {
-    if (this.props.metaState) {
-      return this.props.sections?.map((section: Section, i: number) => {
-        // Hide collapsible section if deciding field is false
-        if (section.show_if && !this.evalShowIf(section.show_if)) {
-          return null;
-        }
-
-        return <div key={i}>{this.renderSection(section)}</div>;
-      });
-    }
-  };
-
-  render() {
-    return (
-      <StyledValuesForm>
-        <DarkMatter />
-        {this.renderFormContents()}
-      </StyledValuesForm>
-    );
-  }
-}
-
-ValuesForm.contextType = Context;
-
-const ResourceList = styled.div`
-  margin-bottom: 15px;
-  margin-top: 20px;
-  border-radius: 5px;
-  overflow: hidden;
-`;
-
-const DarkMatter = styled.div`
-  margin-top: 0px;
-`;
-
-const StyledValuesForm = styled.div`
-  width: 100%;
-  height: 100%;
-  background: #ffffff11;
-  color: #ffffff;
-  padding: 0px 35px 25px;
-  position: relative;
-  border-radius: 5px;
-  font-size: 13px;
-  overflow: auto;
-`;

+ 1 - 1
dashboard/src/main/home/Home.tsx

@@ -614,7 +614,7 @@ const ViewWrapper = styled.div`
 `;
 
 const DashboardWrapper = styled.div`
-  width: 83%;
+  width: calc(85%);
   min-width: 300px;
 `;
 

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

@@ -260,7 +260,7 @@ const Placeholder = styled.div`
   color: #ffffff44;
   background: #26282f;
   border-radius: 5px;
-  height: 320px;
+  height: 370px;
   display: flex;
   align-items: center;
   justify-content: center;

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -1,8 +1,8 @@
 import React, { useContext, useState } from "react";
 import styled from "styled-components";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
-import InputRow from "components/values-form/InputRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
 import { Context } from "shared/Context";
 import api from "shared/api";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -59,5 +59,5 @@ export const ConditionsTable: React.FunctionComponent<NodeStatusModalProps> = ({
 };
 
 const TableWrapper = styled.div`
-  margin-top: 14px;
+  margin-top: 36px;
 `;

+ 64 - 120
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx

@@ -1,7 +1,7 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import { useHistory, useLocation, useParams } from "react-router";
 import styled from "styled-components";
-import closeImg from "assets/close.png";
+import backArrow from "assets/back_arrow.png";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
@@ -11,6 +11,7 @@ import { pushFiltered } from "shared/routing";
 import NodeUsage from "./NodeUsage";
 import { ConditionsTable } from "./ConditionsTable";
 import StatusSection from "components/StatusSection";
+import TitleSection from "components/TitleSection";
 
 type ExpandedNodeViewParams = {
   nodeId: string;
@@ -90,54 +91,73 @@ export const ExpandedNodeView = () => {
   }, [node]);
 
   return (
-    <>
-      <CloseOverlay onClick={closeNodeView} />
-      <StyledExpandedChart>
-        <HeaderWrapper>
-          <TitleSection>
-            <Title>
-              <IconWrapper>
-                <img src={nodePng} />
-              </IconWrapper>
-              {nodeId}
-              <InstanceType>{instanceType}</InstanceType>
-            </Title>
-          </TitleSection>
-
-          <CloseButton onClick={closeNodeView}>
-            <CloseButtonImg src={closeImg} />
-          </CloseButton>
-        </HeaderWrapper>
-        <BodyWrapper>
-          <NodeUsage node={node} />
-
-          <StatusWrapper>
-            <StatusSection status={nodeStatus} />
-          </StatusWrapper>
-
-          <TabSelector
-            options={tabOptions}
-            currentTab={currentTab}
-            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
-          />
-          {currentTabPage}
-        </BodyWrapper>
-      </StyledExpandedChart>
-    </>
+    <StyledExpandedNodeView>
+      <HeaderWrapper>
+        <BackButton onClick={closeNodeView}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <TitleSection icon={nodePng}>
+          {nodeId}
+          <InstanceType>{instanceType}</InstanceType>
+        </TitleSection>
+      </HeaderWrapper>
+      <BodyWrapper>
+        <NodeUsage node={node} />
+
+        <StatusWrapper>
+          <StatusSection status={nodeStatus} />
+        </StatusWrapper>
+
+        <TabSelector
+          options={tabOptions}
+          currentTab={currentTab}
+          setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+        />
+        {currentTabPage}
+      </BodyWrapper>
+    </StyledExpandedNodeView>
   );
 };
 
 export default ExpandedNodeView;
 
+const BackButton = styled.div`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
 const StatusWrapper = styled.div`
   margin-left: 3px;
-  margin-bottom: 15px;
+  margin-bottom: 20px;
 `;
 
 const InstanceType = styled.div`
   font-weight: 400;
   color: #ffffff44;
   margin-left: 12px;
+  font-size: 16px;
 `;
 
 const BodyWrapper = styled.div`
@@ -146,104 +166,28 @@ const BodyWrapper = styled.div`
   overflow: hidden;
 `;
 
-const HeaderWrapper = styled.div``;
-
-const CloseOverlay = styled.div`
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  background: #202227;
-  animation: fadeIn 0.2s 0s;
-  opacity: 0;
-  animation-fill-mode: forwards;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const IconWrapper = styled.div`
-  font-size: 16px;
-  height: 20px;
-  width: 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 3px;
-  margin-right: 12px;
-
-  > img {
-    filter: brightness(50%);
-    width: 18px;
-  }
-`;
-
-const Title = styled.div`
-  font-size: 20px;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-  user-select: text;
-`;
-
-const TitleSection = styled.div`
-  width: 100%;
+const HeaderWrapper = styled.div`
   position: relative;
 `;
 
-const CloseButton = styled.div`
-  position: absolute;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledExpandedChart = styled.div`
-  width: calc(100% - 50px);
-  height: calc(100% - 50px);
+const StyledExpandedNodeView = styled.div`
+  width: 100%;
   z-index: 0;
-  position: absolute;
-  top: 25px;
-  left: 25px;
-  border-radius: 10px;
-  background: #26272f;
-  box-shadow: 0 5px 12px 4px #00000033;
-  animation: floatIn 0.3s;
+  animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;
-  padding: 25px;
   display: flex;
-  overflow: hidden;
+  overflow-y: auto;
+  padding-bottom: 120px;
   flex-direction: column;
+  overflow: visible;
 
-  @keyframes floatIn {
+  @keyframes fadeIn {
     from {
       opacity: 0;
-      transform: translateY(30px);
     }
     to {
       opacity: 1;
-      transform: translateY(0px);
     }
   }
 `;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -7,10 +7,10 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Selector from "components/Selector";
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import SaveButton from "components/SaveButton";
 import { isAlphanumeric } from "shared/common";
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -144,7 +144,7 @@ const Placeholder = styled.div`
   color: #ffffff44;
   background: #26282f;
   border-radius: 5px;
-  height: 320px;
+  height: 370px;
   display: flex;
   align-items: center;
   justify-content: center;

+ 18 - 14
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -16,9 +16,9 @@ import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import TabRegion from "components/TabRegion";
 import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
-import InputRow from "components/values-form/InputRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = WithAuthProps & {
@@ -376,10 +376,6 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
     return (
       <>
         <StyledExpandedChart>
-          <BackButton onClick={closeExpanded}>
-            <BackButtonImg src={backArrow} />
-          </BackButton>
-
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
             message={`Are you sure you want to delete ${name}?`}
@@ -388,12 +384,17 @@ class ExpandedEnvGroup extends Component<PropsType, StateType> {
           />
           {this.renderDeleteOverlay()}
 
-          <TitleSection icon={key} iconWidth="33px">
-            {name}
-            <TagWrapper>
-              Namespace <NamespaceTag>{namespace}</NamespaceTag>
-            </TagWrapper>
-          </TitleSection>
+          <HeaderWrapper>
+            <BackButton onClick={closeExpanded}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+            <TitleSection icon={key} iconWidth="33px">
+              {name}
+              <TagWrapper>
+                Namespace <NamespaceTag>{namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+          </HeaderWrapper>
 
           <InfoWrapper>
             <LastDeployed>
@@ -419,6 +420,10 @@ ExpandedEnvGroup.contextType = Context;
 
 export default withAuth(ExpandedEnvGroup);
 
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
 const BackButton = styled.div`
   position: absolute;
   top: 0px;
@@ -572,7 +577,6 @@ const NamespaceTag = styled.div`
 const StyledExpandedChart = styled.div`
   width: 100%;
   z-index: 0;
-  position: relative;
   animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;

+ 70 - 56
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -24,7 +24,7 @@ import api from "shared/api";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import StatusIndicator from "components/StatusIndicator";
-import FormWrapper from "components/values-form/FormWrapper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import RevisionSection from "./RevisionSection";
 import ValuesYaml from "./ValuesYaml";
 import GraphSection from "./GraphSection";
@@ -66,7 +66,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [devOpsMode, setDevOpsMode] = useState<boolean>(
     localStorage.getItem("devOpsMode") === "true"
   );
-  const [tabOptions, setTabOptions] = useState<any[]>([]);
+  const [rightTabOptions, setRightTabOptions] = useState<any[]>([]);
+  const [leftTabOptions, setLeftTabOptions] = useState<any[]>([]);
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
@@ -178,7 +179,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setControllers((oldControllers) => {
           switch (event.event_type) {
             case "DELETE":
-              typeof oldControllers !== "undefined" && delete oldControllers[object.metadata.uid];
+              typeof oldControllers !== "undefined" &&
+                delete oldControllers[object.metadata.uid];
             case "UPDATE":
               if (
                 oldControllers &&
@@ -425,17 +427,18 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   const updateTabs = () => {
     // Collate non-form tabs
-    let tabOptions = [] as any[];
-    tabOptions.push({ label: "Status", value: "status" });
+    let rightTabOptions = [] as any[];
+    let leftTabOptions = [] as any[];
+    rightTabOptions.push({ label: "Status", value: "status" });
 
     if (props.isMetricsInstalled) {
-      tabOptions.push({ label: "Metrics", value: "metrics" });
+      rightTabOptions.push({ label: "Metrics", value: "metrics" });
     }
 
-    tabOptions.push({ label: "Chart Overview", value: "graph" });
+    rightTabOptions.push({ label: "Chart Overview", value: "graph" });
 
     if (devOpsMode) {
-      tabOptions.push(
+      rightTabOptions.push(
         { label: "Manifests", value: "list" },
         { label: "Helm Values", value: "values" }
       );
@@ -443,18 +446,22 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     // Settings tab is always last
     if (isAuthorized("application", "", ["get", "delete"])) {
-      tabOptions.push({ label: "Settings", value: "settings" });
+      rightTabOptions.push({ label: "Settings", value: "settings" });
     }
 
     // Filter tabs if previewing an old revision or updating the chart version
     if (isPreview) {
       let liveTabs = ["status", "settings", "deploy", "metrics"];
-      tabOptions = tabOptions.filter(
+      rightTabOptions = rightTabOptions.filter(
+        (tab: any) => !liveTabs.includes(tab.value)
+      );
+      leftTabOptions = leftTabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
     }
 
-    setTabOptions(tabOptions);
+    setRightTabOptions(rightTabOptions);
+    setLeftTabOptions(leftTabOptions);
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
@@ -648,10 +655,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
   return (
     <>
       <StyledExpandedChart>
-        <BackButton onClick={props.closeChart}>
-          <BackButtonImg src={backArrow} />
-        </BackButton>
-
         <ConfirmOverlay
           show={showDeleteOverlay}
           message={`Are you sure you want to delete ${currentChart.name}?`}
@@ -664,6 +667,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
           </DeleteOverlay>
         )}
         <HeaderWrapper>
+          <BackButton onClick={props.closeChart}>
+            <BackButtonImg src={backArrow} />
+          </BackButton>
           <TitleSection
             icon={currentChart.chart.metadata.icon}
             iconWidth="33px"
@@ -688,48 +694,49 @@ const ExpandedChart: React.FC<Props> = (props) => {
               {" " + getReadableDate(currentChart.info.last_deployed)}
             </LastDeployed>
           </InfoWrapper>
-
-          <RevisionSection
-            showRevisions={showRevisions}
-            toggleShowRevisions={() => {
-              setShowRevisions(!showRevisions);
-            }}
-            chart={currentChart}
-            refreshChart={() => getChartData(currentChart)}
-            setRevision={setRevision}
-            forceRefreshRevisions={forceRefreshRevisions}
-            refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-            status={chartStatus}
-            shouldUpdate={
-              currentChart.latest_version &&
-              currentChart.latest_version !==
-                currentChart.chart.metadata.version
-            }
-            latestVersion={currentChart.latest_version}
-            upgradeVersion={handleUpgradeVersion}
-          />
         </HeaderWrapper>
-        <FormWrapper
-          isReadOnly={
-            imageIsPlaceholder ||
-            !isAuthorized("application", "", ["get", "update"])
-          }
-          formData={currentChart.form}
-          tabOptions={tabOptions}
-          renderTabContents={renderTabContents}
-          onSubmit={onSubmit}
-          saveValuesStatus={saveValuesStatus}
-          externalValues={{
-            namespace: props.namespace,
-            clusterId: currentCluster.id,
+        <RevisionSection
+          showRevisions={showRevisions}
+          toggleShowRevisions={() => {
+            setShowRevisions(!showRevisions);
           }}
-          color={isPreview ? "#f5cb42" : null}
-          addendum={
-            <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
-              <i className="material-icons">offline_bolt</i> DevOps Mode
-            </TabButton>
+          chart={currentChart}
+          refreshChart={() => getChartData(currentChart)}
+          setRevision={setRevision}
+          forceRefreshRevisions={forceRefreshRevisions}
+          refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+          status={chartStatus}
+          shouldUpdate={
+            currentChart.latest_version &&
+            currentChart.latest_version !== currentChart.chart.metadata.version
           }
+          latestVersion={currentChart.latest_version}
+          upgradeVersion={handleUpgradeVersion}
         />
+        <BodyWrapper>
+          <PorterFormWrapper
+            formData={currentChart.form}
+            valuesToOverride={{
+              namespace: props.namespace,
+              clusterId: currentCluster.id,
+            }}
+            renderTabContents={renderTabContents}
+            isReadOnly={
+              imageIsPlaceholder ||
+              !isAuthorized("application", "", ["get", "update"])
+            }
+            onSubmit={onSubmit}
+            rightTabOptions={rightTabOptions}
+            leftTabOptions={leftTabOptions}
+            color={isPreview ? "#f5cb42" : null}
+            addendum={
+              <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
+                <i className="material-icons">offline_bolt</i> DevOps Mode
+              </TabButton>
+            }
+            saveValuesStatus={saveValuesStatus}
+          />
+        </BodyWrapper>
       </StyledExpandedChart>
     </>
   );
@@ -739,6 +746,11 @@ export default ExpandedChart;
 
 const TextWrap = styled.div``;
 
+const BodyWrapper = styled.div`
+  position: relative;
+  overflow: hidden;
+`;
+
 const BackButton = styled.div`
   position: absolute;
   top: 0px;
@@ -774,7 +786,8 @@ const Header = styled.div`
 `;
 
 const Placeholder = styled.div`
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   padding: 30px;
   padding-bottom: 90px;
   font-size: 13px;
@@ -876,7 +889,9 @@ const TabButton = styled.div`
   }
 `;
 
-const HeaderWrapper = styled.div``;
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
 
 const Dot = styled.div`
   margin-right: 9px;
@@ -953,7 +968,6 @@ const IconWrapper = styled.div`
 const StyledExpandedChart = styled.div`
   width: 100%;
   z-index: 0;
-  position: relative;
   animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;

+ 61 - 58
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -16,7 +16,8 @@ import Loading from "components/Loading";
 import TitleSection from "components/TitleSection";
 import JobList from "./jobs/JobList";
 import SettingsSection from "./SettingsSection";
-import FormWrapper from "components/values-form/FormWrapper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+import { PlaceHolder } from "brace";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
 type PropsType = WithAuthProps & {
@@ -33,7 +34,8 @@ type StateType = {
   newestImage: string;
   loading: boolean;
   jobs: any[];
-  tabOptions: any[];
+  leftTabOptions: any[];
+  rightTabOptions: any[];
   tabContents: any;
   currentTab: string | null;
   websockets: Record<string, any>;
@@ -41,7 +43,6 @@ type StateType = {
   deleting: boolean;
   saveValuesStatus: string | null;
   formData: any;
-  valuesToOverride: any;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -51,7 +52,8 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     newestImage: null as string,
     loading: true,
     jobs: [] as any[],
-    tabOptions: [] as any[],
+    leftTabOptions: [] as any[],
+    rightTabOptions: [] as any[],
     tabContents: [] as any,
     currentTab: null as string | null,
     websockets: {} as Record<string, any>,
@@ -59,7 +61,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     deleting: false,
     saveValuesStatus: null as string | null,
     formData: {} as any,
-    valuesToOverride: {} as any,
   };
 
   // Retrieve full chart data (includes form and values)
@@ -422,12 +423,18 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
 
   renderTabContents = (currentTab: string, submitValues?: any) => {
     let saveButton = (
-      <SaveButton
-        text="Rerun Job"
-        onClick={() => this.handleSaveValues(submitValues, true)}
-        status={this.state.saveValuesStatus}
-        makeFlush={true}
-      />
+      <ButtonWrapper>
+        <SaveButton
+          onClick={() => this.handleSaveValues(submitValues, true)}
+          status={this.state.saveValuesStatus}
+          makeFlush={true}
+          clearPosition={true}
+          rounded={true}
+          statusPosition="right"
+        >
+          <i className="material-icons">play_arrow</i> Run Job
+        </SaveButton>
+      </ButtonWrapper>
     );
 
     if (!this.props.isAuthorized("job", "", ["get", "update", "create"])) {
@@ -451,13 +458,13 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         return (
           <TabWrapper>
+            {saveButton}
             <JobList
               jobs={this.state.jobs}
               setJobs={(jobs: any) => {
                 this.setState({ jobs });
               }}
             />
-            {saveButton}
           </TabWrapper>
         );
       case "settings":
@@ -485,28 +492,16 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
         formData,
       });
     }
-    let tabOptions = [] as any[];
-
-    // Append universal tabs
-    tabOptions.push({ label: "Jobs", value: "jobs" });
-
-    if (formData) {
-      formData.tabs.map((tab: any, i: number) => {
-        tabOptions.push({
-          value: tab.name,
-          label: tab.label,
-          sections: tab.sections,
-          context: tab.context,
-        });
-      });
-    }
-
+    let rightTabOptions = [] as any[];
     if (this.props.isAuthorized("job", "", ["get", "delete"])) {
-      tabOptions.push({ label: "Settings", value: "settings" });
+      rightTabOptions.push({ label: "Settings", value: "settings" });
     }
 
     // Filter tabs if previewing an old revision
-    this.setState({ tabOptions });
+    this.setState({
+      leftTabOptions: [{ label: "Jobs", value: "jobs" }],
+      rightTabOptions,
+    });
   }
 
   readableDate = (s: string) => {
@@ -573,10 +568,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     return (
       <>
         <StyledExpandedChart>
-          <BackButton onClick={closeChart}>
-            <BackButtonImg src={backArrow} />
-          </BackButton>
-
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
             message={`Are you sure you want to delete ${currentChart.name}?`}
@@ -586,6 +577,9 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
           {this.renderDeleteOverlay()}
 
           <HeaderWrapper>
+            <BackButton onClick={closeChart}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
             <TitleSection
               icon={currentChart.chart.metadata.icon}
               iconWidth="33px"
@@ -606,25 +600,29 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
           </HeaderWrapper>
 
           <BodyWrapper>
-            <FormWrapper
-              isReadOnly={
-                this.state.imageIsPlaceholder ||
-                !this.props.isAuthorized("job", "", ["get", "update"])
-              }
-              valuesToOverride={this.state.valuesToOverride}
-              clearValuesToOverride={() =>
-                this.setState({ valuesToOverride: {} })
-              }
-              formData={this.state.formData}
-              tabOptions={this.state.tabOptions}
-              renderTabContents={this.renderTabContents}
-              tabOptionsOnly={true}
-              onSubmit={(formValues) =>
-                this.handleSaveValues(formValues, false)
-              }
-              saveValuesStatus={this.state.saveValuesStatus}
-              saveButtonText="Save Config"
-            />
+            {(this.state.leftTabOptions?.length > 0 ||
+              this.state.formData.tabs?.length > 0 ||
+              this.state.rightTabOptions?.length > 0) && (
+              <PorterFormWrapper
+                formData={this.state.formData}
+                valuesToOverride={{
+                  namespace: chart.namespace,
+                  clusterId: this.props.currentCluster.id,
+                }}
+                renderTabContents={this.renderTabContents}
+                isReadOnly={
+                  this.state.imageIsPlaceholder ||
+                  !this.props.isAuthorized("job", "", ["get", "update"])
+                }
+                onSubmit={(formValues) =>
+                  this.handleSaveValues(formValues, false)
+                }
+                leftTabOptions={this.state.leftTabOptions}
+                rightTabOptions={this.state.rightTabOptions}
+                saveValuesStatus={this.state.saveValuesStatus}
+                saveButtonText="Save Config"
+              />
+            )}
           </BodyWrapper>
         </StyledExpandedChart>
       </>
@@ -636,6 +634,10 @@ ExpandedJobChart.contextType = Context;
 
 export default withAuth(ExpandedJobChart);
 
+const ButtonWrapper = styled.div`
+  margin: 5px 0 35px;
+`;
+
 const BackButton = styled.div`
   position: absolute;
   top: 0px;
@@ -673,7 +675,8 @@ const Header = styled.div`
 `;
 
 const Placeholder = styled.div`
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   padding: 30px;
   padding-bottom: 70px;
   font-size: 13px;
@@ -692,8 +695,7 @@ const Spinner = styled.img`
 `;
 
 const BodyWrapper = styled.div`
-  width: 100%;
-  height: 100%;
+  position: relative;
   overflow: hidden;
 `;
 
@@ -736,7 +738,9 @@ const DeleteOverlay = styled.div`
   }
 `;
 
-const HeaderWrapper = styled.div``;
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
 
 const Dot = styled.div`
   margin-right: 9px;
@@ -814,7 +818,6 @@ const IconWrapper = styled.div`
 const StyledExpandedChart = styled.div`
   width: 100%;
   z-index: 0;
-  position: relative;
   animation: fadeIn 0.3s;
   animation-timing-function: ease-out;
   animation-fill-mode: forwards;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx

@@ -48,7 +48,7 @@ GraphSection.contextType = Context;
 
 const StyledGraphSection = styled.div`
   width: 100%;
-  min-height: 450px;
+  min-height: 400px;
   height: 50vh;
   font-size: 13px;
   overflow: hidden;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx

@@ -142,7 +142,7 @@ const StyledListSection = styled.div`
   display: flex;
   font-size: 13px;
   width: 100%;
-  min-height: 450px;
+  min-height: 400px;
   height: 50vh;
   font-size: 13px;
   overflow: hidden;

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -13,8 +13,8 @@ import { Context } from "shared/Context";
 
 import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
 import useAuth from "shared/auth/useAuth";

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -119,7 +119,7 @@ const StyledValuesYaml = styled.div`
   display: flex;
   flex-direction: column;
   width: 100%;
-  min-height: 450px;
+  min-height: 400px;
   height: 50vh;
   font-size: 13px;
   overflow: hidden;

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -112,7 +112,8 @@ export default withAuth(JobList);
 
 const Placeholder = styled.div`
   width: 100%;
-  height: 100%;
+  min-height: 400px;
+  height: 50vh;
   display: flex;
   align-items: center;
   justify-content: center;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -8,7 +8,7 @@ import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import trash from "assets/trash.png";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import KeyValueArray from "components/form-components/KeyValueArray";
 
 type PropsType = {
   job: any;

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -9,11 +9,11 @@ import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import Loading from "components/Loading";
-import SelectRow from "components/values-form/SelectRow";
+import SelectRow from "components/form-components/SelectRow";
 import AreaChart from "./AreaChart";
 import { MetricNormalizer } from "./MetricNormalizer";
 import { AvailableMetrics, NormalizedMetricsData } from "./types";
-import CheckboxRow from "components/values-form/CheckboxRow";
+import CheckboxRow from "components/form-components/CheckboxRow";
 
 type PropsType = {
   currentChart: ChartTypeWithExtendedConfig;
@@ -682,7 +682,7 @@ const MetricsLabel = styled.div`
 
 const StyledMetricsSection = styled.div`
   width: 100%;
-  min-height: 450px;
+  min-height: 400px;
   height: 50vh;
   overflow: hidden;
   display: flex;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -154,7 +154,7 @@ const StyledStatusSection = styled.div`
   user-select: text;
   overflow: hidden;
   width: 100%;
-  min-height: 450px;
+  min-height: 400px;
   height: 50vh;
   font-size: 13px;
   overflow: hidden;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -8,7 +8,7 @@ import {
   DetailedClusterType,
   DetailedIngressError,
 } from "shared/types";
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import { pushFiltered } from "shared/routing";
 
 import { RouteComponentProps, withRouter } from "react-router";

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -11,7 +11,7 @@ import ClusterPlaceholderContainer from "./ClusterPlaceholderContainer";
 import { RouteComponentProps, withRouter } from "react-router";
 import TabRegion from "components/TabRegion";
 import Provisioner from "../provisioner/Provisioner";
-import FormDebugger from "components/values-form/FormDebugger";
+import FormDebugger from "components/porter-form/FormDebugger";
 import TitleSection from "components/TitleSection";
 
 import { pushQueryParams, pushFiltered } from "shared/routing";

+ 0 - 69
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -131,9 +131,6 @@ const IntegrationCategories: React.FC<Props> = (props) => {
           {buttonText}
         </Button>
       </Flex>
-
-      <LineBreak />
-
       {loading ? (
         <Loading />
       ) : props.category == "slack" ? (
@@ -155,65 +152,6 @@ const IntegrationCategories: React.FC<Props> = (props) => {
 
 export default withRouter(IntegrationCategories);
 
-const Label = styled.div`
-  color: #ffffff;
-  font-size: 14px;
-  font-weight: 500;
-`;
-
-const MainRow = styled.div`
-  height: 70px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 25px;
-  border-radius: 5px;
-  :hover {
-    background: ${(props: { disabled: boolean }) =>
-      props.disabled ? "" : "#ffffff11"};
-    > i {
-      background: ${(props: { disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
-    }
-  }
-
-  > i {
-    border-radius: 20px;
-    font-size: 18px;
-    padding: 5px;
-    color: #ffffff44;
-    margin-right: -7px;
-    :hover {
-      background: ${(props: { disabled: boolean }) =>
-        props.disabled ? "" : "#ffffff11"};
-    }
-  }
-`;
-
-const Integration = styled.div`
-  margin-left: -2px;
-  display: flex;
-  flex-direction: column;
-  background: #26282f;
-  cursor: ${(props: { disabled: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-  margin-bottom: 15px;
-  border-radius: 5px;
-  box-shadow: 0 5px 8px 0px #00000033;
-`;
-
-const StyledIntegrationList = styled.div`
-  margin-top: 20px;
-  margin-bottom: 80px;
-`;
-
-const Icon = styled.img`
-  width: 27px;
-  margin-right: 12px;
-  margin-bottom: -1px;
-`;
-
 const Flex = styled.div`
   display: flex;
   align-items: center;
@@ -262,10 +200,3 @@ const Button = styled.div`
     justify-content: center;
   }
 `;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 18px 0px 24px;
-`;

+ 1 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -254,7 +254,7 @@ const Icon = styled.img`
 
 const Placeholder = styled.div`
   width: 100%;
-  height: 150px;
+  height: 250px;
   display: flex;
   align-items: center;
   font-size: 13px;

+ 2 - 2
dashboard/src/main/home/integrations/Integrations.tsx

@@ -37,7 +37,7 @@ const Integrations: React.FC<PropsType> = (props) => {
                   }
                   icon={icon}
                 >
-                    {integrationList[integration].label}
+                  {integrationList[integration].label}
                 </TitleSection>
                 <CreateIntegrationForm
                   integrationName={integration}
@@ -123,6 +123,6 @@ const TitleSectionAlt = styled(TitleSection)`
 `;
 
 const StyledIntegrations = styled.div`
-  width: 83%;
+  width: calc(85%);
   min-width: 300px;
 `;

+ 56 - 38
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -48,44 +48,48 @@ const SlackIntegrationList: React.FC<Props> = (props) => {
         onNo={() => setIsDelete(false)}
       />
       <StyledIntegrationList>
-        {props.slackData.map((inst, idx) => {
-          if (deleted.current.has(idx)) return null;
-          return (
-            <Integration
-              onClick={() => {}}
-              disabled={false}
-              key={`${inst.team_id}-${inst.channel}`}
-            >
-              <MainRow disabled={false}>
-                <Flex>
-                  <Icon src={inst.team_icon_url && inst.team_icon_url} />
-                  <Label>
-                    {inst.team_name || inst.team_id} - {inst.channel}
-                  </Label>
-                </Flex>
-                <MaterialIconTray disabled={false}>
-                  <i
-                    className="material-icons"
-                    onClick={() => {
-                      setDeleteIndex(idx);
-                      setIsDelete(true);
-                    }}
-                  >
-                    delete
-                  </i>
-                  <i
-                    className="material-icons"
-                    onClick={() => {
-                      window.open(inst.configuration_url, "_blank");
-                    }}
-                  >
-                    launch
-                  </i>
-                </MaterialIconTray>
-              </MainRow>
-            </Integration>
-          );
-        })}
+        {props.slackData?.length > 0 ? (
+          props.slackData.map((inst, idx) => {
+            if (deleted.current.has(idx)) return null;
+            return (
+              <Integration
+                onClick={() => {}}
+                disabled={false}
+                key={`${inst.team_id}-${inst.channel}`}
+              >
+                <MainRow disabled={false}>
+                  <Flex>
+                    <Icon src={inst.team_icon_url && inst.team_icon_url} />
+                    <Label>
+                      {inst.team_name || inst.team_id} - {inst.channel}
+                    </Label>
+                  </Flex>
+                  <MaterialIconTray disabled={false}>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        setDeleteIndex(idx);
+                        setIsDelete(true);
+                      }}
+                    >
+                      delete
+                    </i>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        window.open(inst.configuration_url, "_blank");
+                      }}
+                    >
+                      launch
+                    </i>
+                  </MaterialIconTray>
+                </MainRow>
+              </Integration>
+            );
+          })
+        ) : (
+          <Placeholder>No Slack integrations set up yet.</Placeholder>
+        )}
       </StyledIntegrationList>
     </>
   );
@@ -93,6 +97,20 @@ const SlackIntegrationList: React.FC<Props> = (props) => {
 
 export default SlackIntegrationList;
 
+const Placeholder = styled.div`
+  width: 100%;
+  height: 250px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  justify-content: center;
+  margin-top: 30px;
+  background: #ffffff11;
+  color: #ffffff44;
+  border-radius: 5px;
+`;
+
 const Label = styled.div`
   color: #ffffff;
   font-size: 14px;

+ 1 - 1
dashboard/src/main/home/integrations/create-integration/DockerHubForm.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import SaveButton from "components/SaveButton";
 
 type PropsType = {

+ 3 - 3
dashboard/src/main/home/integrations/create-integration/ECRForm.tsx

@@ -4,10 +4,10 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/create-integration/EKSForm.tsx

@@ -1,11 +1,11 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import InputRow from "components/form-components/InputRow";
+import TextArea from "components/form-components/TextArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/create-integration/GCRForm.tsx

@@ -4,11 +4,11 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import InputRow from "components/values-form/InputRow";
-import UploadArea from "components/values-form/UploadArea";
+import InputRow from "components/form-components/InputRow";
+import UploadArea from "components/form-components/UploadArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/create-integration/GKEForm.tsx

@@ -1,11 +1,11 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import InputRow from "components/form-components/InputRow";
+import TextArea from "components/form-components/TextArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 1 - 1
dashboard/src/main/home/integrations/edit-integration/DockerHubForm.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import SaveButton from "components/SaveButton";
 
 type PropsType = {

+ 3 - 3
dashboard/src/main/home/integrations/edit-integration/ECRForm.tsx

@@ -4,10 +4,10 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/edit-integration/EKSForm.tsx

@@ -1,11 +1,11 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import InputRow from "components/form-components/InputRow";
+import TextArea from "components/form-components/TextArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/edit-integration/GCRForm.tsx

@@ -4,11 +4,11 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import api from "shared/api";
 
-import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import InputRow from "components/form-components/InputRow";
+import TextArea from "components/form-components/TextArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 4 - 4
dashboard/src/main/home/integrations/edit-integration/GKEForm.tsx

@@ -1,11 +1,11 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import InputRow from "components/values-form/InputRow";
-import TextArea from "components/values-form/TextArea";
+import InputRow from "components/form-components/InputRow";
+import TextArea from "components/form-components/TextArea";
 import SaveButton from "components/SaveButton";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 type PropsType = {
   closeForm: () => void;

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -401,6 +401,6 @@ const TemplateList = styled.div`
 `;
 
 const TemplatesWrapper = styled.div`
-  width: 83%;
+  width: calc(85%);
   min-width: 300px;
 `;

+ 1 - 1
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -6,7 +6,7 @@ import Markdown from "markdown-to-jsx";
 import { Context } from "shared/Context";
 
 import { PorterTemplate } from "shared/types";
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import Selector from "components/Selector";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";

+ 18 - 11
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -13,10 +13,10 @@ import {
 
 import { isAlphanumeric } from "shared/common";
 
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import SaveButton from "components/SaveButton";
-import Helper from "components/values-form/Helper";
-import FormWrapper from "components/values-form/FormWrapper";
+import Helper from "components/form-components/Helper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import Selector from "components/Selector";
 import Loading from "components/Loading";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -138,27 +138,30 @@ class SettingsPage extends Component<PropsType, StateType> {
         onSubmit,
       } = this.props;
       return (
-        <>
+        <FadeWrapper>
           <Heading>Additional Settings</Heading>
           <Helper>
             Configure additional settings for this template. (Optional)
           </Helper>
-          <FormWrapper
+          <PorterFormWrapper
             formData={form}
             saveValuesStatus={saveValuesStatus}
-            valuesToOverride={valuesToOverride}
-            clearValuesToOverride={clearValuesToOverride}
-            externalValues={{
+            valuesToOverride={{
+              ...valuesToOverride,
               namespace: selectedNamespace,
               clusterId: this.context.currentCluster.id,
-              isLaunch: true,
             }}
+            //externalValues={{
+            //  isLaunch: true,
+            //}}
             isReadOnly={
               !this.props.isAuthorized("namespace", "", ["get", "create"])
             }
-            onSubmit={onSubmit}
+            onSubmit={(val) => {
+              onSubmit(val);
+            }}
           />
-        </>
+        </FadeWrapper>
       );
     } else {
       return (
@@ -359,6 +362,10 @@ const Link = styled.a`
   margin-left: 5px;
 `;
 
+const FadeWrapper = styled.div`
+  animation: fadeIn 0.25s 0s;
+`;
+
 const Wrapper = styled.div`
   width: 100%;
   position: relative;

+ 4 - 8
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -7,8 +7,8 @@ import close from "assets/close.png";
 import { isAlphanumeric } from "shared/common";
 import { pushFiltered } from "shared/routing";
 
-import InputRow from "components/values-form/InputRow";
-import Helper from "components/values-form/Helper";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
 import ImageSelector from "components/image-selector/ImageSelector";
 import ActionConfEditor from "components/repo-selector/ActionConfEditor";
 import SaveButton from "components/SaveButton";
@@ -174,12 +174,8 @@ class SourcePage extends Component<PropsType, StateType> {
           setProcfileProcess={(procfileProcess: string) => {
             setProcfileProcess(procfileProcess);
             setValuesToOverride({
-              "container.command": {
-                value: procfileProcess || "",
-              },
-              showStartCommand: {
-                value: !procfileProcess,
-              },
+              "container.command": procfileProcess || "",
+              showStartCommand: !procfileProcess,
             });
           }}
           setBranch={setBranch}

+ 2 - 2
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -7,8 +7,8 @@ import github from "assets/github.png";
 import { Context } from "../../../shared/Context";
 import api from "../../../shared/api";
 import Loading from "../../../components/Loading";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 
 import TabSelector from "components/TabSelector";
 

+ 1 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -6,7 +6,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import SaveButton from "components/SaveButton";
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 
 const DeleteNamespaceModal = () => {
   const {

+ 1 - 1
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -8,7 +8,7 @@ import { Context } from "shared/Context";
 
 import Loading from "components/Loading";
 import SaveButton from "components/SaveButton";
-import { KeyValue } from "components/values-form/KeyValueArray";
+import { KeyValue } from "components/form-components/KeyValueArray";
 import {
   EnvGroupData,
   formattedEnvironmentValue,

+ 1 - 1
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -6,7 +6,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import SaveButton from "components/SaveButton";
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 
 type PropsType = {};
 

+ 1 - 1
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -7,7 +7,7 @@ import { Context } from "shared/Context";
 import { pushFiltered } from "shared/routing";
 
 import SaveButton from "components/SaveButton";
-import InputRow from "components/values-form/InputRow";
+import InputRow from "components/form-components/InputRow";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import { RouteComponentProps, withRouter } from "react-router";
 

+ 2 - 2
dashboard/src/main/home/new-project/NewProject.tsx

@@ -5,8 +5,8 @@ import gradient from "assets/gradient.png";
 import { Context } from "shared/Context";
 import { isAlphanumeric } from "shared/common";
 
-import InputRow from "components/values-form/InputRow";
-import Helper from "components/values-form/Helper";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
 import ProvisionerSettings from "../provisioner/ProvisionerSettings";
 import TitleSection from "components/TitleSection";
 

+ 3 - 3
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -12,9 +12,9 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 
 import Loading from "components/Loading";
-import InputRow from "components/values-form/InputRow";
-import Helper from "components/values-form/Helper";
-import Heading from "components/values-form/Heading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
 import { Column } from "react-table";
 import Table from "components/Table";

+ 3 - 3
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -5,8 +5,8 @@ import { Context } from "shared/Context";
 
 import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
-import Heading from "components/values-form/Heading";
-import Helper from "components/values-form/Helper";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
 import TitleSection from "components/TitleSection";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 
@@ -117,7 +117,7 @@ const Warning = styled.div`
 `;
 
 const StyledProjectSettings = styled.div`
-  width: 83%;
+  width: calc(85%);
   min-width: 300px;
   height: 100vh;
 `;

+ 6 - 6
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -8,13 +8,13 @@ import { Context } from "shared/Context";
 import { InfraType, ProjectType } from "shared/types";
 import { pushQueryParams, pushFiltered } from "shared/routing";
 
-import SelectRow from "components/values-form/SelectRow";
-import InputRow from "components/values-form/InputRow";
-import CheckboxRow from "components/values-form/CheckboxRow";
-import Helper from "components/values-form/Helper";
-import Heading from "components/values-form/Heading";
+import SelectRow from "components/form-components/SelectRow";
+import InputRow from "components/form-components/InputRow";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
-import CheckboxList from "components/values-form/CheckboxList";
+import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 
 type PropsType = RouteComponentProps & {

+ 6 - 6
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -8,13 +8,13 @@ import { Context } from "shared/Context";
 import { InfraType, ProjectType } from "shared/types";
 import { pushQueryParams } from "shared/routing";
 
-import InputRow from "components/values-form/InputRow";
-import CheckboxRow from "components/values-form/CheckboxRow";
-import SelectRow from "components/values-form/SelectRow";
-import Helper from "components/values-form/Helper";
-import Heading from "components/values-form/Heading";
+import InputRow from "components/form-components/InputRow";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import SelectRow from "components/form-components/SelectRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
-import CheckboxList from "components/values-form/CheckboxList";
+import CheckboxList from "components/form-components/CheckboxList";
 
 type PropsType = {
   setSelectedProvisioner: (x: string | null) => void;

+ 7 - 7
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -8,14 +8,14 @@ import { Context } from "shared/Context";
 import { InfraType, ProjectType } from "shared/types";
 import { pushQueryParams, pushFiltered } from "shared/routing";
 
-import UploadArea from "components/values-form/UploadArea";
-import SelectRow from "components/values-form/SelectRow";
-import CheckboxRow from "components/values-form/CheckboxRow";
-import InputRow from "components/values-form/InputRow";
-import Helper from "components/values-form/Helper";
-import Heading from "components/values-form/Heading";
+import UploadArea from "components/form-components/UploadArea";
+import SelectRow from "components/form-components/SelectRow";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
 import SaveButton from "components/SaveButton";
-import CheckboxList from "components/values-form/CheckboxList";
+import CheckboxList from "components/form-components/CheckboxList";
 import { RouteComponentProps, withRouter } from "react-router";
 
 type PropsType = RouteComponentProps & {

+ 1 - 1
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -5,7 +5,7 @@ import { Context } from "shared/Context";
 import { integrationList } from "shared/common";
 import { InfraType } from "shared/types";
 
-import Helper from "components/values-form/Helper";
+import Helper from "components/form-components/Helper";
 import AWSFormSection from "./AWSFormSection";
 import GCPFormSection from "./GCPFormSection";
 import DOFormSection from "./DOFormSection";

+ 70 - 0
docs/developing/forms.md

@@ -0,0 +1,70 @@
+So far this doc is for internal use. Once this PR is ready to be merged in it should be updated to have more info. But, 
+since some things are likely to change this is 
+
+# Outline
+
+The main idea with this refactor is to separate the form logic from the rendering logic.
+For this reason forms are split into two components. The first one is `PorterFormContextProvider`,
+which provides a context that the second component `PorterForm` subscribes to using a custom hook.
+This relationship should be kept in mind when adding new functionality to this system: logic and rendering must be 
+separated between these components.
+
+# Custom Hook
+
+In general, a form field is determined by three factors - the current variables of the form, the props that are assigned to it
+in the form YAML and its internal state. To implement this idea, once rendered, a form field subscribes to the context using the `useFormField` hook. 
+To see how this hook works, check out this example:
+```typescript
+const { state, variables, setVars, setState, setValidation } = useFormField<FieldState>(
+    props.id,
+    {
+      initState: {},
+      initValidation: {
+        validated: !props.required,
+      },
+      initVars: {},
+    }
+  );
+```
+The hook takes in two arguments - an id (these are automatically assigned by the context and passed through a prop) and
+a dictionary that describes the intial values for its state, validation and any variables that need to be set for the form.
+Note that these are only set once per form lifecycle (the field component can unmount and mount as much as it wants).
+The hook recieves the state of the field (according to the id), a list of all the variables in the form and three functions to 
+change these values and its validation. Note that all of these functions work by taking in state update functions. So, for example,
+if one wanted to add a new variable "foo" to the form with value "bar", they could do:
+```typescript
+setVars((vars) => {
+    return {
+        ...vars,
+        foo: "bar"
+    }
+})
+```
+And similarly with the other two functions. Also note that state should be used in order to handle component-specific 
+state data. If some kind of data needs to be shared between components, a variable can be used to do that (for example, input does this).
+Finally, the `state` returned by the hook is not guaranteed exist, so a null check for that is needed in every component.
+
+# Exporting Data
+
+In addition to the component that renders the field, each field file can also have a function that applies a modification
+to form variables once the form is submitted. For example, this can be used when a field has a unit setting and one does
+not want to store the field value with the unit attached in the form state. The function takes in the variables of the form, 
+the props of the field, and the fields state on submission. Here's an example used in the input field:
+
+```typescript
+export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
+  vars,
+  props: InputField
+) => {
+  if (vars[props.variable])
+    return {
+      [props.variable]:
+        props.settings?.unit && !props.settings?.omitUnitFromValue
+          ? vars[props.variable] + props.settings.unit
+          : vars[props.variable],
+    };
+  return {
+    [props.variable]: props.settings?.default,
+  };
+};
+```

+ 7 - 6
internal/models/templates.go

@@ -66,10 +66,11 @@ type FormContent struct {
 
 // FormYAML represents a chart's values.yaml form abstraction
 type FormYAML struct {
-	Name        string     `yaml:"name" json:"name"`
-	Icon        string     `yaml:"icon" json:"icon"`
-	HasSource   string     `yaml:"hasSource" json:"hasSource"`
-	Description string     `yaml:"description" json:"description"`
-	Tags        []string   `yaml:"tags" json:"tags"`
-	Tabs        []*FormTab `yaml:"tabs" json:"tabs,omitempty"`
+	Name                string     `yaml:"name" json:"name"`
+	Icon                string     `yaml:"icon" json:"icon"`
+	HasSource           string     `yaml:"hasSource" json:"hasSource"`
+	IncludeHiddenFields string     `yaml:"includeHiddenFields,omitempty" json:"includeHiddenFields,omitempty"`
+	Description         string     `yaml:"description" json:"description"`
+	Tags                []string   `yaml:"tags" json:"tags"`
+	Tabs                []*FormTab `yaml:"tabs" json:"tabs,omitempty"`
 }