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

Merge branch 'nico/por-95-create-project-screen-separation' of github.com:porter-dev/porter into nico/por-96-integrate-github-app-installing-into

jnfrati 4 лет назад
Родитель
Сommit
74335ba008

+ 13 - 0
dashboard/package-lock.json

@@ -9185,6 +9185,11 @@
         "ipaddr.js": "1.9.1"
       }
     },
+    "proxy-compare": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.0.2.tgz",
+      "integrity": "sha512-3qUXJBariEj3eO90M3Rgqq3+/P5Efl0t/dl9g/1uVzIQmO3M+ql4hvNH3mYdu8H+1zcKv07YvL55tsY74jmH1A=="
+    },
     "prr": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -11456,6 +11461,14 @@
       "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==",
       "dev": true
     },
+    "valtio": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.2.4.tgz",
+      "integrity": "sha512-FipYZHGJXsSKObKNGOHwqqiA6T84T7LHhrPfM7ptt3e2uFao4djD5/u4JEb/z2O14fv1CFxIO05UWCuk3VT/qg==",
+      "requires": {
+        "proxy-compare": "2.0.2"
+      }
+    },
     "value-equal": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",

+ 2 - 1
dashboard/package.json

@@ -44,7 +44,8 @@
     "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "stacktrace-js": "^2.0.2",
-    "styled-components": "^5.2.0"
+    "styled-components": "^5.2.0",
+    "valtio": "^1.2.4"
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",

+ 8 - 2
dashboard/src/components/Button.tsx

@@ -5,11 +5,17 @@ interface Props {
   disabled?: boolean;
   children: React.ReactNode;
   onClick: () => void;
+  className?: string;
 }
 
