Merge pull request #75 from nuxxapp/feat/forms-update

Updated form state shape
pull/79/head
Artem Golub 3 years ago committed by GitHub
commit 138fd407b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -38,6 +38,7 @@
"d3": "^7.3.0", "d3": "^7.3.0",
"formik": "^2.2.9", "formik": "^2.2.9",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"random-words": "^1.2.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hot-toast": "^2.2.0", "react-hot-toast": "^2.2.0",

@ -23,6 +23,7 @@ import { ProtectedRouteProps } from "./partials/ProtectedRoute";
import ProtectedRoute from "./partials/ProtectedRoute"; import ProtectedRoute from "./partials/ProtectedRoute";
import "./index.css"; import "./index.css";
import { lightTheme } from "./utils/theme";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -80,7 +81,7 @@ export default function App() {
}, [dispatch, isAuthenticated]); }, [dispatch, isAuthenticated]);
return ( return (
<CssVarsProvider> <CssVarsProvider theme={lightTheme}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div> <div>
<Toaster /> <Toaster />

@ -1,4 +1,4 @@
import TextField from "../../global/FormElements/InputField"; import TextField from "../../global/FormElements/TextField";
const General = () => { const General = () => {
return ( return (

@ -1,66 +1,60 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { Formik } from "formik"; import { Formik } from "formik";
import * as yup from "yup";
import { XIcon } from "@heroicons/react/outline"; import { XIcon } from "@heroicons/react/outline";
import General from "./General"; import General from "./General";
import Environment from "./Environment"; import Environment from "./Environment";
import Volumes from "./Volumes"; import Volumes from "./Volumes";
import Labels from "./Labels"; import Labels from "./Labels";
import { serviceConfigCanvasInitialValues } from "../../../utils";
import { CallbackFunction } from "../../../types"; import { CallbackFunction } from "../../../types";
import {
getInitialValues,
getFinalValues,
validationSchema
} from "./form-utils";
interface IModalServiceProps { interface IModalServiceProps {
onHide: CallbackFunction; onHide: CallbackFunction;
onAddEndpoint: CallbackFunction; onAddEndpoint: CallbackFunction;
} }
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Environment",
href: "#",
current: false,
hidden: false
},
{
name: "Volumes",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const ModalServiceCreate = (props: IModalServiceProps) => { const ModalServiceCreate = (props: IModalServiceProps) => {
const { onHide, onAddEndpoint } = props; const { onHide, onAddEndpoint } = props;
const [openTab, setOpenTab] = useState("General"); const [openTab, setOpenTab] = useState("General");
const handleCreate = (values: any, formik: any) => { const handleCreate = (values: any, formik: any) => {
onAddEndpoint(values); // TODO: This modal should not be aware of endpoints. Seperation of concerns.
onAddEndpoint(getFinalValues(values));
formik.resetForm(); formik.resetForm();
}; };
const validationSchema = yup.object({
canvasConfig: yup.object({ const initialValues = useMemo(() => getInitialValues(), []);
node_name: yup
.string()
.max(256, "service name should be 256 characters or less")
.required("service name is required")
}),
serviceConfig: yup.object({
container_name: yup
.string()
.max(256, "container name should be 256 characters or less")
.required("container name is required")
})
});
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Environment",
href: "#",
current: false,
hidden: false
},
{
name: "Volumes",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const classNames = (...classes: string[]) => { const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
}; };
@ -87,19 +81,7 @@ const ModalServiceCreate = (props: IModalServiceProps) => {
</div> </div>
<Formik <Formik
initialValues={{ initialValues={initialValues}
canvasConfig: {
...serviceConfigCanvasInitialValues()
},
serviceConfig: {
container_name: ""
},
key: "service",
type: "SERVICE",
inputs: ["op_source"],
outputs: [],
config: {}
}}
enableReinitialize={true} enableReinitialize={true}
onSubmit={(values, formik) => { onSubmit={(values, formik) => {
handleCreate(values, formik); handleCreate(values, formik);

@ -1,75 +1,65 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { Formik } from "formik"; import { Formik } from "formik";
import * as yup from "yup";
import { XIcon } from "@heroicons/react/outline"; import { XIcon } from "@heroicons/react/outline";
import General from "./General"; import General from "./General";
import Environment from "./Environment"; import Environment from "./Environment";
import Volumes from "./Volumes"; import Volumes from "./Volumes";
import Labels from "./Labels"; import Labels from "./Labels";
import type { CallbackFunction, IServiceNodeItem } from "../../../types";
import { import {
CallbackFunction, getInitialValues,
ICanvasConfig, getFinalValues,
IService, validationSchema
IServiceNodeItem } from "./form-utils";
} from "../../../types";
interface IModalServiceProps { export interface IModalServiceProps {
node: IServiceNodeItem; node: IServiceNodeItem;
onHide: CallbackFunction; onHide: CallbackFunction;
onUpdateEndpoint: CallbackFunction; onUpdateEndpoint: CallbackFunction;
} }
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Environment",
href: "#",
current: false,
hidden: false
},
{
name: "Volumes",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const ModalServiceEdit = (props: IModalServiceProps) => { const ModalServiceEdit = (props: IModalServiceProps) => {
const { node, onHide, onUpdateEndpoint } = props; const { node, onHide, onUpdateEndpoint } = props;
const [openTab, setOpenTab] = useState("General"); const [openTab, setOpenTab] = useState("General");
const [selectedNode, setSelectedNode] = useState<IServiceNodeItem>(); const [selectedNode, setSelectedNode] = useState<IServiceNodeItem>();
const handleUpdate = (values: any) => { const handleUpdate = (values: any) => {
const updated = { ...selectedNode }; onUpdateEndpoint(getFinalValues(values, selectedNode));
updated.canvasConfig = values.canvasConfig;
updated.serviceConfig = values.serviceConfig;
onUpdateEndpoint(updated);
}; };
const validationSchema = yup.object({
canvasConfig: yup.object({ const initialValues = useMemo(
node_name: yup () => getInitialValues(selectedNode),
.string() [selectedNode]
.max(256, "service name should be 256 characters or less") );
.required("service name is required")
}),
serviceConfig: yup.object({
container_name: yup
.string()
.max(256, "container name should be 256 characters or less")
.required("container name is required")
})
});
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Environment",
href: "#",
current: false,
hidden: false
},
{
name: "Volumes",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const classNames = (...classes: string[]) => { const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
}; };
@ -103,18 +93,9 @@ const ModalServiceEdit = (props: IModalServiceProps) => {
{selectedNode && ( {selectedNode && (
<Formik <Formik
initialValues={{ initialValues={initialValues}
canvasConfig: {
...selectedNode.canvasConfig
} as ICanvasConfig,
serviceConfig: {
...selectedNode.serviceConfig
} as IService
}}
enableReinitialize={true} enableReinitialize={true}
onSubmit={(values) => { onSubmit={handleUpdate}
handleUpdate(values);
}}
validationSchema={validationSchema} validationSchema={validationSchema}
> >
{(formik) => ( {(formik) => (

@ -1,13 +1,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline"; import { PlusIcon } from "@heroicons/react/outline";
import { styled } from "@mui/joy"; import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik"; import { useFormikContext } from "formik";
import Record from "../../Record"; import Record from "../../Record";
import { IService } from "../../../types"; import { IEditServiceForm } from "../../../types";
const Root = styled("div")` const Root = styled("div")`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
`; `;
const Records = styled("div")` const Records = styled("div")`
@ -16,77 +17,90 @@ const Records = styled("div")`
row-gap: ${({ theme }: { theme: any }) => theme.spacing(1)}; row-gap: ${({ theme }: { theme: any }) => theme.spacing(1)};
`; `;
const AddButton = styled(Button)`
width: 140px;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const Description = styled("p")`
margin-top: ${({ theme }) => theme.spacing(2)};
text-align: center;
color: #7a7a7a;
font-size: 14px;
`;
const Environment = () => { const Environment = () => {
const formik = useFormikContext<{ const formik = useFormikContext<IEditServiceForm>();
serviceConfig: IService; const { environmentVariables } = formik.values;
}>();
const environment = (formik.values.serviceConfig.environment as []) || [];
const handleNewEnvironmentVariable = useCallback(() => { const handleNewEnvironmentVariable = useCallback(() => {
formik.setFieldValue(`serviceConfig.environment[${environment.length}]`, { formik.setFieldValue(
key: "", `environmentVariables[${environmentVariables.length}]`,
value: "" {
}); key: "",
value: ""
}
);
}, [formik]); }, [formik]);
const handleRemoveEnvironmentVariable = useCallback( const handleRemoveEnvironmentVariable = useCallback(
(index: number) => { (index: number) => {
const newEnvironmentVariables = environment.filter( const newEnvironmentVariables = environmentVariables.filter(
(_: unknown, currentIndex: number) => currentIndex != index (_: unknown, currentIndex: number) => currentIndex != index
); );
formik.setFieldValue( formik.setFieldValue("environmentVariables", newEnvironmentVariables);
`serviceConfig.environment`,
newEnvironmentVariables
);
}, },
[formik] [formik]
); );
const emptyEnvironmentVariables = const emptyEnvironmentVariables =
environment && environment.length === 0 ? true : false; environmentVariables && environmentVariables.length === 0 ? true : false;
return ( return (
<> <Root>
<Root {!emptyEnvironmentVariables && (
sx={{ alignItems: emptyEnvironmentVariables ? "center" : "flex-start" }} <Records>
> {environmentVariables.map((_: unknown, index: number) => (
{!emptyEnvironmentVariables && ( <Record
<Records> key={index}
{environment.map((_: unknown, index: number) => ( index={index}
<Record fields={[
key={index} {
index={index} name: `environmentVariables[${index}].key`,
formik={formik} placeholder: "Key",
fields={[ required: true,
{ type: "text"
name: `serviceConfig.environment[${index}].key`, },
placeholder: "Key" {
}, name: `environmentVariables[${index}].value`,
{ placeholder: "Value",
name: `serviceConfig.environment[${index}].value`, required: true,
placeholder: "Value" type: "text"
} }
]} ]}
onRemove={handleRemoveEnvironmentVariable} onRemove={handleRemoveEnvironmentVariable}
/> />
))} ))}
</Records> </Records>
)} )}
{emptyEnvironmentVariables && ( {emptyEnvironmentVariables && (
<p className="mt-4 text-md text-gray-500 dark:text-gray-400 text-center"> <Description>
add environment variables This service does not have any environment variables.
</p> <br />
)} Click "+ New variable" to add a new environment variable.
</Root> </Description>
)}
<div className="flex justify-end pt-2"> <AddButton
<button className="btn-util" onClick={handleNewEnvironmentVariable}> size="sm"
<PlusIcon className="h-4 w-4 mr-1" /> variant="plain"
New Variable onClick={handleNewEnvironmentVariable}
</button> >
</div> <PlusIcon className="h-4 w-4 mr-2" />
</> New variable
</AddButton>
</Root>
); );
}; };
export default Environment; export default Environment;

@ -1,10 +1,153 @@
import TextField from "../../global/FormElements/InputField"; import TextField from "../../global/FormElements/TextField";
import { useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline";
import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik";
import Record from "../../Record";
import { IEditServiceForm } from "../../../types";
const Fields = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
`;
const ImageNameGroup = styled("div")`
display: flex;
flex-direction: row;
column-gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const Group = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
`;
const GroupTitle = styled("h5")`
font-size: 0.75rem;
color: #374151;
font-weight: 500;
width: 100%;
text-align: left;
`;
const Records = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
`;
const AddButton = styled(Button)`
width: 140px;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const Description = styled("p")`
margin-top: ${({ theme }) => theme.spacing(2)};
text-align: center;
color: #7a7a7a;
font-size: 14px;
`;
const General = () => { const General = () => {
const formik = useFormikContext<IEditServiceForm>();
const { ports } = formik.values;
const handleNewPort = useCallback(() => {
formik.setFieldValue(`ports[${ports.length}]`, {
hostPort: "",
containerPort: "",
protocol: "tcp"
});
}, [formik]);
const handleRemovePort = useCallback(
(index: number) => {
const newPorts = ports.filter(
(_: unknown, currentIndex: number) => currentIndex != index
);
formik.setFieldValue(`ports`, newPorts);
},
[formik]
);
const emptyPorts = ports && ports.length === 0 ? true : false;
return ( return (
<> <>
<TextField label="Service name" name="canvasConfig.node_name" /> <Fields>
<TextField label="Container name" name="serviceConfig.container_name" /> <TextField label="Service name" name="serviceName" required={true} />
<ImageNameGroup>
<TextField
label="Image name"
name="imageName"
required={true}
style={{ minWidth: 400 }}
/>
<TextField label="Image tag" name="imageTag" />
</ImageNameGroup>
<TextField
label="Container name"
name="containerName"
required={true}
/>
<Group>
<GroupTitle>Ports</GroupTitle>
{!emptyPorts && (
<Records>
{ports.map((_: unknown, index: number) => (
<Record
key={index}
index={index}
fields={[
{
name: `ports[${index}].hostPort`,
placeholder: "Host Port",
required: true,
type: "text"
},
{
name: `ports[${index}].containerPort`,
placeholder: "Container Port",
type: "text"
},
{
name: `ports[${index}].protocol`,
placeholder: "Protocol",
type: "toggle",
options: [
{
value: "tcp",
text: "TCP"
},
{
value: "udp",
text: "UDP"
}
]
}
]}
onRemove={handleRemovePort}
/>
))}
</Records>
)}
{emptyPorts && (
<Description>
This service does not have any ports.
<br />
Click "+ New port" to add a new port.
</Description>
)}
<AddButton size="sm" variant="plain" onClick={handleNewPort}>
<PlusIcon className="h-4 w-4 mr-2" />
New port
</AddButton>
</Group>
</Fields>
</> </>
); );
}; };

@ -1,13 +1,14 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline"; import { PlusIcon } from "@heroicons/react/outline";
import { styled } from "@mui/joy"; import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik"; import { useFormikContext } from "formik";
import Record from "../../Record"; import Record from "../../Record";
import { IService } from "../../../types"; import { IEditServiceForm, IService } from "../../../types";
const Root = styled("div")` const Root = styled("div")`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
`; `;
const Records = styled("div")` const Records = styled("div")`
@ -16,14 +17,24 @@ const Records = styled("div")`
row-gap: ${({ theme }) => theme.spacing(1)}; row-gap: ${({ theme }) => theme.spacing(1)};
`; `;
const AddButton = styled(Button)`
width: 140px;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const Description = styled("p")`
margin-top: ${({ theme }) => theme.spacing(2)};
text-align: center;
color: #7a7a7a;
font-size: 14px;
`;
const Labels = () => { const Labels = () => {
const formik = useFormikContext<{ const formik = useFormikContext<IEditServiceForm>();
serviceConfig: IService; const { labels } = formik.values;
}>();
const labels = (formik.values.serviceConfig.labels as []) || [];
const handleNewLabel = useCallback(() => { const handleNewLabel = useCallback(() => {
formik.setFieldValue(`serviceConfig.labels[${labels.length}]`, { formik.setFieldValue(`labels[${labels.length}]`, {
key: "", key: "",
value: "" value: ""
}); });
@ -34,7 +45,7 @@ const Labels = () => {
const newLabels = labels.filter( const newLabels = labels.filter(
(_: unknown, currentIndex: number) => currentIndex != index (_: unknown, currentIndex: number) => currentIndex != index
); );
formik.setFieldValue(`serviceConfig.labels`, newLabels); formik.setFieldValue(`labels`, newLabels);
}, },
[formik] [formik]
); );
@ -42,44 +53,45 @@ const Labels = () => {
const emptyLabels = labels && labels.length === 0 ? true : false; const emptyLabels = labels && labels.length === 0 ? true : false;
return ( return (
<> <Root>
<Root sx={{ alignItems: emptyLabels ? "center" : "flex-start" }}> {!emptyLabels && (
{!emptyLabels && ( <Records>
<Records> {labels.map((_: unknown, index: number) => (
{labels.map((_: unknown, index: number) => ( <Record
<Record key={index}
key={index} index={index}
index={index} fields={[
formik={formik} {
fields={[ name: `labels[${index}].key`,
{ placeholder: "Key",
name: `serviceConfig.labels[${index}].key`, required: true,
placeholder: "Key" type: "text"
}, },
{ {
name: `serviceConfig.labels[${index}].value`, name: `labels[${index}].value`,
placeholder: "Value" placeholder: "Value",
} required: true,
]} type: "text"
onRemove={handleRemoveLabel} }
/> ]}
))} onRemove={handleRemoveLabel}
</Records> />
)} ))}
{emptyLabels && ( </Records>
<p className="mt-4 text-md text-gray-500 dark:text-gray-400 text-center"> )}
add labels {emptyLabels && (
</p> <Description>
)} This service does not have any labels.
</Root> <br />
Click "+ New label" to add a new label.
</Description>
)}
<div className="flex justify-end pt-2"> <AddButton size="sm" variant="plain" onClick={handleNewLabel}>
<button className="btn-util" onClick={handleNewLabel}> <PlusIcon className="h-4 w-4 mr-2" />
<PlusIcon className="h-4 w-4 mr-1" /> New label
New Labels </AddButton>
</button> </Root>
</div>
</>
); );
}; };

@ -1,5 +1,103 @@
import { useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline";
import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik";
import Record from "../../Record";
import { IEditServiceForm } from "../../../types";
const Root = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
`;
const Records = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
`;
const AddButton = styled(Button)`
width: 140px;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const Description = styled("p")`
margin-top: ${({ theme }) => theme.spacing(2)};
text-align: center;
color: #7a7a7a;
font-size: 14px;
`;
const Volumes = () => { const Volumes = () => {
return <></>; const formik = useFormikContext<IEditServiceForm>();
const { volumes } = formik.values;
const handleNewVolume = useCallback(() => {
formik.setFieldValue(`volumes[${volumes.length}]`, {
name: "",
containerPath: "",
accessMode: ""
});
}, [formik]);
const handleRemoveVolume = useCallback(
(index: number) => {
const newVolumes = volumes.filter(
(_: unknown, currentIndex: number) => currentIndex != index
);
formik.setFieldValue(`volumes`, newVolumes);
},
[formik]
);
const emptyVolumes = volumes && volumes.length === 0 ? true : false;
return (
<Root>
{!emptyVolumes && (
<Records>
{volumes.map((_: unknown, index: number) => (
<Record
key={index}
index={index}
fields={[
{
name: `volumes[${index}].name`,
placeholder: "Name",
required: true,
type: "text"
},
{
name: `volumes[${index}].containerPath`,
placeholder: "Container path",
type: "text"
},
{
name: `volumes[${index}].accessMode`,
placeholder: "Access mode",
type: "text"
}
]}
onRemove={handleRemoveVolume}
/>
))}
</Records>
)}
{emptyVolumes && (
<Description>
This service does not have any volumes.
<br />
Click "+ New volume" to add a new volume.
</Description>
)}
<AddButton size="sm" variant="plain" onClick={handleNewVolume}>
<PlusIcon className="h-4 w-4 mr-2" />
New volume
</AddButton>
</Root>
);
}; };
export default Volumes; export default Volumes;

@ -0,0 +1,200 @@
import type { IEditServiceForm, IServiceNodeItem } from "../../../types";
import * as yup from "yup";
import lodash from "lodash";
const initialValues: IEditServiceForm = {
imageName: "",
imageTag: "",
serviceName: "",
containerName: "",
ports: [],
environmentVariables: [],
volumes: [],
labels: []
};
yup.addMethod<yup.StringSchema>(yup.string, "port", function (message) {
return this.test("test-port", message, function (value):
| boolean
| yup.ValidationError {
const { path, createError } = this;
if (value) {
const result = parseInt(value, 10);
if (isNaN(result) || result < 0 || result > 65535) {
return createError({ path, message });
}
}
return true;
});
});
export const validationSchema = yup.object({
serviceName: yup
.string()
.max(256, "Service name should be 256 characters or less")
.required("Service name is required"),
imageName: yup
.string()
.max(256, "Image name should be 256 characters or less")
.required("Image name is required"),
imageTag: yup.string().max(256, "Image tag should be 256 characters or less"),
containerName: yup
.string()
.max(256, "Container name should be 256 characters or less")
.required("Container name is required"),
ports: yup.array(
yup.object({
hostPort: (yup.string().required("Host port is required") as any).port(
"Host port should be an integer in the range 0-65535"
),
containerPort: (yup.string() as any).port(
"Container port should be an integer in the range 0-65535"
),
protocol: yup
.string()
.oneOf(["tcp", "udp"], "Protocol should be tcp or udp")
})
),
environmentVariables: yup.array(
yup.object({
key: yup.string().required("Key is required"),
value: yup.string().required("Value is required")
})
),
volumes: yup.array(
yup.object({
name: yup.string().required("Name is required"),
containerPath: yup.string(),
accessMode: yup.string()
})
),
labels: yup.array(
yup.object({
key: yup.string().required("Key is required"),
value: yup.string().required("Value is required")
})
)
});
export const getInitialValues = (node?: IServiceNodeItem): IEditServiceForm => {
if (!node) {
return {
...initialValues
};
}
const { canvasConfig, serviceConfig } = node;
const { node_name = "" } = canvasConfig;
const {
image,
container_name = "",
environment,
volumes,
ports,
labels
} = serviceConfig;
const checkArray = <T>(array: any, name: string): T => {
if (!Array.isArray(array)) {
throw new Error(
`Looks like we encountered a bug. The current implementation expects "${name}" to be an array.`
);
}
return array as unknown as T;
};
const environment0: string[] = checkArray(environment, "environment");
const volumes0: string[] = checkArray(volumes, "volumes");
const ports0: string[] = checkArray(ports, "ports");
const labels0: string[] = checkArray(labels, "labels");
const [imageName, imageTag] = (image ?? ":").split(":");
return {
...initialValues,
imageName,
imageTag,
serviceName: node_name,
containerName: container_name,
environmentVariables: environment0.map((variable) => {
const [key, value] = variable.split(":");
return {
key,
value
};
}),
volumes: volumes0.map((volume) => {
const [name, containerPath, accessMode] = volume.split(":");
return {
name,
containerPath,
accessMode
};
}),
ports: ports0.map((port) => {
const slashIndex = port.lastIndexOf("/");
const protocol = slashIndex >= 0 ? port.substring(slashIndex + 1) : "";
const [hostPort, containerPort] = port
.substring(0, slashIndex)
.split(":");
if (!["tcp", "udp"].includes(protocol)) {
throw new Error(
`Invalid protocol "${protocol}" found while deserializing.`
);
}
return { hostPort, containerPort, protocol } as any;
}),
labels: labels0.map((label) => {
const [key, value] = label.split(":");
return {
key,
value
};
})
};
};
export const getFinalValues = (
values: IEditServiceForm,
previous?: IServiceNodeItem
): IServiceNodeItem => {
const { environmentVariables, ports, volumes, labels } = values;
return lodash.merge(
lodash.cloneDeep(previous) || {
key: "service",
type: "SERVICE",
inputs: ["op_source"],
outputs: [],
config: {}
},
{
canvasConfig: {
node_name: values.serviceName
},
serviceConfig: {
image: `${values.imageName}:${values.imageTag}`,
container_name: values.containerName,
environment: environmentVariables.map(
(variable) => `${variable.key}:${variable.value}`
),
volumes: volumes.map(
(volume) =>
volume.name +
(volume.containerPath ? `:${volume.containerPath}` : "") +
(volume.accessMode ? `:${volume.accessMode}` : "")
),
ports: ports.map(
(port) =>
port.hostPort +
(port.containerPort ? `:${port.containerPort}` : "") +
(port.protocol ? `/${port.protocol}` : "")
),
labels: labels.map((label) => `${label.key}:${label.value}`)
}
}
) as any;
};

@ -1,4 +1,4 @@
import TextField from "../../global/FormElements/InputField"; import TextField from "../../global/FormElements/TextField";
const General = () => { const General = () => {
return ( return (

@ -2,7 +2,8 @@ import { useEffect, useState, useRef, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { debounce, Dictionary, omit } from "lodash"; import { debounce, Dictionary, omit } from "lodash";
import YAML from "yaml"; import YAML from "yaml";
import { PlusIcon } from "@heroicons/react/solid"; import { GlobeAltIcon, CubeIcon, FolderAddIcon } from "@heroicons/react/solid";
import randomWords from "random-words";
import { import {
IProjectPayload, IProjectPayload,
IServiceNodeItem, IServiceNodeItem,
@ -70,7 +71,15 @@ export default function Project() {
const [nodes, setNodes] = useState({}); const [nodes, setNodes] = useState({});
const [connections, setConnections] = useState<[[string, string]] | []>([]); const [connections, setConnections] = useState<[[string, string]] | []>([]);
const [networks, setNetworks] = useState<Record<string, any>>({}); const [networks, setNetworks] = useState<Record<string, any>>({});
const [projectName, setProjectName] = useState("Untitled"); const [projectName, setProjectName] = useState(
() =>
randomWords({
wordsPerString: 2,
exactly: 1,
separator: "-"
} as any)[0]
);
const [canvasPosition, setCanvasPosition] = useState({ const [canvasPosition, setCanvasPosition] = useState({
top: 0, top: 0,
left: 0, left: 0,
@ -398,7 +407,7 @@ export default function Project() {
focus:ring-0 focus:ring-0
`} `}
type="text" type="text"
placeholder="Untitled" placeholder="Project name"
autoComplete="off" autoComplete="off"
id="name" id="name"
name="name" name="name"
@ -415,7 +424,7 @@ export default function Project() {
className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto" className="btn-util text-black bg-gray-200 hover:bg-gray-300 sm:w-auto"
> >
<div className="flex justify-center items-center space-x-2 mx-auto"> <div className="flex justify-center items-center space-x-2 mx-auto">
<span>New</span> <span>New project</span>
</div> </div>
</button> </button>
@ -451,8 +460,8 @@ export default function Project() {
type="button" type="button"
onClick={() => setShowModalCreateService(true)} onClick={() => setShowModalCreateService(true)}
> >
<PlusIcon className="w-3" /> <CubeIcon className="w-4" />
<span>Service</span> <span>Add service</span>
</button> </button>
<button <button
@ -460,8 +469,8 @@ export default function Project() {
type="button" type="button"
onClick={() => setShowVolumesModal(true)} onClick={() => setShowVolumesModal(true)}
> >
<PlusIcon className="w-3" /> <FolderAddIcon className="w-4" />
<span>Volume</span> <span>Add volume</span>
</button> </button>
<button <button
@ -469,7 +478,8 @@ export default function Project() {
type="button" type="button"
onClick={() => setShowNetworksModal(true)} onClick={() => setShowNetworksModal(true)}
> >
<span>Networks</span> <GlobeAltIcon className="w-4" />
<span>Add Network</span>
</button> </button>
</div> </div>
</div> </div>

@ -1,16 +1,22 @@
import { FunctionComponent, ReactElement, useCallback } from "react"; import { Fragment, FunctionComponent, ReactElement, useCallback } from "react";
import { styled } from "@mui/joy"; import { styled } from "@mui/joy";
import IconButton from "@mui/joy/IconButton"; import IconButton from "@mui/joy/IconButton";
import { MinusSmIcon } from "@heroicons/react/solid"; import { MinusSmIcon } from "@heroicons/react/solid";
import lodash from "lodash"; import TextField from "./global/FormElements/TextField";
import Toggle from "./global/FormElements/Toggle";
export interface IFieldType { export interface IFieldType {
name: string; name: string;
placeholder: string; placeholder: string;
required?: boolean;
type: "text" | "toggle";
options?: {
text: string;
value: string;
}[];
} }
export interface IRecordProps { export interface IRecordProps {
formik: any;
fields: IFieldType[]; fields: IFieldType[];
index: number; index: number;
onRemove: (index: number) => void; onRemove: (index: number) => void;
@ -20,7 +26,7 @@ const Root = styled("div")`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: flex-start;
column-gap: ${({ theme }) => theme.spacing(2)}; column-gap: ${({ theme }) => theme.spacing(2)};
`; `;
@ -29,7 +35,7 @@ const RemoveButton = styled(IconButton)``;
const Record: FunctionComponent<IRecordProps> = ( const Record: FunctionComponent<IRecordProps> = (
props: IRecordProps props: IRecordProps
): ReactElement => { ): ReactElement => {
const { formik, fields, index, onRemove } = props; const { fields, index, onRemove } = props;
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
onRemove(index); onRemove(index);
@ -37,18 +43,20 @@ const Record: FunctionComponent<IRecordProps> = (
return ( return (
<Root> <Root>
{fields.map(({ name, placeholder }) => ( {fields.map(({ type, name, placeholder, required, options }) => (
<input <Fragment key={name}>
key={name} {type === "text" && (
id={name} <TextField
name={name} id={name}
type="text" name={name}
placeholder={placeholder} placeholder={placeholder + (required ? "*" : "")}
autoComplete="none" required={required}
className="input-util" />
onChange={formik.handleChange} )}
value={lodash.get(formik.values, name)} {type === "toggle" && (
/> <Toggle name={name} label={placeholder} options={options || []} />
)}
</Fragment>
))} ))}
<RemoveButton <RemoveButton
variant="soft" variant="soft"

@ -1,50 +0,0 @@
import _ from "lodash";
import { useFormikContext } from "formik";
interface Props {
name: string;
help?: string;
[key: string]: any;
}
const TextField = (props: Props) => {
const { label, name, help, ...otherProps } = props;
const formik = useFormikContext();
const error = _.get(formik.touched, name) && _.get(formik.errors, name);
return (
<div className="relative pb-3 flex-auto">
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3">
<label
htmlFor={name}
className="block text-xs font-medium text-gray-700"
>
{label}
</label>
<div className="mt-1">
<input
id={name}
name={name}
type="text"
autoComplete="none"
className="input-util"
onBlur={formik.handleBlur}
onChange={formik.handleChange}
{...otherProps}
value={_.get(formik.values, name)}
/>
{
<div className="mt-1 text-xs text-red-600">
{error && <div className="caption">{error}</div>}
{!error && help}
</div>
}
</div>
</div>
</div>
</div>
);
};
export default TextField;

@ -0,0 +1,57 @@
import lodash from "lodash";
import { useFormikContext } from "formik";
import { FunctionComponent, ReactElement } from "react";
import { styled } from "@mui/joy";
export interface ITextFieldProps {
name: string;
help?: string;
[key: string]: any;
}
const Root = styled("div")`
display: flex;
flex-direction: column;
`;
const TextField: FunctionComponent<ITextFieldProps> = (
props: ITextFieldProps
): ReactElement => {
const { label, name, help, required, ...otherProps } = props;
const formik = useFormikContext();
const error =
lodash.get(formik.touched, name) && lodash.get(formik.errors, name);
return (
<Root>
{label && (
<label
htmlFor={name}
className="block text-xs font-medium text-gray-700"
>
{label + (required ? "*" : "")}
</label>
)}
<input
id={name}
name={name}
type="text"
autoComplete="none"
className="input-util mt-1"
required={required}
onBlur={formik.handleBlur}
onChange={formik.handleChange}
value={lodash.get(formik.values, name)}
{...otherProps}
/>
<div className="mt-1 text-xs text-red-600">
{error && <span className="caption">{error}</span>}
{!error && help}
</div>
</Root>
);
};
export default TextField;

@ -0,0 +1,72 @@
import lodash from "lodash";
import { useFormikContext } from "formik";
import { FunctionComponent, ReactElement, useCallback } from "react";
import { Button, styled } from "@mui/joy";
export interface IToggleProps {
name: string;
label: string;
help?: string;
options: {
text: string;
value: string;
}[];
}
interface IToggleButtonProps {
index: number;
total: number;
}
const Root = styled("div")`
display: flex;
flex-direction: row;
`;
const ToggleButton = styled(Button)<IToggleButtonProps>(({ index, total }) => ({
borderRadius: `
${index === 0 ? "8px" : "0px"}
${index === total - 1 ? "8px" : "0px"}
${index === total - 1 ? "8px" : "0px"}
${index === 0 ? "8px" : "0px"}
`,
fontSize: 11
}));
const Toggle: FunctionComponent<IToggleProps> = (
props: IToggleProps
): ReactElement => {
const { name, help, options } = props;
const formik = useFormikContext();
const error =
lodash.get(formik.touched, name) && lodash.get(formik.errors, name);
const value = lodash.get(formik.values, name);
const handleChange = (value: string) => () => {
formik.setFieldValue(name, value);
};
return (
<Root>
{options.map((option, index) => (
<ToggleButton
variant={value === option.value ? "solid" : "soft"}
size="sm"
color="primary"
index={index}
total={options.length}
onClick={handleChange(option.value)}
>
{option.text}
</ToggleButton>
))}
<div className="mt-1 text-xs text-red-600">
{error && <span className="caption">{error}</span>}
{!error && help}
</div>
</Root>
);
};
export default Toggle;

@ -352,3 +352,28 @@ export interface IGeneratePayload {
volumes: Record<string, Partial<IVolumeTopLevel>>; volumes: Record<string, Partial<IVolumeTopLevel>>;
}; };
} }
export interface IEditServiceForm {
serviceName: string;
imageName: string;
imageTag: string;
containerName: string;
ports: {
hostPort: string;
containerPort: string;
protocol: "tcp" | "udp";
}[];
environmentVariables: {
key: string;
value: string;
}[];
volumes: {
name: string;
containerPath: string;
accessMode: string;
}[];
labels: {
key: string;
value: string;
}[];
}

@ -0,0 +1,13 @@
import { extendTheme } from "@mui/joy/styles";
export const lightTheme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: {
mainChannel: "#4f46e5"
}
}
}
}
});

@ -8283,6 +8283,11 @@ raf@^3.4.1:
dependencies: dependencies:
performance-now "^2.1.0" performance-now "^2.1.0"
random-words@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/random-words/-/random-words-1.2.0.tgz#94d0cc8061965efe955d60b80ad93392a7edf2f5"
integrity sha512-YP2bXrT19pxtBh22DK9CLcWsmBjUBAGzw3JWJycTNbXm1+0aS6PrKuAJ9aLT0GGaPlPp9LExfJIMVkzhrDZE6g==
randombytes@^2.1.0: randombytes@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz"

Loading…
Cancel
Save