Преглед изворни кода

Stacks unsaved changes banner (#3105)

Feroze Mohideen пре 2 година
родитељ
комит
119a0fe54c

+ 3 - 0
dashboard/src/assets/save-01.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.60002 20.3999V14.9999C6.60002 14.3372 7.13728 13.7999 7.80002 13.7999H16.2C16.8628 13.7999 17.4 14.3372 17.4 14.9999V20.9999M15 7.1999L7.80002 7.1999C7.13728 7.1999 6.60002 6.66264 6.60002 5.9999L6.60002 2.3999M20.9975 6.59737L17.4026 3.00243C17.0168 2.61664 16.4935 2.39991 15.9479 2.3999H4.45717C3.32102 2.3999 2.40002 3.3209 2.40002 4.45705V19.5428C2.40002 20.6789 3.32102 21.5999 4.45717 21.5999H19.5429C20.679 21.5999 21.6 20.6789 21.6 19.5428V8.05199C21.6 7.5064 21.3833 6.98316 20.9975 6.59737Z" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 162 - 125
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -7,10 +7,12 @@ import { z } from "zod";
 import notFound from "assets/not-found.png";
 import web from "assets/web.png";
 import box from "assets/box.png";
-import github from "assets/github.png";
+import github from "assets/github-white.png";
 import pr_icon from "assets/pull_request_icon.svg";
 import loadingImg from "assets/loading.gif";
 import refresh from "assets/refresh.png";
+import deploy from "assets/deploy.png";
+import save from "assets/save-01.svg";
 import danger from "assets/danger.svg";
 
 import api from "shared/api";
@@ -27,6 +29,7 @@ import Spacer from "components/porter/Spacer";
 import Link from "components/porter/Link";
 import Back from "components/porter/Back";
 import TabSelector from "components/TabSelector";
+import Icon from "components/porter/Icon";
 import { ChartType, PorterAppOptions, ResourceType } from "shared/types";
 import RevisionSection from "main/home/cluster-dashboard/expanded-chart/RevisionSection";
 import BuildSettingsTabStack from "./BuildSettingsTabStack";
@@ -51,10 +54,9 @@ import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
 import Anser, { AnserJsonEntry } from "anser";
-import dayjs from "dayjs";
-import Modal from "components/porter/Modal";
-import TitleSection from "components/TitleSection";
 import GHALogsModal from "./status/GHALogsModal";
+import _ from "lodash";
+import AnimateHeight from "react-animate-height";
 
 type Props = RouteComponentProps & {};
 
@@ -82,32 +84,32 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
     false
   );
-  const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
 
   const [tab, setTab] = useState("overview");
-  const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
+  const [saveValuesStatus, setSaveValueStatus] = useState<string>("");
   const [loading, setLoading] = useState<boolean>(false);
   const [bannerLoading, setBannerLoading] = useState<boolean>(false);
 
-  const [components, setComponents] = useState<ResourceType[]>([]);
-
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
   const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
-  const [porterJson, setPorterJson] = useState<
-    z.infer<typeof PorterYamlSchema> | undefined
-  >(undefined);
+
+  // this is what we read from their porter.yaml in github
+  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
+  // this is what we use to update the release. the above is a subset of this
+  const [porterYaml, setPorterYaml] = useState<PorterJson>({} as PorterJson);
+  const [showUnsavedChangesBanner, setShowUnsavedChangesBanner] = useState<boolean>(false);
+
   const [expandedJob, setExpandedJob] = useState(null);
-  const [logs, setLogs] = useState<Log[]>(null);
+  const [logs, setLogs] = useState<Log[]>([]);
   const [modalVisible, setModalVisible] = useState(false);
 
   const [services, setServices] = useState<Service[]>([]);
-  const [releaseJob, setReleaseJob] = useState<ReleaseService[]>([]);
   const [envVars, setEnvVars] = useState<KeyValueType[]>([]);
   const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
   const [subdomain, setSubdomain] = useState<string>("");
 
