Răsfoiți Sursa

Add form support for raw dictionary input and array of raw dictionary inputs (#3691)

Co-authored-by: Justin Rhee <jusrhee@Justins-MacBook-Air-2.local>
jusrhee 2 ani în urmă
părinte
comite
04422e98eb

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

@@ -13,6 +13,8 @@ import {
   ServiceIPListField,
   TextAreaField,
   UrlLinkField,
+  DictionaryField,
+  DictionaryArrayField,
 } from "./types";
 import TabRegion, { TabOption } from "../TabRegion";
 import Heading from "../form-components/Heading";
@@ -32,6 +34,8 @@ import CronInput from "./field-components/CronInput";
 import TextAreaInput from "./field-components/TextAreaInput";
 import UrlLink from "./field-components/UrlLink";
 import Button from "components/porter/Button";
+import DictionaryArray from "./field-components/DictionaryArray";
+import Dictionary from "./field-components/Dictionary";
 
 interface Props {
   leftTabOptions?: TabOption[];
@@ -88,6 +92,10 @@ const PorterForm: React.FC<Props> = (props) => {
         return <Helper>{field.label}</Helper>;
       case "input":
         return <Input {...(bundledProps as InputField)} />;
+      case "dictionary":
+        return <Dictionary {...(bundledProps as DictionaryField)} />;
+      case "dictionary-array":
+        return <DictionaryArray {...(bundledProps as DictionaryArrayField)} />;
       case "checkbox":
         return <Checkbox {...(bundledProps as CheckboxField)} />;
       case "key-value-array":

+ 67 - 0
dashboard/src/components/porter-form/field-components/Dictionary.tsx

@@ -0,0 +1,67 @@
+import React, { useEffect } from "react";
+import InputRow from "../../form-components/InputRow";
+import useFormField from "../hooks/useFormField";
+import {
+  GetFinalVariablesFunction,
+  DictionaryField,
+  DictionaryFieldState,
+} from "../types";
+import DictionaryEditor from "components/porter/DictionaryEditor";
+import { hasSetValue } from "../utils";
+
+const Dictionary: React.FC<DictionaryField> = (props) => {
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<DictionaryFieldState>(props.id, {
+    initValidation: {
+      validated: hasSetValue(props),
+    },
+    initVars: {
+      [props.variable]: hasSetValue(props) ? props.value[0] : undefined,
+    },
+  });
+
+  if (state == undefined) return <></>;
+
+  return (
+    <DictionaryEditor
+      value={props?.value && props.value[0]}
+      onChange={(x: any) => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [props.variable]: x,
+          };
+        });
+        setValidation((prev) => {
+          return {
+            ...prev,
+            validated: true,
+          };
+        });
+      }}
+    />
+  );
+};
+
+export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
+  vars,
+  props: DictionaryField
+) => {
+  const val =
+    vars[props.variable] != undefined && vars[props.variable] != null
+      ? vars[props.variable] : hasSetValue(props)
+      ? props.value[0] : undefined;
+
+  return {
+    [props.variable]:
+      props.settings?.unit && !props.settings.omitUnitFromValue
+        ? val + props.settings.unit
+        : val,
+  };
+};
+
+export default Dictionary;

+ 218 - 0
dashboard/src/components/porter-form/field-components/DictionaryArray.tsx

