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",
"formik": "^2.2.9",
"lodash": "^4.17.21",
"random-words": "^1.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hot-toast": "^2.2.0",

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

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

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

@ -1,50 +1,25 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Formik } from "formik";
import * as yup from "yup";
import { XIcon } from "@heroicons/react/outline";
import General from "./General";
import Environment from "./Environment";
import Volumes from "./Volumes";
import Labels from "./Labels";
import type { CallbackFunction, IServiceNodeItem } from "../../../types";
import {
CallbackFunction,
ICanvasConfig,
IService,
IServiceNodeItem
} from "../../../types";
getInitialValues,
getFinalValues,
validationSchema
} from "./form-utils";
interface IModalServiceProps {
export interface IModalServiceProps {
node: IServiceNodeItem;
onHide: CallbackFunction;
onUpdateEndpoint: CallbackFunction;
}
const ModalServiceEdit = (props: IModalServiceProps) => {
const { node, onHide, onUpdateEndpoint } = props;
const [openTab, setOpenTab] = useState("General");
const [selectedNode, setSelectedNode] = useState<IServiceNodeItem>();
const handleUpdate = (values: any) => {
const updated = { ...selectedNode };
updated.canvasConfig = values.canvasConfig;
updated.serviceConfig = values.serviceConfig;
onUpdateEndpoint(updated);
};
const validationSchema = yup.object({
canvasConfig: yup.object({
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 = [
const tabs = [
{
name: "General",
href: "#",
@ -69,7 +44,22 @@ const ModalServiceEdit = (props: IModalServiceProps) => {
current: false,
hidden: false
}
];
];
const ModalServiceEdit = (props: IModalServiceProps) => {
const { node, onHide, onUpdateEndpoint } = props;
const [openTab, setOpenTab] = useState("General");
const [selectedNode, setSelectedNode] = useState<IServiceNodeItem>();
const handleUpdate = (values: any) => {
onUpdateEndpoint(getFinalValues(values, selectedNode));
};
const initialValues = useMemo(
() => getInitialValues(selectedNode),
[selectedNode]
);
const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
@ -103,18 +93,9 @@ const ModalServiceEdit = (props: IModalServiceProps) => {
{selectedNode && (
<Formik
initialValues={{
canvasConfig: {
...selectedNode.canvasConfig
} as ICanvasConfig,
serviceConfig: {
...selectedNode.serviceConfig
} as IService
}}
initialValues={initialValues}
enableReinitialize={true}
onSubmit={(values) => {
handleUpdate(values);
}}
onSubmit={handleUpdate}
validationSchema={validationSchema}
>
{(formik) => (

@ -1,13 +1,14 @@
import { useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline";
import { styled } from "@mui/joy";
import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik";
import Record from "../../Record";
import { IService } from "../../../types";
import { IEditServiceForm } from "../../../types";
const Root = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
`;
const Records = styled("div")`
@ -16,55 +17,65 @@ const Records = styled("div")`
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 formik = useFormikContext<{
serviceConfig: IService;
}>();
const environment = (formik.values.serviceConfig.environment as []) || [];
const formik = useFormikContext<IEditServiceForm>();
const { environmentVariables } = formik.values;
const handleNewEnvironmentVariable = useCallback(() => {
formik.setFieldValue(`serviceConfig.environment[${environment.length}]`, {
formik.setFieldValue(
`environmentVariables[${environmentVariables.length}]`,
{
key: "",
value: ""
});
}
);
}, [formik]);
const handleRemoveEnvironmentVariable = useCallback(
(index: number) => {
const newEnvironmentVariables = environment.filter(
const newEnvironmentVariables = environmentVariables.filter(
(_: unknown, currentIndex: number) => currentIndex != index
);
formik.setFieldValue(
`serviceConfig.environment`,
newEnvironmentVariables
);
formik.setFieldValue("environmentVariables", newEnvironmentVariables);
},
[formik]
);
const emptyEnvironmentVariables =
environment && environment.length === 0 ? true : false;
environmentVariables && environmentVariables.length === 0 ? true : false;
return (
<>
<Root
sx={{ alignItems: emptyEnvironmentVariables ? "center" : "flex-start" }}
>
<Root>
{!emptyEnvironmentVariables && (
<Records>
{environment.map((_: unknown, index: number) => (
{environmentVariables.map((_: unknown, index: number) => (
<Record
key={index}
index={index}
formik={formik}
fields={[
{
name: `serviceConfig.environment[${index}].key`,
placeholder: "Key"
name: `environmentVariables[${index}].key`,
placeholder: "Key",
required: true,
type: "text"
},
{
name: `serviceConfig.environment[${index}].value`,
placeholder: "Value"
name: `environmentVariables[${index}].value`,
placeholder: "Value",
required: true,
type: "text"
}
]}
onRemove={handleRemoveEnvironmentVariable}
@ -74,19 +85,22 @@ const Environment = () => {
)}
{emptyEnvironmentVariables && (
<p className="mt-4 text-md text-gray-500 dark:text-gray-400 text-center">
add environment variables
</p>
<Description>
This service does not have any environment variables.
<br />
Click "+ New variable" to add a new environment variable.
</Description>
)}
</Root>
<div className="flex justify-end pt-2">
<button className="btn-util" onClick={handleNewEnvironmentVariable}>
<PlusIcon className="h-4 w-4 mr-1" />
New Variable
</button>
</div>
</>
<AddButton
size="sm"
variant="plain"
onClick={handleNewEnvironmentVariable}
>
<PlusIcon className="h-4 w-4 mr-2" />
New variable
</AddButton>
</Root>
);
};
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 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 (
<>
<TextField label="Service name" name="canvasConfig.node_name" />
<TextField label="Container name" name="serviceConfig.container_name" />
<Fields>
<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 { PlusIcon } from "@heroicons/react/outline";
import { styled } from "@mui/joy";
import { Button, styled } from "@mui/joy";
import { useFormikContext } from "formik";
import Record from "../../Record";
import { IService } from "../../../types";
import { IEditServiceForm, IService } from "../../../types";
const Root = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
`;
const Records = styled("div")`
@ -16,14 +17,24 @@ const Records = styled("div")`
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 formik = useFormikContext<{
serviceConfig: IService;
}>();
const labels = (formik.values.serviceConfig.labels as []) || [];
const formik = useFormikContext<IEditServiceForm>();
const { labels } = formik.values;
const handleNewLabel = useCallback(() => {
formik.setFieldValue(`serviceConfig.labels[${labels.length}]`, {
formik.setFieldValue(`labels[${labels.length}]`, {
key: "",
value: ""
});
@ -34,7 +45,7 @@ const Labels = () => {
const newLabels = labels.filter(
(_: unknown, currentIndex: number) => currentIndex != index
);
formik.setFieldValue(`serviceConfig.labels`, newLabels);
formik.setFieldValue(`labels`, newLabels);
},
[formik]
);
@ -42,23 +53,25 @@ const Labels = () => {
const emptyLabels = labels && labels.length === 0 ? true : false;
return (
<>
<Root sx={{ alignItems: emptyLabels ? "center" : "flex-start" }}>
<Root>
{!emptyLabels && (
<Records>
{labels.map((_: unknown, index: number) => (
<Record
key={index}
index={index}
formik={formik}
fields={[
{
name: `serviceConfig.labels[${index}].key`,
placeholder: "Key"
name: `labels[${index}].key`,
placeholder: "Key",
required: true,
type: "text"
},
{
name: `serviceConfig.labels[${index}].value`,
placeholder: "Value"
name: `labels[${index}].value`,
placeholder: "Value",
required: true,
type: "text"
}
]}
onRemove={handleRemoveLabel}
@ -67,19 +80,18 @@ const Labels = () => {
</Records>
)}
{emptyLabels && (
<p className="mt-4 text-md text-gray-500 dark:text-gray-400 text-center">
add labels
</p>
<Description>
This service does not have any labels.
<br />
Click "+ New label" to add a new label.
</Description>
)}
</Root>
<div className="flex justify-end pt-2">
<button className="btn-util" onClick={handleNewLabel}>
<PlusIcon className="h-4 w-4 mr-1" />
New Labels
</button>
</div>
</>
<AddButton size="sm" variant="plain" onClick={handleNewLabel}>
<PlusIcon className="h-4 w-4 mr-2" />
New label
</AddButton>
</Root>
);
};

@ -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 = () => {
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;

@ -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 = () => {
return (

@ -2,7 +2,8 @@ import { useEffect, useState, useRef, useMemo } from "react";
import { useParams } from "react-router-dom";
import { debounce, Dictionary, omit } from "lodash";
import YAML from "yaml";
import { PlusIcon } from "@heroicons/react/solid";
import { GlobeAltIcon, CubeIcon, FolderAddIcon } from "@heroicons/react/solid";
import randomWords from "random-words";
import {
IProjectPayload,
IServiceNodeItem,
@ -70,7 +71,15 @@ export default function Project() {
const [nodes, setNodes] = useState({});
const [connections, setConnections] = useState<[[string, string]] | []>([]);
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({
top: 0,
left: 0,
@ -398,7 +407,7 @@ export default function Project() {
focus:ring-0
`}
type="text"
placeholder="Untitled"
placeholder="Project name"
autoComplete="off"
id="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"
>
<div className="flex justify-center items-center space-x-2 mx-auto">
<span>New</span>
<span>New project</span>
</div>
</button>
@ -451,8 +460,8 @@ export default function Project() {
type="button"
onClick={() => setShowModalCreateService(true)}
>
<PlusIcon className="w-3" />
<span>Service</span>
<CubeIcon className="w-4" />
<span>Add service</span>
</button>
<button
@ -460,8 +469,8 @@ export default function Project() {
type="button"
onClick={() => setShowVolumesModal(true)}
>
<PlusIcon className="w-3" />
<span>Volume</span>
<FolderAddIcon className="w-4" />
<span>Add volume</span>
</button>
<button
@ -469,7 +478,8 @@ export default function Project() {
type="button"
onClick={() => setShowNetworksModal(true)}
>
<span>Networks</span>
<GlobeAltIcon className="w-4" />
<span>Add Network</span>
</button>
</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 IconButton from "@mui/joy/IconButton";
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 {
name: string;
placeholder: string;
required?: boolean;
type: "text" | "toggle";
options?: {
text: string;
value: string;
}[];
}
export interface IRecordProps {
formik: any;
fields: IFieldType[];
index: number;
onRemove: (index: number) => void;
@ -20,7 +26,7 @@ const Root = styled("div")`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
align-items: flex-start;
column-gap: ${({ theme }) => theme.spacing(2)};
`;
@ -29,7 +35,7 @@ const RemoveButton = styled(IconButton)``;
const Record: FunctionComponent<IRecordProps> = (
props: IRecordProps
): ReactElement => {
const { formik, fields, index, onRemove } = props;
const { fields, index, onRemove } = props;
const handleRemove = useCallback(() => {
onRemove(index);
@ -37,18 +43,20 @@ const Record: FunctionComponent<IRecordProps> = (
return (
<Root>
{fields.map(({ name, placeholder }) => (
<input
key={name}
{fields.map(({ type, name, placeholder, required, options }) => (
<Fragment key={name}>
{type === "text" && (
<TextField
id={name}
name={name}
type="text"
placeholder={placeholder}
autoComplete="none"
className="input-util"
onChange={formik.handleChange}
value={lodash.get(formik.values, name)}
placeholder={placeholder + (required ? "*" : "")}
required={required}
/>
)}
{type === "toggle" && (
<Toggle name={name} label={placeholder} options={options || []} />
)}
</Fragment>
))}
<RemoveButton
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>>;
};
}
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:
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:
version "2.1.0"
resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz"

Loading…
Cancel
Save