+
   const getPorterApp = async () => {
-    // setIsLoading(true);
     setBannerLoading(true);
     const { appName } = props.match.params as any;
     try {
@@ -167,11 +169,19 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
       setPorterJson(porterJson);
       setAppData(newAppData);
-      updateServicesAndEnvVariables(
+      const [newServices, newEnvVars] = updateServicesAndEnvVariables(
         resChartData?.data,
         releaseChartData?.data,
-        porterJson
+        porterJson,
+      );
+      const finalPorterYaml = createFinalPorterYaml(
+        newServices,
+        newEnvVars,
+        porterJson,
+        // if we are using a heroku buildpack, inject a PORT env variable
+        newAppData.app.builder != null && newAppData.app.builder.includes("heroku")
       );
+      setPorterYaml(finalPorterYaml);
 
       // Only check GHA status if no built image is set
       const hasBuiltImage = !!resChartData.data.config?.global?.image
@@ -270,7 +280,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       ) {
         const finalPorterYaml = createFinalPorterYaml(
           services,
-          releaseJob,
           envVars,
           porterJson,
           // if we are using a heroku buildpack, inject a PORT env variable
@@ -291,7 +300,9 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             stack_name: appData.app.name,
           }
         );
+        setPorterYaml(finalPorterYaml);
         setButtonStatus("success");
+        setShowUnsavedChangesBanner(false);
       } else {
         setButtonStatus(<Error message="Unable to update app" />);
       }
@@ -425,29 +436,31 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           break;
       }
     }
-    return <Icon src={src} />;
+    return <Icon src={src} height={"24px"} />;
   };
 