@@ -0,0 +1,218 @@
+import React from "react";
+import styled from "styled-components";
+import {
+  DictionaryArrayField,
+  DictionaryArrayFieldState,
+  GetFinalVariablesFunction,
+} from "../types";
+import useFormField from "../hooks/useFormField";
+import { hasSetValue } from "../utils";
+import DictionaryEditor from "components/porter/DictionaryEditor";
+
+// this is used to set validation for the below form component in case
+// input validation needs to get more complicated in the future
+const validateArray = (arr: any[]) => {
+  return true;
+};
+
+const DictionaryArray: React.FC<DictionaryArrayField> = (props) => {
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<DictionaryArrayFieldState>(props.id, {
+    initVars: {
+      [props.variable]: hasSetValue(props) ? props.value[0] : [],
+    },
+    initValidation: {
+      validated: validateArray(hasSetValue(props) ? props.value[0] : []),
+    },
+  });
+
+  if (state == undefined) return <></>;
+
+  const renderDeleteButton = (values: string[], i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            setVars((prev) => {
+              const val = prev[props.variable]
+                .slice(0, i)
+                .concat(prev[props.variable].slice(i + 1));
+              setValidation((prev) => {
+                return {
+                  ...prev,
+                  validated: validateArray(val),
+                };
+              });
+              return {
+                [props.variable]: val,
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderInputList = (values: string[]) => {
+    return (
+      <>
+        {values.length > 0 && values.map((value: string, i: number) => {
+          return (
+            <InputWrapper>
+              <DictionaryEditor
+                key={i}
+                value={value}
+                onChange={(e: any) => {
+                  setVars((prev) => {
+                    const val = prev[props.variable]?.map(
+                      (t: string, j: number) => {
+                        return i == j ? e : t;
+                      }
+                    );
+                    setValidation((prev) => {
+                      return {
+                        ...prev,
+                        validated: validateArray(val),
+                      };
+                    });
+                    return {
+                      [props.variable]: val,
+                    };
+                  });
+                }}
+              />
+              {renderDeleteButton(values, i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <StyledInputArray>
+      <Label>
+        {props.label}
+        {props.required && <Required>{" *"}</Required>}
+      </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> Create new entry
+      </AddRowButton>
+    </StyledInputArray>
+  );
+};
+
+export default DictionaryArray;
+
+export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
+  vars,
+  props: DictionaryArrayField
+) => {
+  return vars[props.variable] != undefined && vars[props.variable] != null
+    ? {}
+    : {
+        [props.variable]: hasSetValue(props) ? props.value[0] : [],
+      };
+};
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 30px;
+  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 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 InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+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;
+`;
+
+const Required = styled.span`
+  color: #fc4976;
+`;

+ 17 - 1
dashboard/src/components/porter-form/types.ts

@@ -99,6 +99,16 @@ export interface ArrayInputField extends GenericInputField {
   label?: string;
 }
 
+export interface DictionaryField extends GenericInputField {
+  type: "dictionary";
+  label?: string;
+}
+
+export interface DictionaryArrayField extends GenericInputField {
+  type: "dictionary-array";
+  label?: string;
+}
+
 export interface SelectField extends GenericInputField {
   type: "select";
   settings:
@@ -168,6 +178,8 @@ export type FormField =
   | VariableField
   | CronField
   | TextAreaField
+  | DictionaryField
+  | DictionaryArrayField
   | UrlLinkField;
 
 export interface ShowIfAnd {
@@ -256,6 +268,8 @@ export interface KeyValueArrayFieldState {
   synced_env_groups: PopulatedEnvGroup[];
 }
 export interface ArrayInputFieldState { }
+export interface DictionaryFieldState {}
+export interface DictionaryArrayFieldState { }
 export interface SelectFieldState { }
 
 export type PorterFormFieldFieldState =
@@ -263,6 +277,8 @@ export type PorterFormFieldFieldState =
   | CheckboxFieldState
   | KeyValueArrayField
   | ArrayInputFieldState
+  | DictionaryFieldState
+  | DictionaryArrayFieldState
   | SelectFieldState;
 
 // reducer interfaces
@@ -324,7 +340,7 @@ export type PorterFormAction =
 
 export type GetFinalVariablesFunction = (
   vars: PorterFormVariableList,
-  props: FormField,
+  props: FormField | any,
   state: PorterFormFieldFieldState,
   context: Partial<ContextProps>
 ) => PorterFormVariableList;

+ 160 - 0
dashboard/src/components/porter/DictionaryEditor.tsx

@@ -0,0 +1,160 @@
+import { ok } from "assert";
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import Container from "./Container";
+import Text from "./Text";
+import Spacer from "./Spacer";
+
+type Props = {
+  value: any;
+  onChange: any;
+};
+
+const DictionaryEditor: React.FC<Props> = ({
+  value,
+  onChange,
+}) => {
+  const [rawEditor, setRawEditor] = useState<boolean>(true);
+  const [savedValue, setSavedValue] = useState<any>(JSON.stringify(value, null, 2));
+  const [rawValue, setRawValue] = useState<string>(JSON.stringify(value, null, 2));
+  const [changesNotSaved, setChangesNotSaved] = useState<boolean>(false);
+  const [isValidJSON, setIsValidJSON] = useState<boolean>(true);
+
+  useEffect(() => {
+    setSavedValue(JSON.stringify(value, null, 2));
+  }, [value]);
+
+  useEffect(() => {
+    if (rawValue !== savedValue) {
+      setChangesNotSaved(true);
+    } else {
+      setChangesNotSaved(false);
+      setIsValidJSON(true);
+    }
+  }, [rawValue]);
+
+  return (
+    <>
+      {rawEditor ? (
+        <Div>
+          <TextArea
+            color={
+              !isValidJSON ? "#ff385d" : (
+                changesNotSaved ? "#f5cb42" : "#494b4f"
+              )
+            }
+            value={rawValue}
+            onChange={(e) => {
+              setRawValue(e.target.value);
+            }}
+          />
+          {changesNotSaved && (
+            <Flex>
+              <SaveButton onClick={() => {
+                try {
+                  const parsedValue = JSON.parse(rawValue);
+                  setIsValidJSON(true);
+                  onChange(parsedValue);
+                  setChangesNotSaved(false);
+                } catch (e) {
+                  setIsValidJSON(false);
+                }
+              }}>
+                Update
+              </SaveButton>
+              <Spacer width="10px" inline />
+              {isValidJSON ? (
+                <Text color="#f5cb42">Existing changes have not been saved.</Text>
+              ) : (
+                <Text color="#ff385d">Object is not valid.</Text>
+              )}
+            </Flex>
+          )}
+        </Div>
+      ) : (
+        <>
+          {Object.keys(value).map((key: string, i: number) => {
+            return <Block>{key}</Block>
+          })}
+        </>
+      )}
+    </>
+  );
+};
+
+export default DictionaryEditor;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  padding: 10px;
+`;
+
+const Div = styled.div`
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+  margin-bottom: 15px;
+  max-width: calc(100% - 30px);
+  width: 400px;
+  background: #ffffff11;
+  border: 1px solid ${props => props.color || "#494b4f"};
+  border-radius: 5px;
+`;
+
+const SaveButton = styled.div`
+  width: 60px;
+  border-radius: 3px;
+  height: 25px;
+  background: #616FEEcc;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const TextArea = styled.textarea<{ color: string }>`
+  height: 200px;
+  background: #26292e;
+  border: 0;
+  color: #ffffff;
+  padding: 10px;
+  outline: none;
+  resize: none;
+  font-family: monospace;
+`;
+
+const Block = styled.div`
+  width: 100%;
+  padding: 10px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 5px;
+`;
+
+const StyledInput = styled.input<{
+  width: string;
+  height: string;
+  hasError: boolean;
+  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"};
+    }
+  `}
+`;