Browse Source

scaffold create app form and use ff (#3388)

ianedwards 2 years ago
parent
commit
634ab057c4

+ 35 - 26
dashboard/src/components/porter/Button.tsx

@@ -5,7 +5,7 @@ import loading from "assets/loading.gif";
 
 type Props = {
   children: React.ReactNode;
-  onClick: () => void;
+  onClick?: () => void;
   disabled?: boolean;
   status?: React.ReactNode;
   helperText?: string;
@@ -18,6 +18,7 @@ type Props = {
   withBorder?: boolean;
   rounded?: boolean;
   alt?: boolean;
+  type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
 };
 
 const Button: React.FC<Props> = ({
@@ -35,6 +36,7 @@ const Button: React.FC<Props> = ({
   withBorder,
   rounded,
   alt,
+  type,
 }) => {
   const renderStatus = () => {
     switch (status) {
@@ -53,13 +55,13 @@ const Button: React.FC<Props> = ({
           </StatusWrapper>
         );
       case "":
-        return helperText && (
-          <StatusWrapper success={false}>{helperText}</StatusWrapper>
-        )
-      default:
         return (
-          <StatusWrapper success={false}>{status}</StatusWrapper>
+          helperText && (
+            <StatusWrapper success={false}>{helperText}</StatusWrapper>
+          )
         );
+      default:
+        return <StatusWrapper success={false}>{status}</StatusWrapper>;
     }
   };
 
@@ -67,13 +69,18 @@ const Button: React.FC<Props> = ({
     <Wrapper>
       <StyledButton
         disabled={disabled}
-        onClick={() => !disabled && onClick()}
+        onClick={() => {
+          if (!disabled && onClick) {
+            onClick();
+          }
+        }}
         width={width}
         height={height}
         color={color}
         withBorder={withBorder || alt}
         rounded={rounded || alt}
         alt={alt}
+        type={type}
       >
         <Text>{children}</Text>
       </StyledButton>
@@ -119,7 +126,7 @@ const StatusWrapper = styled.div<{
     font-size: 18px;
     margin-right: 10px;
     float: left;
-    color: ${props => props.success ? "#4797ff" : "#fcba03"};
+    color: ${(props) => (props.success ? "#4797ff" : "#fcba03")};
   }
 `;
 
@@ -134,37 +141,39 @@ const Text = styled.div`
 `;
 
 const StyledButton = styled.button<{
-  disabled: boolean;
-  width: string;
-  height: string;
-  color: string;
-  withBorder: boolean;
-  rounded: boolean;
-  alt: boolean;
+  disabled?: boolean;
+  width?: string;
+  height?: string;
+  color?: string;
+  withBorder?: boolean;
+  rounded?: boolean;
+  alt?: boolean;
 }>`
-  height: ${props => props.height || "35px"};
-  width: ${props => props.width || "auto"};
-  min-width: ${props => props.width || ""};
+  height: ${(props) => props.height || "35px"};
+  width: ${(props) => props.width || "auto"};
+  min-width: ${(props) => props.width || ""};
   font-size: 13px;
-  cursor: ${props => props.disabled ? "not-allowed" : "pointer"};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
   padding: 15px;
   border: none;
   outline: none;
   color: white;
-  opacity: ${props => props.disabled && props.withBorder ? "0.5" : "1"};
-  background: ${props => {
+  opacity: ${(props) => (props.disabled && props.withBorder ? "0.5" : "1")};
+  background: ${(props) => {
     if (props.alt || props.color === "fg") {
       return props.theme.fg;
     }
-    return (props.disabled && !props.color) ? "#aaaabb" : (props.color || props.theme.button);
+    return props.disabled && !props.color
+      ? "#aaaabb"
+      : props.color || props.theme.button;
   }};
   display: flex;
   ailgn-items: center;
   justify-content: center;
-  border-radius: ${props => props.rounded ? "50px" : "5px"};
-  border: ${props => props.withBorder ? "1px solid #494b4f" : "none"};
+  border-radius: ${(props) => (props.rounded ? "50px" : "5px")};
+  border: ${(props) => (props.withBorder ? "1px solid #494b4f" : "none")};
 
   :hover {
-    filter: ${props => props.disabled ? "" : "brightness(120%)"};
+    filter: ${(props) => (props.disabled ? "" : "brightness(120%)")};
   }
-`;
+`;

+ 157 - 0
dashboard/src/components/porter/ControlledInput.tsx

@@ -0,0 +1,157 @@
+import React from "react";
+import styled from "styled-components";
+import Tooltip from "./Tooltip";
+
+/*
+ *
+ * ControlledInput is a wrapper around an input that allows for
+ * onChange and onBlur handlers to be passed in by the higher level form component.
+ * This is particular useful if using the "register" method from react-hook-form
+ *
+ */
+export const ControlledInput = React.forwardRef<
+  HTMLInputElement,
+  {
+    id: string; // id is the id attribute of the input
+    name: string; // name is the name attribute of the input
+    label?: string; // label is used to render a label above the input. If not provided, no label is rendered
+    type: React.HTMLInputTypeAttribute; // type is the type attribute of the input (text, password, etc.)
+    placeholder?: string; // placeholder is the placeholder attribute of the input. If not provided, no placeholder is rendered
+    defaultValue?: string; // defaultValue is the default value of the input. If not provided, the input is empty by default
+    onChange: React.ChangeEventHandler<HTMLInputElement>; // onChange is the onChange handler of the input, called when the input value changes
+    onBlur: React.FocusEventHandler<HTMLInputElement>; // onBlur is the onBlur handler of the input, called when the input loses focus
+    autoComplete?: string; // autoComplete is the autoComplete attribute of the input. If not provided, no autoComplete is rendered
+    width?: string; // width is the width of the input. If not provided, the width is 200px by default
+    height?: string; // height is the height of the input. If not provided, the height is 35px by default
+    error?: string; // error is the error message to display below the input. If not provided, no error is rendered
+    disabled?: boolean; // disabled is whether or not the input is disabled. If not provided, the input is not disabled by default
+    disabledTooltip?: string; // disabledTooltip is the tooltip to display when hovering over the input if it is disabled. If not provided, no tooltip is rendered
+  }
+>(
+  (
+    {
+      id,
+      name,
+      label,
+      type,
+      autoComplete = "off",
+      placeholder,
+      defaultValue,
+      onChange,
+      onBlur,
+      width,
+      height,
+      error,
+      disabled,
+      disabledTooltip,
+    },
+    ref
+  ) => {
+    return disabled && disabledTooltip ? (
+      <Tooltip content={disabledTooltip} position="right">
+        <Block width={width}>
+          {label && <Label>{label}</Label>}
+          <StyledInput
+            id={id}
+            name={name}
+            type={type}
+            autoComplete={autoComplete}
+            placeholder={placeholder}
+            defaultValue={defaultValue}
+            onChange={onChange}
+            onBlur={onBlur}
+            ref={ref}
+            disabled={disabled}
+            width={width}
+            height={height}
+            hasError={(error && true) || error === ""}
+          />
+          {error && (
+            <Error>
+              <i className="material-icons">error</i>
+              {error}
+            </Error>
+          )}
+        </Block>
+      </Tooltip>
+    ) : (
+      <Block width={width}>
+        {label && <Label>{label}</Label>}
+        <StyledInput
+          id={id}
+          name={name}
+          type={type}
+          autoComplete={autoComplete}
+          placeholder={placeholder}
+          defaultValue={defaultValue}
+          onChange={onChange}
+          onBlur={onBlur}
+          ref={ref}
+          disabled={disabled}
+          width={width}
+          height={height}
+          hasError={(error && true) || error === ""}
+        />
+        {error && (
+          <Error>
+            <i className="material-icons">error</i>
+            {error}
+          </Error>
+        )}
+      </Block>
+    );
+  }
+);
+
+ControlledInput.displayName = "ControlledInput";
+
+const Block = styled.div<{
+  width?: string;
+}>`
+  display: block;
+  position: relative;
+  width: ${(props) => props.width || "200px"};
+`;
+
+const Label = styled.div`
+  font-size: 13px;
+  color: #aaaabb;
+  margin-bottom: 10px;
+`;
+
+const Error = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ff3b62;
+  margin-top: 10px;
+  > i {
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const StyledInput = styled.input<{
+  hasError: boolean;
+  width?: string;
+  height?: string;
+  disabled?: boolean;
+}>`
+  height: ${(props) => props.height || "35px"};
+  padding: 5px 10px;
+  width: ${(props) => props.width || "200px"};
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
+  font-size: 13px;
+  outline: none;
+  border-radius: 5px;
+  background: #26292e;
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
+  border: 1px solid ${(props) => (props.hasError ? "#ff3b62" : "#494b4f")};
+  ${(props) =>
+    !props.disabled &&
+    `
+      :hover {
+        border: 1px solid ${props.hasError ? "#ff3b62" : "#7a7b80"};
+      }
+    `}
+`;

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

@@ -40,6 +40,7 @@ import Button from "components/porter/Button";
 import NewAppFlow from "./app-dashboard/new-app-flow/NewAppFlow";
 import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 import ExpandedJob from "./app-dashboard/expanded-app/expanded-job/ExpandedJob";
+import CreateApp from "./app-dashboard/create-app/CreateApp";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -192,7 +193,7 @@ const Home: React.FC<Props> = (props) => {
       } else {
         setHasFinishedOnboarding(true);
       }
-    } catch (error) { }
+    } catch (error) {}
   };
 
   useEffect(() => {
@@ -410,7 +411,7 @@ const Home: React.FC<Props> = (props) => {
 
           <Switch>
             <Route path="/apps/new/app">
-              <NewAppFlow />
+              {currentProject?.validate_apply_v2 ? <CreateApp /> : <NewAppFlow />}
             </Route>
             <Route path="/apps/:appName/:tab">
               <ExpandedApp />

+ 213 - 0
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -0,0 +1,213 @@
+import React, { useContext, useEffect } from "react";
+import { RouteComponentProps, withRouter } from "react-router";
+import web from "assets/web.png";
+
+import styled from "styled-components";
+import { useForm, Controller, FormProvider } from "react-hook-form";
+import Back from "components/porter/Back";
+import VerticalSteps from "components/porter/VerticalSteps";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Link from "components/porter/Link";
+
+import { Context } from "shared/Context";
+import { PorterAppFormData } from "lib/porter-apps";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import SourceSelector from "../new-app-flow/SourceSelector";
+import Button from "components/porter/Button";
+
+type CreateAppProps = {} & RouteComponentProps;
+
+const CreateApp: React.FC<CreateAppProps> = ({}) => {
+  const { currentProject } = useContext(Context);
+  const [step, setStep] = React.useState(0);
+
+  const methods = useForm<PorterAppFormData>({
+    reValidateMode: "onSubmit",
+    defaultValues: {
+      app: {
+        name: "",
+        build: {
+          context: "./",
+          builder: "",
+          buildpacks: [],
+          dockerfile: "",
+        },
+      },
+      source: {
+        git_repo_name: "",
+        git_repo_id: 0,
+        git_branch: "",
+        porter_yaml_path: "./porter.yaml",
+      },
+    },
+  });
+  const {
+    register,
+    control,
+    watch,
+    formState: { isSubmitting },
+  } = methods;
+
+  const name = watch("app.name");
+  const source = watch("source");
+  const build = watch("app.build");
+
+  useEffect(() => {
+    if (name) {
+      setStep((prev) => Math.max(prev, 1));
+    }
+
+    if (source?.type) {
+      setStep((prev) => Math.max(prev, 2));
+    }
+  }, [name, source?.type]);
+
+  if (!currentProject) {
+    return null;
+  }
+
+  return (
+    <CenterWrapper>
+      <Div>
+        <StyledConfigureTemplate>
+          <Back to="/apps" />
+          <DashboardHeader
+            prefix={<Icon src={web} />}
+            title="Deploy a new application"
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <FormProvider {...methods}>
+            <VerticalSteps
+              currentStep={step}
+              steps={[
+                <>
+                  <Text size={16}>Application name</Text>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Lowercase letters, numbers, and "-" only.
+                  </Text>
+                  <Spacer y={0.5} />
+                  <ControlledInput
+                    id={"name"}
+                    placeholder="ex: academic-sophon"
+                    autoComplete="off"
+                    type="text"
+                    {...register("app.name")}
+                  />
+                </>,
+                <>
+                  <Text size={16}>Deployment method</Text>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Deploy from a Git repository or a Docker registry.
+                    <Spacer inline width="5px" />
+                    <Link
+                      hasunderline
+                      to="https://docs.porter.run/standard/deploying-applications/overview"
+                      target="_blank"
+                    >
+                      Learn more
+                    </Link>
+                  </Text>
+                  <Spacer y={0.5} />
+                  <Controller
+                    name="source.type"
+                    control={control}
+                    render={({ field: { value, onChange } }) => (
+                      <SourceSelector
+                        selectedSourceType={value}
+                        setSourceType={(sourceType) => {
+                          onChange(sourceType);
+                        }}
+                      />
+                    )}
+                  />
+                  {/* todo(ianedwards): add back in the following as form comes together */}
+                  {/* {source?.type ? (
+                    source.type === "github" ? (
+                      <RepoSettings
+                        build={build}
+                        source={source}
+                        projectId={currentProject.id}
+                      />
+                    ) : (
+                      <div></div>
+                    )
+                  ) : null} */}
+                </>,
+                <>
+                  <ErrorText hasError={true}>
+                    Deploying apps in this flow is not supported for your
+                    project. Contact support@porter.run for more information.
+                  </ErrorText>
+                </>,
+                <>
+                  <Button
+                    status={isSubmitting && "loading"}
+                    loadingText={"Deploying..."}
+                    width={"120px"}
+                    disabled={true}
+                  >
+                    Deploy app
+                  </Button>
+                </>,
+              ]}
+            />
+          </FormProvider>
+          <Spacer y={3} />
+        </StyledConfigureTemplate>
+      </Div>
+    </CenterWrapper>
+  );
+};
+
+export default withRouter(CreateApp);
+
+const ErrorText = styled.span`
+  color: red;
+  margin-left: 10px;
+  display: ${(props: { hasError: boolean }) =>
+    props.hasError ? "inline-block" : "none"};
+`;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 28px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -275,6 +275,7 @@ export interface ProjectType {
   multi_cluster: boolean;
   full_add_ons: boolean;
   enable_reprovision: boolean;
+  validate_apply_v2: boolean;
   roles: {
     id: number;
     kind: string;