-  const updateServicesAndEnvVariables = async (
+  const updateServicesAndEnvVariables = (
     currentChart?: ChartType,
     releaseChart?: ChartType,
-    porterJson?: PorterJson
-  ) => {
+    porterJson?: PorterJson,
+  ): [Service[], KeyValueType[]] => {
     // handle normal chart
     const helmValues = currentChart?.config;
     const defaultValues = (currentChart?.chart as any)?.values;
+    let newServices: Service[] = [];
+    let envVars: KeyValueType[] = [];
+
     if (
       (defaultValues && Object.keys(defaultValues).length > 0) ||
       (helmValues && Object.keys(helmValues).length > 0)
     ) {
-      const svcs = Service.deserialize(helmValues, defaultValues, porterJson);
-      setServices(svcs);
+      newServices = Service.deserialize(helmValues, defaultValues, porterJson);
       const { global, ...helmValuesWithoutGlobal } = helmValues;
       if (Object.keys(helmValuesWithoutGlobal).length > 0) {
-        const envs = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
-        setEnvVars(envs);
+        envVars = Service.retrieveEnvFromHelmValues(helmValuesWithoutGlobal);
+        setEnvVars(envVars);
         const subdomain = Service.retrieveSubdomainFromHelmValues(
-          svcs,
+          newServices,
           helmValuesWithoutGlobal
         );
         setSubdomain(subdomain);
@@ -456,98 +469,87 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
 
     // handle release chart
     if (releaseChart?.config || porterJson?.release) {
-      setReleaseJob([
-        Service.deserializeRelease(releaseChart?.config, porterJson),
-      ]);
+      const release = Service.deserializeRelease(releaseChart?.config, porterJson);
+      newServices.push(release);
     }
+
+    setServices(newServices);
+
+    return [newServices, envVars];
   };
 
-  // todo: keep a history of the release job chart, difficult because they can be upgraded asynchronously
-  const updateComponents = async (currentChart: ChartType) => {
-    setLoading(true);
+  const getChartData = async (chart: ChartType, isCurrent?: boolean) => {
+    setButtonStatus("");
     try {
-      const res = await api.getChartComponents(
+      const res = await api.getChart(
         "<token>",
         {},
         {
-          id: currentProject.id,
-          name: currentChart.name,
-          namespace: currentChart.namespace,
+          name: chart.name,
+          namespace: chart.namespace,
           cluster_id: currentCluster.id,
-          revision: currentChart.version,
+          revision: chart.version,
+          id: currentProject.id,
         }
       );
-      setComponents(res.data.Objects);
-      updateServicesAndEnvVariables(currentChart, undefined, porterJson);
-      setLoading(false);
-    } catch (error) {
-      console.log(error);
-      setLoading(false);
-    }
-  };
 
-  const getChartData = async (chart: ChartType) => {
-    setIsLoadingChartData(true);
-    const res = await api.getChart(
-      "<token>",
-      {},
-      {
-        name: chart.name,
-        namespace: chart.namespace,
-        cluster_id: currentCluster.id,
-        revision: chart.version,
-        id: currentProject.id,
+      const updatedChart = res.data;
+
+      if (appData != null && updatedChart != null) {
+        setAppData({ ...appData, chart: updatedChart });
       }
-    );
 
-    const updatedChart = res.data;
+      // let releaseChartData;
+      // // get the release chart
+      // try {
+      //   releaseChartData = await api.getChart(
+      //     "<token>",
+      //     {},
+      //     {
+      //       id: currentProject.id,
+      //       namespace: `porter-stack-${chart.name}`,
+      //       cluster_id: currentCluster.id,
+      //       name: `${chart.name}-r`,
+      //       revision: 0,
+      //     }
+      //   );
+      // } catch (err) {
+      //   // do nothing, unable to find release chart
+      //   // console.log(err);
+      // }
+
+      // const releaseChart = releaseChartData?.data;
+
+      // if (appData != null && updatedChart != null) {
+      //   if (releaseChart != null) {
+      //     setAppData({ ...appData, chart: updatedChart, releaseChart });
+      //   } else {
+      //     setAppData({ ...appData, chart: updatedChart });
+      //   }
+      // }
+
+      const [newServices, newEnvVars] = updateServicesAndEnvVariables(
+        updatedChart,
+        appData.releaseChart,
+        porterJson,
+        appData.app.builder != null && appData.app.builder.includes("heroku")
+      );
 
-    if (appData != null && updatedChart != null) {
-      setAppData({ ...appData, chart: updatedChart });
+      if (isCurrent) {
+        setShowUnsavedChangesBanner(false);
+      } else {
+        onAppUpdate(newServices, newEnvVars);
+      }
+    } catch (err) {
+      console.log(err);
+    } finally {
+      setLoading(false);
     }
 
-    // let releaseChartData;
-    // // get the release chart
-    // try {
-    //   releaseChartData = await api.getChart(
-    //     "<token>",
-    //     {},
-    //     {
-    //       id: currentProject.id,
-    //       namespace: `porter-stack-${chart.name}`,
-    //       cluster_id: currentCluster.id,
-    //       name: `${chart.name}-r`,
-    //       revision: 0,
-    //     }
-    //   );
-    // } catch (err) {
-    //   // do nothing, unable to find release chart
-    //   console.log(err);
-    // }
-
-    // const releaseChart = releaseChartData?.data;
-
-    // if (appData != null && updatedChart != null) {
-    //   if (releaseChart != null) {
-    //     setAppData({ ...appData, chart: updatedChart, releaseChart });
-    //   } else {
-    //     setAppData({ ...appData, chart: updatedChart });
-    //   }
-    // }
-
-    updateComponents(updatedChart).finally(() => setIsLoadingChartData(false));
   };
 
   const setRevision = (chart: ChartType, isCurrent?: boolean) => {
-    // // if we've set the revision, we also override the revision in log data
-    // let newLogData = logData;
-
-    // newLogData.revision = `${chart.version}`;
-
-    // setLogData(newLogData);
-
-    // setIsPreview(!isCurrent);
-    getChartData(chart);
+    getChartData(chart, isCurrent);
   };
 
   const appUpgradeVersion = useCallback(
@@ -623,6 +625,23 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     return `${time} on ${date}`;
   };
 
+  const onAppUpdate = (services: Service[], envVars: KeyValueType[]) => {
+    const newPorterYaml = createFinalPorterYaml(
+      services,
+      envVars,
+      porterJson,
+      // if we are using a heroku buildpack, inject a PORT env variable
+      appData.app.builder != null && appData.app.builder.includes("heroku")
+    );
+    if (!_.isEqual(porterYaml, newPorterYaml)) {
+      setShowUnsavedChangesBanner(true);
+    } else {
+      setShowUnsavedChangesBanner(false);
+    }
+    // console.log("old porter yaml", porterYaml);
+    // console.log("new porter yaml", newPorterYaml);
+  };
+
   const renderTabContents = () => {
     switch (tab) {
       case "overview":
@@ -634,26 +653,21 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 <Text size={16}>Pre-deploy job</Text>
                 <Spacer y={0.5} />
                 <Services
-                  setServices={(x) => {
+                  setServices={(release: Service[]) => {
                     if (buttonStatus !== "") {
                       setButtonStatus("");
                     }
-                    setReleaseJob(x as ReleaseService[]);
+                    const nonRelease = services.filter(Service.isNonRelease)
+                    const newServices = [...nonRelease, ...release]
+                    setServices(newServices)
+                    onAppUpdate(newServices, envVars)
                   }}
                   chart={appData.releaseChart}
-                  services={releaseJob}
+                  services={services.filter(Service.isRelease)}
                   limitOne={true}
-                  customOnClick={() => {
-                    setReleaseJob([
-                      Service.default(
-                        "pre-deploy",
-                        "release",
-                        porterJson
-                      ) as ReleaseService,
-                    ]);
-                  }}
+                  prePopulateService={Service.default("pre-deploy", "release", porterJson)}
                   addNewText={"Add a new pre-deploy job"}
-                  defaultExpanded={true}
+                  defaultExpanded={false}
                 />
                 <Spacer y={0.5} />
               </>
@@ -672,14 +686,17 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
               </>
             )}
             <Services
-              setServices={(x) => {
+              setServices={(svcs: Service[]) => {
                 if (buttonStatus !== "") {
                   setButtonStatus("");
                 }
-                setServices(x);
+                const release = services.filter(Service.isRelease)
+                const newServices = [...svcs, ...release]
+                setServices(newServices);
+                onAppUpdate(newServices, envVars);
               }}
+              services={services.filter(Service.isNonRelease)}
               chart={appData.chart}
-              services={services}
               addNewText={"Add a new service"}
               setExpandedJob={(x: string) => setExpandedJob(x)}
             />
@@ -743,7 +760,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return (
           <EnvVariablesTab
             envVars={envVars}
-            setEnvVars={setEnvVars}
+            setEnvVars={(envVars: KeyValueType[]) => {
+              setEnvVars(envVars);
+              onAppUpdate(services, envVars.filter((e) => e.key !== "" || e.value !== ""));
+            }}
             status={buttonStatus}
             updatePorterApp={updatePorterApp}
             clearStatus={() => setButtonStatus("")}
@@ -752,7 +772,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
       case "pre-deploy":
         return (
           <>
-            {!isLoading && releaseJob.length === 0 && (
+            {!isLoading && !services.some(Service.isRelease) && (
               <>
                 <Fieldset>
                   <Container row>
@@ -760,14 +780,14 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                     <Text color="helper">
                       No pre-deploy jobs were found. You can add a pre-deploy job in the Overview tab to
                       perform an operation before your application services
-                      deploy, like a database migration.
+                      deploy each time, like a database migration.
                     </Text>
                   </Container>
                 </Fieldset>
                 <Spacer y={0.5} />
               </>
             )}
-            {releaseJob.length > 0 && (
+            {services.some(Service.isRelease) && (
               <JobRuns
                 lastRunStatus="all"
                 namespace={appData.chart?.namespace}
@@ -813,6 +833,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           <Back to="/apps" />
           <Container row>
             {renderIcon(appData.app?.build_packs)}
+            <Spacer inline x={0.5} />
             <Text size={21}>{appData.app.name}</Text>
             {appData.app.repo_name && (
               <>
@@ -976,7 +997,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       setShowRevisions(!showRevisions);
                     }}
                     chart={appData.chart}
-                    refreshChart={() => getChartData(appData.chart)}
                     setRevision={setRevision}
                     forceRefreshRevisions={forceRefreshRevisions}
                     refreshRevisionsOff={() => setForceRefreshRevisions(false)}
@@ -992,6 +1012,28 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 </>
               )}
               <Spacer y={1} />
+              <AnimateHeight height={showUnsavedChangesBanner ? 67 : 0}>
+                <Banner
+                  type="warning"
+                  suffix={
+                    <>
+                      <Button
+                        onClick={async () => await updatePorterApp({})}
+                        status={buttonStatus}
+                        loadingText={"Updating..."}
+                        height={"10px"}
+                      >
+                        <Icon src={save} height={"13px"} />
+                        <Spacer inline x={0.5} />
+                        Save as latest version
+                      </Button>
+                    </>
+                  }
+                >
+                  Changes you are currently previewing have not been saved.
+                  <Spacer inline width="5px" />
+                </Banner>
+              </AnimateHeight>
               <TabSelector
                 options={
                   [
@@ -1149,11 +1191,6 @@ const BranchIcon = styled.img`
   margin-right: 5px;
 `;
 
-const Icon = styled.img`
-  height: 24px;
-  margin-right: 15px;
-`;
-
 const PlaceholderIcon = styled.img`
   height: 13px;
   margin-right: 12px;

+ 14 - 37
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -51,7 +51,6 @@ interface FormState {
   applicationName: string;
   selectedSourceType: SourceType | undefined;
   serviceList: Service[];
-  releaseJob: ReleaseService[];
   envVariables: KeyValueType[];
   releaseCommand: string;
 }
@@ -60,7 +59,6 @@ const INITIAL_STATE: FormState = {
   applicationName: "",
   selectedSourceType: undefined,
   serviceList: [],
-  releaseJob: [],
   envVariables: [],
   releaseCommand: "",
 };
@@ -73,7 +71,6 @@ const Validators: {
   serviceList: (value: Service[]) => value.length > 0,
   envVariables: (value: KeyValueType[]) => true,
   releaseCommand: (value: string) => true,
-  releaseJob: (value: ReleaseService[]) => true,
 };
 
 type Detected = {
@@ -135,9 +132,7 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
   const [currentProvider, setCurrentProvider] = useState(null);
   const [hasProviders, setHasProviders] = useState(true);
 
-  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(
-    undefined
-  );
+  const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
   const [detected, setDetected] = useState<Detected | undefined>(undefined);
   const handleSetAccessData = (data: GithubAppAccessData) => {
     setAccessData(data);
@@ -184,7 +179,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       const porterYamlToJson = parsedData as PorterJson;
       setPorterJson(porterYamlToJson);
       const newServices = [];
-      const newReleaseJob = [];
       const existingServices = formState.serviceList.map((s) => s.name);
       for (const [name, app] of Object.entries(porterYamlToJson.apps)) {
         if (!existingServices.includes(name)) {
@@ -197,21 +191,13 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
           }
         }
       }
-      if (!formState.releaseJob.length && porterYamlToJson.release != null) {
-        newReleaseJob.push(
-          Service.default(
-            "pre-deploy",
-            "release",
-            porterYamlToJson
-          ) as ReleaseService
-        );
+      if (porterYamlToJson.release != null && !existingServices.includes("pre-deploy")) {
+        newServices.push(Service.default("pre-deploy", "release", porterYamlToJson));
       }
       const newServiceList = [...formState.serviceList, ...newServices];
-      const newReleaseJobList = [...formState.releaseJob, ...newReleaseJob];
       setFormState({
         ...formState,
         serviceList: newServiceList,
-        releaseJob: newReleaseJobList,
       });
       if (Validators.serviceList(newServiceList)) {
         setCurrentStep(Math.max(currentStep, 5));
@@ -331,7 +317,6 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
       // validate form data
       const finalPorterYaml = createFinalPorterYaml(
         formState.serviceList,
-        formState.releaseJob,
         formState.envVariables,
         porterJson,
         // if we are using a heroku buildpack, inject a PORT env variable
@@ -535,12 +520,13 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Spacer y={0.5} />
                 <Services
                   setServices={(services: Service[]) => {
-                    setFormState({ ...formState, serviceList: services });
+                    const release = formState.serviceList.filter(Service.isRelease)
+                    setFormState({ ...formState, serviceList: [...services, ...release] });
                     if (Validators.serviceList(services)) {
                       setCurrentStep(Math.max(currentStep, 5));
                     }
                   }}
-                  services={formState.serviceList}
+                  services={formState.serviceList.filter(Service.isNonRelease)}
                   defaultExpanded={true}
                   addNewText={"Add a new service"}
                 />
@@ -564,30 +550,21 @@ const NewAppFlow: React.FC<Props> = ({ ...props }) => {
                 <Text size={16}>Pre-deploy job (optional)</Text>
                 <Spacer y={0.5} />
                 <Text color="helper">
-                  If specified, this is a job that will be run before every
-                  deployment.
+                  You may add a pre-deploy job to
+                  perform an operation before your application services
+                  deploy each time, like a database migration.
                 </Text>
                 <Spacer y={0.5} />
                 <Services
-                  setServices={(releaseJob: ReleaseService[]) => {
-                    setFormState({ ...formState, releaseJob });
+                  setServices={(release: Service[]) => {
+                    const nonRelease = formState.serviceList.filter(Service.isNonRelease)
+                    setFormState({ ...formState, serviceList: [...nonRelease, ...release] });
                   }}
-                  services={formState.releaseJob}
+                  services={formState.serviceList.filter(Service.isRelease)}
                   defaultExpanded={true}
                   limitOne={true}
-                  customOnClick={() => {
-                    setFormState({
-                      ...formState,
-                      releaseJob: [
-                        Service.default(
-                          "pre-deploy",
-                          "release",
-                          porterJson
-                        ) as ReleaseService,
-                      ],
-                    });
-                  }}
                   addNewText={"Add a new pre-deploy job"}
+                  prePopulateService={Service.default("pre-deploy", "release", porterJson)}
                 />
               </>,
               <Button

+ 1 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/ServiceContainer.tsx

@@ -12,7 +12,6 @@ import WebTabs from "./WebTabs";
 import WorkerTabs from "./WorkerTabs";
 import JobTabs from "./JobTabs";
 import { Service } from "./serviceTypes";
-import { StyledStatusFooter } from "../expanded-app/StatusFooter";
 import StatusFooter from "../expanded-app/StatusFooter";
 import ReleaseTabs from "./ReleaseTabs";
 
@@ -216,7 +215,7 @@ const ServiceHeader = styled.div<{
     border-radius: 20px;
     margin-left: -10px;
     transform: ${(props: { showExpanded: boolean; chart: any }) =>
-      props.showExpanded ? "" : "rotate(-90deg)"};
+    props.showExpanded ? "" : "rotate(-90deg)"};
   }
 
   animation: fadeIn 0.3s 0s;

+ 22 - 20
dashboard/src/main/home/app-dashboard/new-app-flow/Services.tsx

@@ -21,9 +21,8 @@ interface ServicesProps {
   defaultExpanded?: boolean;
   chart?: any;
   limitOne?: boolean;
-  customOnClick?: () => void;
+  prePopulateService?: Service;
   setExpandedJob?: (x: string) => void;
-  onUpdate?: () => void;
 }
 
 const Services: React.FC<ServicesProps> = ({
@@ -33,9 +32,8 @@ const Services: React.FC<ServicesProps> = ({
   chart,
   defaultExpanded = false,
   limitOne = false,
-  customOnClick,
   setExpandedJob,
-  onUpdate = () => ({}),
+  prePopulateService,
 }) => {
   const [showAddServiceModal, setShowAddServiceModal] = useState<boolean>(
     false
@@ -72,13 +70,16 @@ const Services: React.FC<ServicesProps> = ({
       <>
         <AddServiceButton
           onClick={() => {
-            if (customOnClick != null) {
-              customOnClick();
-              return;
+            if (prePopulateService == null) {
+              setShowAddServiceModal(true);
+              setServiceType("web");
+            } else {
+              const newServices = [
+                ...services,
+                prePopulateService,
+              ]
+              setServices(newServices);
             }
-            setShowAddServiceModal(true);
-            setServiceType("web");
-            onUpdate();
           }}
         >
           <i className="material-icons add-icon">add_icon</i>
@@ -100,14 +101,14 @@ const Services: React.FC<ServicesProps> = ({
                 setExpandedJob={setExpandedJob}
                 service={service}
                 chart={chart}
-                editService={(newService: Service) =>
-                  setServices(
-                    services.map((s, i) => (i === index ? newService : s))
-                  )
-                }
-                deleteService={() =>
-                  setServices(services.filter((_, i) => i !== index))
-                }
+                editService={(newService: Service) => {
+                  const newServices = services.map((s, i) => (i === index ? newService : s));
+                  setServices(newServices);
+                }}
+                deleteService={() => {
+                  const newServices = services.filter((_, i) => i !== index);
+                  setServices(newServices);
+                }}
                 defaultExpanded={defaultExpanded}
               />
             );
@@ -151,10 +152,11 @@ const Services: React.FC<ServicesProps> = ({
           <Spacer y={1} />
           <Button
             onClick={() => {
-              setServices([
+              const newServices = [
                 ...services,
                 Service.default(serviceName, serviceType),
-              ]);
+              ]
+              setServices(newServices);
               setShowAddServiceModal(false);
               setServiceName("");
               setServiceType("web");

+ 0 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/WebTabs.tsx

@@ -6,8 +6,6 @@ import TabSelector from "components/TabSelector";
 import Checkbox from "components/porter/Checkbox";
 import { WebService } from "./serviceTypes";
 import AnimateHeight, { Height } from "react-animate-height";
-import styled from "styled-components";
-import ExpandableSection from "components/porter/ExpandableSection";
 
 interface Props {
   service: WebService;

+ 4 - 4
dashboard/src/main/home/app-dashboard/new-app-flow/WorkerTabs.tsx

@@ -8,15 +8,15 @@ import { WorkerService } from "./serviceTypes";
 import { Height } from "react-animate-height";
 
 interface Props {
-  service: WorkerService
-  editService: (service: WorkerService) => void
-  setHeight: (height: Height) => void
+  service: WorkerService;
+  editService: (service: WorkerService) => void;
+  setHeight: (height: Height) => void;
 }
 
 const WorkerTabs: React.FC<Props> = ({
   service,
   editService,
-  setHeight
+  setHeight,
 }) => {
   const [currentTab, setCurrentTab] = React.useState<string>('main');
 

+ 18 - 9
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -45,7 +45,6 @@ export const PorterYamlSchema = z.object({
 
 export const createFinalPorterYaml = (
     services: Service[],
-    releaseJob: Service[],
     dashboardSetEnvVariables: KeyValueType[],
     porterJson: PorterJson | undefined,
     injectPortEnvVariable: boolean = false,
@@ -58,11 +57,17 @@ export const createFinalPorterYaml = (
         env.PORT = port;
     }
 
-    return {
+    const release = services.find(Service.isRelease);
+
+    return release != null && release.startCommand.value.trim() != "" ? {
+        version: "v1stack",
+        env,
+        apps,
+        release: createRelease(release, porterJson),
+    } : {
         version: "v1stack",
         env,
         apps,
-        release: createRelease(releaseJob.find(Service.isRelease)),
     };
 };
 
@@ -118,16 +123,20 @@ const createApps = (
     return [apps, port];
 };
 
-const createRelease = (
-    release: ReleaseService | undefined,
-): z.infer<typeof appConfigSchema> => {
-    if (release == null) {
-        return {};
+const createRelease = (release: ReleaseService, porterJson: PorterJson | undefined): z.infer<typeof appConfigSchema> => {
+    let config = Service.serialize(release);
+
+    if (porterJson?.release?.config != null) {
+        config = overrideObjectValues(
+            config,
+            porterJson.release.config
+        );
     }
+
     return {
         type: 'job',
         run: release.startCommand.value,
-        config: Service.serialize(release),
+        config,
     }
 }