-const Button: React.FC<Props> = ({ children, disabled, onClick }) => {
+const Button: React.FC<Props> = ({
+  children,
+  disabled,
+  onClick,
+  className,
+}) => {
   return (
-    <ButtonWrapper disabled={disabled} onClick={onClick}>
+    <ButtonWrapper className={className} disabled={disabled} onClick={onClick}>
       {children}
     </ButtonWrapper>
   );

+ 7 - 1
dashboard/src/components/SaveButton.tsx

@@ -16,6 +16,9 @@ type Props = {
   makeFlush?: boolean;
   clearPosition?: boolean;
   statusPosition?: "right" | "left";
+  // Provide the classname to modify styles from other components
+  className?: string;
+  successText?: string;
 };
 
 const SaveButton: React.FC<Props> = (props) => {
@@ -25,7 +28,9 @@ const SaveButton: React.FC<Props> = (props) => {
         return (
           <StatusWrapper position={props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
-            <StatusTextWrapper>Successfully updated</StatusTextWrapper>
+            <StatusTextWrapper>
+              {props?.successText || "Successfully updated"}
+            </StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (props.status === "loading") {
@@ -65,6 +70,7 @@ const SaveButton: React.FC<Props> = (props) => {
     <ButtonWrapper
       makeFlush={props.makeFlush}
       clearPosition={props.clearPosition}
+      className={props.className}
     >
       {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
       <Button

+ 2 - 0
dashboard/src/components/form-components/InputRow.tsx

@@ -14,6 +14,7 @@ type PropsType = {
   disabled?: boolean;
   isRequired?: boolean;
   className?: string;
+  maxLength?: number;
 };
 
 type StateType = {
@@ -74,6 +75,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             type={type}
             value={value}
             onChange={this.handleChange}
+            maxLength={this.props.maxLength}
           />
           {unit ? <Unit>{unit}</Unit> : null}
         </InputWrapper>

+ 48 - 137
dashboard/src/main/home/Home.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from "react";
-import { RouteComponentProps, withRouter } from "react-router";
+import { RouteComponentProps, Switch, withRouter } from "react-router";
 import styled from "styled-components";
 
 import api from "shared/api";
@@ -15,23 +15,18 @@ import Dashboard from "./dashboard/Dashboard";
 import WelcomeForm from "./WelcomeForm";
 import Integrations from "./integrations/Integrations";
 import Templates from "./launch/Launch";
-import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
-import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
-import IntegrationsModal from "./modals/IntegrationsModal";
-import Modal from "./modals/Modal";
-import UpdateClusterModal from "./modals/UpdateClusterModal";
-import NamespaceModal from "./modals/NamespaceModal";
+
 import Navbar from "./navbar/Navbar";
-import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
-import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+
 import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
-import AccountSettingsModal from "./modals/AccountSettingsModal";
 import discordLogo from "../../assets/discord.svg";
+import Onboarding from "./onboarding/Onboarding";
+import ModalHandler from "./ModalHandler";
+
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -337,7 +332,7 @@ class Home extends Component<PropsType, StateType> {
   renderContents = () => {
     let currentView = this.props.currentRoute;
 
-    if (this.context.currentProject && currentView !== "new-project") {
+    if (this.context.currentProject && currentView !== "onboarding") {
       if (
         currentView === "cluster-dashboard" ||
         currentView === "applications" ||
@@ -362,46 +357,8 @@ class Home extends Component<PropsType, StateType> {
         return <GuardedProjectSettings />;
       }
       return <Templates />;
-    } else if (currentView === "new-project") {
-      return <NewProject />;
-    }
-  };
-
-  renderSidebar = () => {
-    if (this.context.projects.length > 0) {
-      return (
-        <Sidebar
-          key="sidebar"
-          forceSidebar={this.state.forceSidebar}
-          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-          currentView={this.props.currentRoute}
-          forceRefreshClusters={this.state.forceRefreshClusters}
-          setRefreshClusters={(x: boolean) =>
-            this.setState({ forceRefreshClusters: x })
-          }
-        />
-      );
-    } else {
-      return (
-        <>
-          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-            <Icon src={discordLogo} />
-            Join Our Discord
-          </DiscordButton>
-          {this.state.showWelcomeForm &&
-            localStorage.getItem("welcomed") != "true" && (
-              <>
-                <WelcomeForm
-                  closeForm={() => this.setState({ showWelcomeForm: false })}
-                />
-                <Navbar
-                  logOut={this.props.logOut}
-                  currentView={this.props.currentRoute} // For form feedback
-                />
-              </>
-            )}
-        </>
-      );
+    } else if (currentView === "onboarding") {
+      return <Onboarding />;
     }
   };
 
@@ -469,94 +426,15 @@ class Home extends Component<PropsType, StateType> {
       setCurrentModal,
       currentProject,
       currentOverlay,
-      setCurrentOverlay,
+      projects,
     } = this.context;
 
+    const { cluster } = this.props.match.params as any;
     return (
       <StyledHome>
-        {currentModal === "ClusterInstructionsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="650px"
-          >
-            <ClusterInstructionsModal />
-          </Modal>
-        )}
-
-        {/* We should be careful, as this component is named Update but is for deletion */}
-        {this.props.isAuthorized("cluster", "", ["get", "delete"]) &&
-          currentModal === "UpdateClusterModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="565px"
-              height="275px"
-            >
-              <UpdateClusterModal
-                setRefreshClusters={(x: boolean) =>
-                  this.setState({ forceRefreshClusters: x })
-                }
-              />
-            </Modal>
-          )}
-        {currentModal === "IntegrationsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="725px"
-          >
-            <IntegrationsModal />
-          </Modal>
-        )}
-        {currentModal === "IntegrationsInstructionsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="650px"
-          >
-            <IntegrationsInstructionsModal />
-          </Modal>
-        )}
-        {this.props.isAuthorized("namespace", "", ["get", "create"]) &&
-          currentModal === "NamespaceModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="600px"
-              height="220px"
-            >
-              <NamespaceModal />
-            </Modal>
-          )}
-        {this.props.isAuthorized("namespace", "", ["get", "delete"]) &&
-          currentModal === "DeleteNamespaceModal" && (
-            <Modal
-              onRequestClose={() => setCurrentModal(null, null)}
-              width="700px"
-              height="280px"
-            >
-              <DeleteNamespaceModal />
-            </Modal>
-          )}
-
-        {currentModal === "EditInviteOrCollaboratorModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="600px"
-            height="250px"
-          >
-            <EditInviteOrCollaboratorModal />
-          </Modal>
-        )}
-        {currentModal === "AccountSettingsModal" && (
-          <Modal
-            onRequestClose={() => setCurrentModal(null, null)}
-            width="760px"
-            height="440px"
-          >
-            <AccountSettingsModal />
-          </Modal>
-        )}
-
+        <ModalHandler
+          setRefreshClusters={(x) => this.setState({ forceRefreshClusters: x })}
+        />
         {currentOverlay && (
           <ConfirmOverlay
             show={true}
@@ -566,7 +444,40 @@ class Home extends Component<PropsType, StateType> {
           />
         )}
 
-        {this.renderSidebar()}
+        {/* Render sidebar when there's at least one project */}
+        {projects?.length > 0 && cluster !== "new-project" ? (
+          <Sidebar
+            key="sidebar"
+            forceSidebar={this.state.forceSidebar}
+            setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+            currentView={this.props.currentRoute}
+            forceRefreshClusters={this.state.forceRefreshClusters}
+            setRefreshClusters={(x: boolean) =>
+              this.setState({ forceRefreshClusters: x })
+            }
+          />
+        ) : (
+          <>
+            <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+              <Icon src={discordLogo} />
+              Join Our Discord
+            </DiscordButton>
+            {/* This should only be shown on the first render of the app */}
+            {this.state.showWelcomeForm &&
+              localStorage.getItem("welcomed") != "true" &&
+              projects?.length === 0 && (
+                <>
+                  <WelcomeForm
+                    closeForm={() => this.setState({ showWelcomeForm: false })}
+                  />
+                  <Navbar
+                    logOut={this.props.logOut}
+                    currentView={this.props.currentRoute} // For form feedback
+                  />
+                </>
+              )}
+          </>
+        )}
 
         <ViewWrapper>
           <Navbar

+ 105 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -0,0 +1,105 @@
+import React, { useContext } from "react";
+import useAuth from "shared/auth/useAuth";
+import { Context } from "shared/Context";
+import Modal from "./modals/Modal";
+import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
+import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
+import IntegrationsModal from "./modals/IntegrationsModal";
+import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
+import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
+
+const ModalHandler: React.FC<{
+  setRefreshClusters: (x: boolean) => void;
+}> = ({ setRefreshClusters }) => {
+  const [isAuth] = useAuth();
+  const { currentModal, setCurrentModal } = useContext(Context);
+  return (
+    <>
+      {currentModal === "ClusterInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="650px"
+        >
+          <ClusterInstructionsModal />
+        </Modal>
+      )}
+
+      {/* We should be careful, as this component is named Update but is for deletion */}
+      {isAuth("cluster", "", ["get", "delete"]) &&
+        currentModal === "UpdateClusterModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="565px"
+            height="275px"
+          >
+            <UpdateClusterModal
+              setRefreshClusters={(x: boolean) => setRefreshClusters(x)}
+            />
+          </Modal>
+        )}
+      {currentModal === "IntegrationsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="725px"
+        >
+          <IntegrationsModal />
+        </Modal>
+      )}
+      {currentModal === "IntegrationsInstructionsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="650px"
+        >
+          <IntegrationsInstructionsModal />
+        </Modal>
+      )}
+      {isAuth("namespace", "", ["get", "create"]) &&
+        currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
+      {isAuth("namespace", "", ["get", "delete"]) &&
+        currentModal === "DeleteNamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+          >
+            <DeleteNamespaceModal />
+          </Modal>
+        )}
+
+      {currentModal === "EditInviteOrCollaboratorModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="600px"
+          height="250px"
+        >
+          <EditInviteOrCollaboratorModal />
+        </Modal>
+      )}
+      {currentModal === "AccountSettingsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="440px"
+        >
+          <AccountSettingsModal />
+        </Modal>
+      )}
+    </>
+  );
+};
+
+export default ModalHandler;

+ 234 - 0
dashboard/src/main/home/onboarding/NewProject.tsx

@@ -0,0 +1,234 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import gradient from "assets/gradient.png";
+import { isAlphanumeric } from "shared/common";
+
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import TitleSection from "components/TitleSection";
+import { useSnapshot } from "valtio";
+import { actions, OnboardingState } from "./OnboardingState";
+import { useRouting } from "shared/routing";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import SaveButton from "components/SaveButton";
+
+import backArrow from "assets/back_arrow.png";
+
+type ValidationError = {
+  hasError: boolean;
+  description?: string;
+};
+
+export const NewProjectFC = () => {
+  const snap = useSnapshot(OnboardingState);
+  const { user, setProjects, setCurrentProject } = useContext(Context);
+  const { pushFiltered } = useRouting();
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  useEffect(() => {
+    if (snap.userId !== null) {
+      window.analytics.track("provision_new-project", {
+        userId: snap.userId,
+      });
+    }
+  }, [snap.userId]);
+
+  const validateProjectName = (): ValidationError => {
+    const name = snap.projectName;
+    if (name === "") {
+      return {
+        hasError: true,
+        description: "The name cannot be empty. Please fill the input.",
+      };
+    }
+    if (!isAlphanumeric(name)) {
+      return {
+        hasError: true,
+        description:
+          'Please be sure that the text is alphanumeric. (lowercase letters, numbers, and "-" only)',
+      };
+    }
+    if (name.length > 25) {
+      return {
+        hasError: true,
+        description:
+          "The length of the name cannot be more than 25 characters.",
+      };
+    }
+
+    return {
+      hasError: false,
+    };
+  };
+
+  const createProject = async () => {
+    const { projectName } = snap;
+    setButtonStatus("loading");
+    const validation = validateProjectName();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.description);
+      return;
+    }
+
+    try {
+      const project = await api
+        .createProject("<token>", { name: projectName }, {})
+        .then((res) => res.data);
+
+      // Need to set project list for dropdown
+      // TODO: consolidate into ProjectSection (case on exists in list on set)
+      const projectList = await api
+        .getProjects(
+          "<token>",
+          {},
+          {
+            id: user.userId,
+          }
+        )
+        .then((res) => res.data);
+      setProjects(projectList);
+      setCurrentProject(project);
+
+      pushFiltered("/onboarding/provision", []);
+      setButtonStatus("success");
+    } catch (error) {
+      setButtonStatus("Couldn't create project, try again.");
+      console.log(error);
+    }
+  };
+
+  return (
+    <>
+      {!snap.isFirstProject && (
+        <BackButton
+          onClick={() => {
+            pushFiltered("/dashboard", []);
+          }}
+        >
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+      )}
+      <TitleSection>New Project</TitleSection>
+      <Helper>
+        Project name
+        <Warning highlight={validateProjectName().hasError}>
+          (lowercase letters, numbers, and "-" only) 25 letters max length
+        </Warning>
+        <Required>*</Required>
+      </Helper>
+      <InputWrapper>
+        <ProjectIcon>
+          <ProjectImage src={gradient} />
+          <Letter>
+            {snap.projectName ? snap.projectName[0].toUpperCase() : "-"}
+          </Letter>
+        </ProjectIcon>
+        <InputRow
+          type="string"
+          value={snap.projectName}
+          setValue={(x: string) => {
+            setButtonStatus("");
+            actions.setProjectName(x);
+          }}
+          placeholder="ex: perspective-vortex"
+          width="470px"
+        />
+      </InputWrapper>
+      <NewProjectSaveButton
+        text="Create Project"
+        disabled={false}
+        onClick={createProject}
+        status={buttonStatus}
+        makeFlush={true}
+        clearPosition={true}
+        statusPosition="right"
+        saveText="Creating project..."
+        successText="Project created successfully!"
+      />
+    </>
+  );
+};
+
+const NewProjectSaveButton = styled(SaveButton)`
+  margin-top: 24px;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  display: flex;
+  color: white;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 45px;
+  min-width: 45px;
+  height: 45px;
+  border-radius: 5px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  margin-top: 9px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const BackButton = styled.div`
+  margin-bottom: 24px;
+  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;
+`;

+ 33 - 0
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -0,0 +1,33 @@
+import React, { useContext, useEffect } from "react";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { actions } from "./OnboardingState";
+import Routes from "./Routes";
+
+const Onboarding = () => {
+  const context = useContext(Context);
+
+  useEffect(() => {
+    actions.initFromGlobalContext(context);
+  }, [context]);
+
+  useEffect(() => {
+    return () => {
+      actions.clearState();
+    };
+  }, []);
+  return (
+    <StyledOnboarding>
+      <Routes />
+    </StyledOnboarding>
+  );
+};
+
+export default Onboarding;
+
+const StyledOnboarding = styled.div`
+  width: calc(90% - 130px);
+  min-width: 300px;
+  position: relative;
+  margin-top: calc(50vh - 340px);
+`;

+ 68 - 0
dashboard/src/main/home/onboarding/OnboardingState.ts

@@ -0,0 +1,68 @@
+import { isAlphanumeric } from "shared/common";
+import { ContextProps } from "shared/types";
+import { proxy } from "valtio";
+import { derive, devtools } from "valtio/utils";
+
+export type OnboardingStateType = {
+  [key: string]: unknown;
+  projectName: string;
+  // Null when is not setted yet.
+  isProvisionerEnabled: boolean | null;
+  userId: number | null;
+  // Check if it's the first project that will be created
+  isFirstProject: boolean | null;
+};
+
+const initialState: OnboardingStateType = {
+  projectName: "",
+  isProvisionerEnabled: null,
+  userId: null,
+  isFirstProject: null,
+};
+
+export const OnboardingState = proxy<OnboardingStateType>(initialState);
+
+devtools(OnboardingState, "Onboarding state");
+
+export const actions = {
+  setProjectName: (name: string) => {
+    OnboardingState.projectName = name;
+  },
+  setIsProvisionerEnabled: (provStatus: boolean) => {
+    OnboardingState.isProvisionerEnabled = provStatus;
+  },
+  setUserId: (userId: number) => {
+    OnboardingState.userId = userId;
+  },
+  setIsFirstProject: (isFirstProject: boolean) => {
+    OnboardingState.isFirstProject = isFirstProject;
+  },
+  initFromGlobalContext: (context: Partial<ContextProps>) => {
+    const provisionerStatus = context?.capabilities?.provisioner;
+
+    if (typeof provisionerStatus === "boolean") {
+      actions.setIsProvisionerEnabled(provisionerStatus);
+    } else {
+      actions.setIsProvisionerEnabled(null);
+    }
+
+    const userId = context?.user?.id;
+    if (typeof userId === "number") {
+      actions.setUserId(userId);
+    } else {
+      actions.setUserId(null);
+    }
+    if (context?.projects?.length >= 1) {
+      actions.setIsFirstProject(false);
+    } else {
+      actions.setIsFirstProject(true);
+    }
+  },
+  clearState: () => {
+    Object.keys(OnboardingState).forEach((key) => {
+      if (key in initialState) {
+        OnboardingState[key] = initialState[key];
+      }
+    });
+  },
+};

+ 23 - 0
dashboard/src/main/home/onboarding/ProvisionerForms.tsx

@@ -0,0 +1,23 @@
+import Helper from "components/form-components/Helper";
+import TitleSection from "components/TitleSection";
+import React from "react";
+import { useSnapshot } from "valtio";
+import ProvisionerSettings from "../provisioner/ProvisionerSettings";
+import { OnboardingState } from "./OnboardingState";
+
+const ProvisionerForms = () => {
+  const snap = useSnapshot(OnboardingState);
+  return (
+    <>
+      <TitleSection>Getting Started</TitleSection>
+      <Helper>Provision a new cluster through us or link one later!</Helper>
+      <ProvisionerSettings
+        isInNewProject={true}
+        projectName={snap.projectName}
+        provisioner={snap.isProvisionerEnabled}
+      />
+    </>
+  );
+};
+
+export default ProvisionerForms;

+ 21 - 0
dashboard/src/main/home/onboarding/Routes.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+import { Route, Switch } from "react-router";
+import { NewProjectFC } from "./NewProject";
+import ProvisionerForms from "./ProvisionerForms";
+
+export const Routes = () => {
+  return (
+    <>
+      <Switch>
+        <Route path={`/onboarding/new-project`}>
+          <NewProjectFC />
+        </Route>
+        <Route path={`/onboarding/provision`}>
+          <ProvisionerForms />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 0 - 32
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -165,34 +165,6 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
     props.handleError();
   };
 
-  // Step 1: Create a project
-  // TODO: promisify this function
-  const createProject = async () => {
-    const { projectName } = props;
-    const { user, setProjects, setCurrentProject } = context;
-    try {
-      const project = await api
-        .createProject("<token>", { name: projectName }, {})
-        .then((res) => res.data);
-
-      // Need to set project list for dropdown
-      // TODO: consolidate into ProjectSection (case on exists in list on set)
-      const projectList = await api
-        .getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        )
-        .then((res) => res.data);
-      setProjects(projectList);
-      setCurrentProject(project);
-    } catch (error) {
-      catchError(error);
-    }
-  };
-
   const getAwsIntegrationId = async () => {
     const { currentProject } = context;
     try {
@@ -253,10 +225,6 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
     setButtonStatus("loading");
     const { projectName } = props;
 
-    if (projectName) {
-      await createProject();
-    }
-
     const awsIntegrationId = await getAwsIntegrationId();
 
     const filterNonAWSInfras = (infra: any) =>

+ 1 - 32
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -125,31 +125,6 @@ const DOFormSectionFC: React.FC<PropsType> = (props) => {
     return;
   };
 
-  // Step 1: Create a project
-  const createProject = (callback?: any) => {
-    let { projectName } = props;
-    let { user, setProjects, setCurrentProject } = context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then(async (res) => {
-        let proj = res.data;
-
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        const res_1 = await api.getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        );
-        setProjects(res_1.data);
-        setCurrentProject(proj, () => callback && callback(proj.id));
-      })
-      .catch(catchError);
-  };
-
   const doRedirect = (projectId: number) => {
     let redirectUrl = `/api/projects/${projectId}/oauth/digitalocean?project_id=${projectId}&provision=do`;
     redirectUrl += `&tier=${subscriptionTier}&region=${doRegion}&cluster_name=${clusterName}`;
@@ -163,14 +138,8 @@ const DOFormSectionFC: React.FC<PropsType> = (props) => {
   // TODO: handle generically (with > 2 steps)
   const onCreateDO = () => {
     props?.trackOnSave();
-    let { projectName } = props;
     let { currentProject } = context;
-
-    if (!projectName) {
-      doRedirect(currentProject.id);
-    } else {
-      createProject((projectId: number) => doRedirect(projectId));
-    }
+    doRedirect(currentProject.id);
   };
 
   const getButtonStatus = () => {

+ 6 - 31
dashboard/src/main/home/provisioner/ExistingClusterSection.tsx

@@ -24,39 +24,14 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
     buttonStatus: "",
   };
 
-  onCreateProject = () => {
+  onSkip = () => {
     this.props?.trackOnSave();
-    let { projectName } = this.props;
-    let { user, setProjects, setCurrentProject } = this.context;
 
     this.setState({ buttonStatus: "loading" });
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) =>
-        api.getProjects(
-          "<token>",
-          {},
-          {
-            id: user.userId,
-          }
-        )
-      )
-      .then((res) => {
-        if (res.data) {
-          setProjects(res.data);
-          if (res.data.length > 0) {
-            let proj = res.data.find((el: ProjectType) => {
-              return el.name === projectName;
-            });
-            setCurrentProject(proj, () =>
-              pushFiltered(this.props, "/dashboard", ["project_id"], {
-                tab: "overview",
-              })
-            );
-          }
-        }
-      })
-      .catch(console.log);
+
+    pushFiltered(this.props, "/dashboard", ["project_id"], {
+      tab: "overview",
+    });
   };
 
   render() {
@@ -72,7 +47,7 @@ class ExistingClusterSection extends Component<PropsType, StateType> {
         <SaveButton
           text="Submit"
           disabled={!isAlphanumeric(projectName)}
-          onClick={this.onCreateProject}
+          onClick={this.onSkip}
           status={buttonStatus}
           makeFlush={true}
           helper="Note: Provisioning can take up to 15 minutes"

+ 2 - 35
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -150,36 +150,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
     props.handleError();
   };
 
-  // Step 1: Create a project
-  const createProject = (callback?: any) => {
-    let { projectName } = props;
-    let { user, setProjects, setCurrentProject } = context;
-
-    api
-      .createProject("<token>", { name: projectName }, {})
-      .then((res) => {
-        let proj = res.data;
-
-        // Need to set project list for dropdown
-        // TODO: consolidate into ProjectSection (case on exists in list on set)
-        api
-          .getProjects(
-            "<token>",
-            {},
-            {
-              id: user.userId,
-            }
-          )
-          .then((res) => {
-            setProjects(res.data);
-            setCurrentProject(proj, () => callback && callback());
-          })
-          .catch(catchError);
-      })
-      .catch(catchError);
-  };
-
-  const provisionGCR = (id: number, callback?: any) => {
+  const provisionGCR = (id: number) => {
     console.log("Provisioning GCR");
     let { currentProject } = context;
 
@@ -260,11 +231,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
     setButtonStatus("loading");
     let { projectName } = props;
 
-    if (!projectName) {
-      handleCreateFlow();
-    } else {
-      createProject(handleCreateFlow);
-    }
+    handleCreateFlow();
   };
 
   const getButtonStatus = () => {

+ 3 - 1
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -78,7 +78,9 @@ class ProjectSection extends Component<PropsType, StateType> {
               selected={false}
               lastItem={true}
               onClick={() =>
-                pushFiltered(this.props, "/new-project", ["project_id"])
+                pushFiltered(this.props, "/onboarding/new-project", [
+                  "project_id",
+                ])
               }
             >
               <ProjectIconAlt>+</ProjectIconAlt>

+ 29 - 1
dashboard/src/shared/routing.tsx

@@ -1,3 +1,5 @@
+import { useHistory, useLocation } from "react-router";
+
 export type PorterUrl =
   | "dashboard"
   | "launch"
@@ -7,7 +9,8 @@ export type PorterUrl =
   | "project-settings"
   | "applications"
   | "env-groups"
-  | "jobs";
+  | "jobs"
+  | "onboarding";
 
 export const PorterUrls = [
   "dashboard",
@@ -19,6 +22,7 @@ export const PorterUrls = [
   "applications",
   "env-groups",
   "jobs",
+  "onboarding",
 ];
 
 // TODO: consolidate with pushFiltered
@@ -66,3 +70,27 @@ export const getQueryParam = (props: any, paramName: string) => {
   const searchParams = getQueryParams(props);
   return searchParams?.get(paramName);
 };
+
+export const useRouting = () => {
+  const location = useLocation();
+  const history = useHistory();
+
+  return {
+    pushQueryParams: (params: { [key: string]: unknown }) => {
+      return pushQueryParams({ location, history }, params);
+    },
+    pushFiltered: (
+      pathname: string,
+      keys: string[],
+      params?: { [key: string]: unknown }
+    ) => {
+      return pushFiltered({ location, history }, pathname, keys, params);
+    },
+    getQueryParams: () => {
+      return getQueryParams({ location });
+    },
+    getQueryParam: (paramName: string) => {
+      return getQueryParam({ location }, paramName);
+    },
+  };
+};