Merge pull request #78 from nuxxapp/feat/network-forms

Created network forms
pull/79/head
Samuel Rowe 3 years ago committed by GitHub
commit 859ddc0398
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,133 +0,0 @@
import { useState } from "react";
import { Formik } from "formik";
import * as yup from "yup";
import {
topLevelNetworkConfigInitialValues,
networkConfigCanvasInitialValues
} from "../../../utils";
import General from "./General";
import IPam from "./IPam";
import Labels from "./Labels";
import { CallbackFunction } from "../../../types";
interface INetworkCreateProps {
onCreateNetwork: CallbackFunction;
}
const NetworkCreate = (props: INetworkCreateProps) => {
const { onCreateNetwork } = props;
const [openTab, setOpenTab] = useState("General");
const handleCreate = (values: any, formik: any) => {
onCreateNetwork(values);
formik.resetForm();
};
const validationSchema = yup.object({
canvasConfig: yup.object({
node_name: yup
.string()
.max(256, "network name should be 256 characters or less")
.required("network name is required")
}),
networkConfig: yup.object({
name: yup
.string()
.max(256, "name should be 256 characters or less")
.required("name is required")
})
});
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Ipam",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
return (
<Formik
initialValues={{
canvasConfig: {
...networkConfigCanvasInitialValues()
},
networkConfig: {
...topLevelNetworkConfigInitialValues()
},
key: "network",
type: "NETWORK"
}}
enableReinitialize={true}
onSubmit={(values, formik) => {
handleCreate(values, formik);
}}
validationSchema={validationSchema}
>
{(formik) => (
<>
<div className="border-b border-gray-200 px-4 md:px-8">
<nav
className="-mb-px flex space-x-4 md:space-x-8"
aria-label="Tabs"
>
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={classNames(
tab.name === openTab
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
tab.hidden ? "hidden" : ""
)}
aria-current={tab.current ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
setOpenTab(tab.name);
}}
>
{tab.name}
</a>
))}
</nav>
</div>
<div className="relative px-4 py-3 flex-auto">
{openTab === "General" && <General />}
{openTab === "IPam" && <IPam />}
{openTab === "Labels" && <Labels />}
</div>
<div className="flex items-center justify-end px-4 py-3 border-t border-solid border-blueGray-200 rounded-b">
<button
className="btn-util"
type="button"
onClick={() => {
formik.submitForm();
}}
>
Create
</button>
</div>
</>
)}
</Formik>
);
};
export default NetworkCreate;

@ -1,144 +0,0 @@
import { useState } from "react";
import { Formik } from "formik";
import * as yup from "yup";
import { TrashIcon } from "@heroicons/react/outline";
import General from "./General";
import IPam from "./IPam";
import Labels from "./Labels";
import {
CallbackFunction,
ICanvasConfig,
INetworkTopLevel
} from "../../../types";
interface INetworkEditProps {
onUpdateNetwork: CallbackFunction;
onDeleteNetwork: CallbackFunction;
network: any;
}
const NetworkEdit = (props: INetworkEditProps) => {
const { onUpdateNetwork, onDeleteNetwork, network } = props;
const [openTab, setOpenTab] = useState("General");
const handleUpdate = (values: any) => {
const updated = { ...network };
updated.canvasConfig = values.canvasConfig;
updated.networkConfig = values.networkConfig;
onUpdateNetwork(updated);
};
const validationSchema = yup.object({
canvasConfig: yup.object({
node_name: yup
.string()
.max(256, "network name should be 256 characters or less")
.required("network name is required")
}),
networkConfig: yup.object({
name: yup
.string()
.max(256, "name should be 256 characters or less")
.required("name is required")
})
});
const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "Ipam",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
return (
<Formik
initialValues={{
canvasConfig: {
...network.canvasConfig
} as ICanvasConfig,
networkConfig: {
...network.networkConfig
} as INetworkTopLevel
}}
enableReinitialize={true}
onSubmit={(values) => {
handleUpdate(values);
}}
validationSchema={validationSchema}
>
{(formik) => (
<>
<div className="border-b border-gray-200 px-4 md:px-8">
<nav
className="-mb-px flex space-x-4 md:space-x-8"
aria-label="Tabs"
>
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={classNames(
tab.name === openTab
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
tab.hidden ? "hidden" : ""
)}
aria-current={tab.current ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
setOpenTab(tab.name);
}}
>
{tab.name}
</a>
))}
</nav>
</div>
<div className="relative px-4 py-3 flex-auto">
{openTab === "General" && <General />}
{openTab === "IPam" && <IPam />}
{openTab === "Labels" && <Labels />}
</div>
<div className="flex justify-between items-center justify-end px-4 py-3 border-t border-solid border-blueGray-200 rounded-b">
<button
className="btn-util-red"
type="button"
onClick={onDeleteNetwork}
>
<TrashIcon className="w-4 h-4" />
</button>
<button
className="btn-util"
type="button"
onClick={() => {
formik.submitForm();
}}
>
Update
</button>
</div>
</>
)}
</Formik>
);
};
export default NetworkEdit;

@ -1,12 +0,0 @@
import TextField from "../../global/FormElements/TextField";
const General = () => {
return (
<>
<TextField label="Network name" name="canvasConfig.node_name" />
<TextField label="Name" name="networkConfig.name" />
</>
);
};
export default General;

@ -1,5 +0,0 @@
const IPam = () => {
return <></>;
};
export default IPam;

@ -1,5 +0,0 @@
const Labels = () => {
return <></>;
};
export default Labels;

@ -1,118 +0,0 @@
import { useState } from "react";
import { XIcon } from "@heroicons/react/outline";
import NetworkCreate from "./Create";
import { CallbackFunction } from "../../../types";
import NetworkEdit from "./Edit";
import { attachUUID } from "../../../utils";
interface IModalNetworkProps {
networks: Record<string, any>;
onCreateNetwork: CallbackFunction;
onUpdateNetwork: CallbackFunction;
onDeleteNetwork: CallbackFunction;
onHide: CallbackFunction;
}
const ModalNetwork = (props: IModalNetworkProps) => {
const {
networks,
onCreateNetwork,
onUpdateNetwork,
onDeleteNetwork,
onHide
} = props;
const [selectedNetwork, setSelectedNetwork] = useState<any | null>();
const handleCreate = (values: any) => {
const uniqueKey = attachUUID(values.key);
const network = {
...values,
key: uniqueKey
};
onCreateNetwork(network);
setSelectedNetwork(network);
};
const handleUpdate = (values: any) => {
onUpdateNetwork(values);
setSelectedNetwork(values);
};
const handleDelete = () => {
onDeleteNetwork(selectedNetwork.key);
setSelectedNetwork(null);
};
const handleNew = () => {
setSelectedNetwork(null);
};
const onNetworkSelect = (e: any) => {
const networkUuid = e.target.value;
setSelectedNetwork(networks[networkUuid]);
};
return (
<div className="fixed z-50 inset-0 overflow-y-auto">
<div className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 outline-none focus:outline-none">
<div
onClick={onHide}
className="opacity-25 fixed inset-0 z-40 bg-black"
></div>
<div className="relative w-auto my-6 mx-auto max-w-5xl z-50">
<div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none">
<div className="flex items-center justify-between px-4 py-3 border-b border-solid border-blueGray-200 rounded-t">
<h3 className="text-sm font-semibold">Top level networks</h3>
<button
className="p-1 ml-auto text-black float-right outline-none focus:outline-none"
onClick={onHide}
>
<span className="block outline-none focus:outline-none">
<XIcon className="w-4" />
</span>
</button>
</div>
{networks && Object.keys(networks).length > 0 && (
<div className="flex flex-row space-x-1 mx-4 mt-2">
<select
id="network"
name="network"
className="input-util"
value={selectedNetwork ? selectedNetwork.key : ""}
onChange={onNetworkSelect}
>
<option>select network to edit</option>
{Object.keys(networks).map((networkUuid: string) => (
<option value={networkUuid} key={networkUuid}>
{networks[networkUuid].canvasConfig.node_name}
</option>
))}
</select>
{selectedNetwork && (
<button
className="btn-util"
type="button"
onClick={handleNew}
>
New
</button>
)}
</div>
)}
{!selectedNetwork && (
<NetworkCreate onCreateNetwork={handleCreate} />
)}
{selectedNetwork && (
<NetworkEdit
network={selectedNetwork}
onUpdateNetwork={handleUpdate}
onDeleteNetwork={handleDelete}
/>
)}
</div>
</div>
</div>
</div>
);
};
export default ModalNetwork;

@ -0,0 +1,84 @@
import { FunctionComponent, ReactElement, useMemo, useState } from "react";
import { Formik } from "formik";
import General from "./General";
import IPam from "./IPam";
import Labels from "./Labels";
import { CallbackFunction } from "../../../types";
import { getInitialValues, tabs, validationSchema } from "./form-utils";
import { classNames } from "../../../utils/styles";
import { Button, styled } from "@mui/joy";
interface ICreateNetworkModalProps {
onCreateNetwork: CallbackFunction;
}
const Actions = styled("div")`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
const CreateNetworkModal: FunctionComponent<ICreateNetworkModalProps> = (
props: ICreateNetworkModalProps
): ReactElement => {
const { onCreateNetwork } = props;
const [openTab, setOpenTab] = useState("General");
const initialValues = useMemo(() => getInitialValues(), []);
return (
<Formik
initialValues={initialValues}
enableReinitialize={true}
onSubmit={onCreateNetwork}
validationSchema={validationSchema}
>
{(formik) => (
<>
<div className="border-b border-gray-200 px-4 md:px-8">
<nav
className="-mb-px flex space-x-4 md:space-x-8"
aria-label="Tabs"
>
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={classNames(
tab.name === openTab
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
tab.hidden ? "hidden" : ""
)}
aria-current={tab.current ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
setOpenTab(tab.name);
}}
>
{tab.name}
</a>
))}
</nav>
</div>
<div className="relative px-4 py-3 flex-auto">
{openTab === "General" && <General />}
{openTab === "IPAM" && <IPam />}
{openTab === "Labels" && <Labels />}
</div>
<Actions>
<Button size="sm" variant="solid" onClick={formik.submitForm}>
Save
</Button>
</Actions>
</>
)}
</Formik>
);
};
export default CreateNetworkModal;

@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { Formik } from "formik";
import General from "./General";
import IPam from "./IPam";
import Labels from "./Labels";
import { CallbackFunction } from "../../../types";
import { getInitialValues, tabs, validationSchema } from "./form-utils";
import { classNames } from "../../../utils/styles";
import { Button, styled } from "@mui/joy";
interface IEditNetworkModalProps {
onUpdateNetwork: CallbackFunction;
network: any;
}
const Actions = styled("div")`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
const EditNetworkModal = (props: IEditNetworkModalProps) => {
const { onUpdateNetwork, network } = props;
const [openTab, setOpenTab] = useState("General");
const initialValues = useMemo(() => getInitialValues(network), [network]);
return (
<Formik
initialValues={initialValues}
enableReinitialize={true}
onSubmit={onUpdateNetwork}
validationSchema={validationSchema}
>
{(formik) => (
<>
<div className="border-b border-gray-200 px-4 md:px-8">
<nav
className="-mb-px flex space-x-4 md:space-x-8"
aria-label="Tabs"
>
{tabs.map((tab) => (
<a
key={tab.name}
href={tab.href}
className={classNames(
tab.name === openTab
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
tab.hidden ? "hidden" : ""
)}
aria-current={tab.current ? "page" : undefined}
onClick={(e) => {
e.preventDefault();
setOpenTab(tab.name);
}}
>
{tab.name}
</a>
))}
</nav>
</div>
<div className="relative px-4 py-3 flex-auto">
{openTab === "General" && <General />}
{openTab === "IPAM" && <IPam />}
{openTab === "Labels" && <Labels />}
</div>
<Actions>
<Button size="sm" variant="solid" onClick={formik.submitForm}>
Save
</Button>
</Actions>
</>
)}
</Formik>
);
};
export default EditNetworkModal;

@ -0,0 +1,43 @@
import { Button, styled } from "@mui/joy";
import { FunctionComponent, ReactElement } from "react";
const Root = styled("div")`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: ${({ theme }) => theme.spacing(2, 5, 5, 5)};
text-align: center;
`;
const Image = styled("img")`
width: 300px;
height: auto;
`;
const CreateNew = styled(Button)`
margin-top: ${({ theme }) => theme.spacing(1)};
`;
export interface IEmptyNetworksProps {
onCreate: () => void;
}
const EmptyNetworks: FunctionComponent<IEmptyNetworksProps> = (
props: IEmptyNetworksProps
): ReactElement => {
const { onCreate } = props;
return (
<Root>
<Image src="https://res.cloudinary.com/hypertool/image/upload/v1657816359/hypertool-assets/empty-projects_fdcxtk.svg" />
<p className="mt-4 text-md text-gray-500 dark:text-gray-400">
We tried our best, but could not find any networks.
</p>
<CreateNew variant="solid" size="sm" onClick={onCreate}>
Create new network
</CreateNew>
</Root>
);
};
export default EmptyNetworks;

@ -0,0 +1,19 @@
import { styled } from "@mui/joy";
import TextField from "../../global/FormElements/TextField";
const Root = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
`;
const General = () => {
return (
<Root>
<TextField label="Entry name" name="entryName" />
<TextField label="Network name" name="networkName" />
</Root>
);
};
export default General;

@ -0,0 +1,162 @@
import { ReactElement } from "react";
import { styled } from "@mui/joy";
import TextField from "../../global/FormElements/TextField";
import { IFieldType } from "../../Record";
import Records from "../../Records";
const Fields = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const Field = styled("div")`
width: 100%;
`;
const Remove = styled("div")`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const Configuration = styled("div")`
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(4)};
`;
const ConfigurationTop = styled("div")`
display: flex;
flex-direction: row;
column-gap: ${({ theme }) => theme.spacing(1)};
`;
const ConfigurationBorder = styled("div")`
border-bottom: dotted 1px black;
height: 1px;
width: 40px;
margin: 8px 0px 0px 0px;
`;
const IPam = () => {
return (
<Fields>
<TextField label="Driver" name="driver" />
<Records
name="configurations"
modal="network"
title="Configurations"
referred="configuration"
fields={(index: number) => [
{
name: `configurations[${index}].subnet`,
label: "Subnet",
type: "text"
},
{
name: `configurations[${index}].ipRange`,
label: "IP Range",
type: "text"
},
{
name: `configurations[${index}].gateway`,
label: "Gateway",
type: "text"
},
{
name: `configurations[${index}].auxAddresses`,
type: "records",
// TODO: Remove placeholder from the main object.
placeholder: "",
options: {
name: `configurations[${index}].auxAddresses`,
modal: "configuration",
title: "Aux addresses",
referred: "aux address",
fields: (index2: number) => [
{
name: `configurations[${index}].auxAddresses[${index2}].hostName`,
label: "Host name",
type: "text"
},
{
name: `configurations[${index}].auxAddresses[${index2}].ipAddress`,
label: "IP address",
type: "text"
}
],
newValue: {
hostName: "",
ipAddress: ""
},
renderField: (element: ReactElement): ReactElement => (
<Field>{element}</Field>
),
renderRemove: (element: ReactElement): ReactElement => (
<Remove>{element}</Remove>
)
}
}
]}
newValue={{
subnet: "",
ipRange: "",
gateway: "",
auxAddresses: []
}}
renderLayout={(elements: ReactElement[]): ReactElement => (
<Configuration>
<ConfigurationTop>
{elements[0]}
{elements[1]}
{elements[2]}
</ConfigurationTop>
{elements[3]}
</Configuration>
)}
renderField={(
element: ReactElement,
field: IFieldType
): ReactElement => (
<Field style={{ paddingLeft: field.type === "records" ? 16 : 0 }}>
{element}
</Field>
)}
renderRemove={(element: ReactElement): ReactElement => (
<Remove>{element}</Remove>
)}
renderBorder={() => <ConfigurationBorder />}
/>
<Records
name="options"
modal="network"
title="Options"
referred="option"
fields={(index: number) => [
{
name: `options[${index}].key`,
placeholder: "Key",
required: true,
type: "text"
},
{
name: `options[${index}].value`,
placeholder: "Value",
required: true,
type: "text"
}
]}
newValue={{ key: "", value: "" }}
renderField={(element: ReactElement): ReactElement => (
<Field>{element}</Field>
)}
/>
</Fields>
);
};
export default IPam;

@ -0,0 +1,39 @@
import { styled } from "@mui/joy";
import { FunctionComponent, ReactElement } from "react";
import Records from "../../Records";
const Root = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
`;
const Labels: FunctionComponent = (): ReactElement => {
return (
<Root>
<Records
name="labels"
modal="network"
title="Labels"
referred="label"
fields={(index: number) => [
{
name: `labels[${index}].key`,
placeholder: "Key",
required: true,
type: "text"
},
{
name: `labels[${index}].value`,
placeholder: "Value",
required: true,
type: "text"
}
]}
newValue={{ key: "", value: "" }}
/>
</Root>
);
};
export default Labels;

@ -0,0 +1,104 @@
import { MinusSmIcon, PlusSmIcon } from "@heroicons/react/outline";
import { Button, styled } from "@mui/joy";
import IconButton from "@mui/joy/IconButton";
import { FunctionComponent, ReactElement } from "react";
export interface INetworkListProps {
networks: Record<string, any>;
selectedUuid: string;
onEdit: (networkUuid: string) => void;
onNew: () => void;
onRemove: (networkUuid: string) => void;
}
interface IListItemProps {
selected: boolean;
}
const Root = styled("div")`
padding: ${({ theme }) => theme.spacing(0)};
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: solid #eaeaea 1px;
margin: ${({ theme }) => theme.spacing(1, 2, 1, 0)};
`;
const Top = styled("div")`
display: flex;
flex-direction: column;
`;
const Bottom = styled("div")`
padding: ${({ theme }) => theme.spacing(1, 2)};
`;
const ListItem = styled("div")<IListItemProps>`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
column-gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1, 2)};
cursor: pointer;
background-color: ${({ selected }) => selected && "#f5f5f5"};
&:hover {
background-color: #f5f5f5;
}
`;
const ListItemText = styled("h5")`
font-weight: 400;
font-size: 14px;
`;
const RemoveButton = styled(IconButton)`
width: 24px;
max-height: 16px;
`;
const NetworkList: FunctionComponent<INetworkListProps> = (
props: INetworkListProps
): ReactElement => {
const { onNew, onRemove, onEdit, networks, selectedUuid } = props;
const handleEdit = (networkUuid: string) => () => onEdit(networkUuid);
const handleRemove = (networkUuid: string) => () => onRemove(networkUuid);
return (
<Root>
<Top>
{Object.keys(networks).map((networkUuid: string) => (
<ListItem
key={networkUuid}
onClick={handleEdit(networkUuid)}
selected={networkUuid === selectedUuid}
>
<ListItemText>
{networks[networkUuid].canvasConfig.node_name}
</ListItemText>
<RemoveButton
variant="soft"
size="sm"
color="danger"
onClick={handleRemove(networkUuid)}
>
<MinusSmIcon className="h-4 w-4" />
</RemoveButton>
</ListItem>
))}
</Top>
<Bottom>
<Button variant="plain" size="sm" onClick={onNew}>
<PlusSmIcon className="h-5 w-5" />
New network
</Button>
</Bottom>
</Root>
);
};
export default NetworkList;

@ -0,0 +1,163 @@
import lodash from "lodash";
import * as yup from "yup";
import { IEditNetworkForm, INetworkNodeItem } from "../../../types";
export const validationSchema = yup.object({
entryName: yup
.string()
.max(256, "Entry name should be 256 characters or less")
.required("Entry name is required"),
networkName: yup
.string()
.max(256, "Network name should be 256 characters or less")
.required("Network name is required"),
driver: yup
.string()
.max(256, "Driver should be 256 characters or less")
.default("default"),
configurations: yup.array(
yup.object({
subnet: yup.string(),
ipRange: yup.string(),
gateway: yup.string(),
auxAddresses: yup.array(
yup.object({
hostName: yup.string().required("Host name is required"),
ipAddress: yup.string().required("IP address is required")
})
)
})
),
options: yup.array(
yup.object({
key: yup.string().required("Key is required"),
value: yup.string().required("Value is required")
})
),
labels: yup.array(
yup.object({
key: yup.string().required("Key is required"),
value: yup.string().required("Value is required")
})
)
});
export const tabs = [
{
name: "General",
href: "#",
current: true,
hidden: false
},
{
name: "IPAM",
href: "#",
current: false,
hidden: false
},
{
name: "Labels",
href: "#",
current: false,
hidden: false
}
];
export const initialValues: IEditNetworkForm = {
entryName: "",
networkName: "",
driver: "default",
configurations: [],
options: [],
labels: []
};
export const getInitialValues = (node?: INetworkNodeItem): IEditNetworkForm => {
if (!node) {
return {
...initialValues
};
}
const { canvasConfig, networkConfig } = node;
const { node_name = "" } = canvasConfig;
const { name = "", ipam, labels } = networkConfig;
return {
...initialValues,
entryName: node_name,
networkName: name,
driver: ipam?.driver ?? "",
configurations:
ipam?.config.map((item) => ({
subnet: item.subnet,
ipRange: item.ip_range,
gateway: item.gateway,
auxAddresses: Object.entries(item.aux_addresses).map(
([hostName, ipAddress]) => ({
hostName,
ipAddress
})
)
})) ?? [],
options: Object.keys(ipam?.options || {}).map((key) => {
if (!ipam) {
throw new Error("Control should not reach here.");
}
return {
key,
value: ipam.options[key].toString()
};
}),
labels: Object.entries(labels as any).map(([key, value]: any) => ({
key,
value
}))
};
};
export const getFinalValues = (
values: IEditNetworkForm,
previous?: INetworkNodeItem
): INetworkNodeItem => {
const { labels, driver, configurations, options } = values;
return {
key: "network",
type: "NETWORK",
inputs: previous?.inputs ?? [],
outputs: previous?.outputs ?? [],
config: (previous as any)?.config ?? {},
canvasConfig: {
node_name: values.entryName
},
networkConfig: {
name: values.networkName,
ipam: {
driver,
config: configurations.map((configuration) => ({
subnet: configuration.subnet,
ip_range: configuration.ipRange,
gateway: configuration.gateway,
aux_addresses: Object.fromEntries(
configuration.auxAddresses.map((auxAddress) => [
auxAddress.hostName,
auxAddress.ipAddress
])
)
})),
options: Object.fromEntries(
options.map((option) => [option.key, option.value])
)
},
labels: Object.fromEntries(
labels.map((label) => [label.key, label.value])
)
}
} as any;
};

@ -0,0 +1,141 @@
import { useCallback, useState } from "react";
import { XIcon } from "@heroicons/react/outline";
import CreateNetworkModal from "./CreateNetworkModal";
import { CallbackFunction } from "../../../types";
import EditNetworkModal from "./EditNetworkModal";
import { attachUUID } from "../../../utils";
import { getFinalValues } from "./form-utils";
import EmptyNetworks from "./EmptyNetworks";
import NetworkList from "./NetworkList";
import { styled } from "@mui/joy";
interface IModalNetworkProps {
networks: Record<string, any>;
onCreateNetwork: CallbackFunction;
onUpdateNetwork: CallbackFunction;
onDeleteNetwork: CallbackFunction;
onHide: CallbackFunction;
}
const Container = styled("div")`
display: flex;
flex-direction: row;
`;
const NetworkFormContainer = styled("div")`
display: flex;
flex-direction: column;
`;
const ModalNetwork = (props: IModalNetworkProps) => {
const {
networks,
onCreateNetwork,
onUpdateNetwork,
onDeleteNetwork,
onHide
} = props;
const [selectedNetwork, setSelectedNetwork] = useState<any | null>();
const [showCreate, setShowCreate] = useState(false);
const handleCreate = (values: any) => {
const finalValues = getFinalValues(values);
const uniqueKey = attachUUID(finalValues.key);
const network = {
...finalValues,
key: uniqueKey
};
onCreateNetwork(network);
setSelectedNetwork(network);
};
const handleUpdate = (values: any) => {
const finalValues = getFinalValues(values, selectedNetwork);
onUpdateNetwork(finalValues);
setSelectedNetwork(finalValues);
};
const handleRemove = useCallback(
(networkUuid: string) => {
onDeleteNetwork(networkUuid);
/* Show the new network form only when the selected node was deleted. */
/* BUG: If a selected node is deleted, it still remains in the form. */
if (selectedNetwork?.key === networkUuid) {
setSelectedNetwork(null);
}
},
[onDeleteNetwork, selectedNetwork]
);
const handleNew = useCallback(() => {
setShowCreate(true);
setSelectedNetwork(null);
}, []);
const handleEdit = useCallback(
(networkUuid: string) => {
setSelectedNetwork(networks[networkUuid]);
},
[networks]
);
const networkKeys = Object.keys(networks);
return (
<div className="fixed z-50 inset-0 overflow-y-auto">
<div className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 outline-none focus:outline-none">
<div
onClick={onHide}
className="opacity-25 fixed inset-0 z-40 bg-black"
></div>
<div className="relative w-auto my-6 mx-auto max-w-5xl z-50">
<div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none">
<div className="flex items-center justify-between px-4 py-3 border-b border-solid border-blueGray-200 rounded-t">
<h3 className="text-sm font-semibold">Networks</h3>
<button
className="p-1 ml-auto text-black float-right outline-none focus:outline-none"
onClick={onHide}
>
<span className="block outline-none focus:outline-none">
<XIcon className="w-4" />
</span>
</button>
</div>
{networkKeys.length === 0 && !showCreate && (
<EmptyNetworks onCreate={handleNew} />
)}
{(networkKeys.length > 0 || showCreate) && (
<Container>
{networkKeys.length > 0 && (
<NetworkList
networks={networks}
onNew={handleNew}
onRemove={handleRemove}
onEdit={handleEdit}
selectedUuid={selectedNetwork?.key}
/>
)}
<NetworkFormContainer>
{!selectedNetwork && (
<CreateNetworkModal onCreateNetwork={handleCreate} />
)}
{selectedNetwork && (
<EditNetworkModal
network={selectedNetwork}
onUpdateNetwork={handleUpdate}
/>
)}
</NetworkFormContainer>
</Container>
)}
</div>
</div>
</div>
</div>
);
};
export default ModalNetwork;

@ -11,8 +11,8 @@ const Root = styled("div")`
const General = () => {
return (
<Root>
<TextField label="Entry name" name="entryName" />
<TextField label="Volume name" name="volumeName" />
<TextField label="Entry name" name="entryName" required={true} />
<TextField label="Volume name" name="volumeName" required={true} />
</Root>
);
};

@ -20,8 +20,8 @@ export const validationSchema = yup.object({
});
const initialValues: IEditVolumeForm = {
entryName: "unknown",
volumeName: "unknown",
entryName: "",
volumeName: "",
labels: []
};

@ -33,9 +33,9 @@ import { generateHttp } from "../../services/generate";
import { Canvas } from "../Canvas";
import Spinner from "../global/Spinner";
import ModalConfirmDelete from "../Modal/ConfirmDelete";
import ModalServiceCreate from "../Modal/Service/Create";
import ModalServiceEdit from "../Modal/Service/Edit";
import ModalNetwork from "../Modal/Network";
import ModalServiceCreate from "../Modal/service/Create";
import ModalServiceEdit from "../Modal/service/Edit";
import ModalNetwork from "../Modal/network";
import CreateVolumeModal from "../Modal/volume/CreateVolumeModal";
import EditVolumeModal from "../Modal/volume/EditVolumeModal";
import CodeEditor from "../CodeEditor";
@ -489,7 +489,7 @@ export default function Project() {
onClick={() => setShowNetworksModal(true)}
>
<GlobeAltIcon className="w-4" />
<span>Add Network</span>
<span>Networks</span>
</button>
</div>
</div>

@ -1,25 +1,38 @@
import { Fragment, FunctionComponent, ReactElement, useCallback } from "react";
import {
Fragment,
FunctionComponent,
ReactElement,
useCallback,
useMemo
} from "react";
import { styled } from "@mui/joy";
import IconButton from "@mui/joy/IconButton";
import { MinusSmIcon } from "@heroicons/react/solid";
import TextField from "./global/FormElements/TextField";
import Toggle from "./global/FormElements/Toggle";
import Records, { IRecordsProps } from "./Records";
export interface IFieldType {
name: string;
placeholder: string;
placeholder?: string;
required?: boolean;
type: "text" | "toggle";
options?: {
type: "text" | "toggle" | "records";
options?:
| {
text: string;
value: string;
}[];
}[]
| IRecordsProps;
}
export interface IRecordProps {
fields: IFieldType[];
index: number;
onRemove: (index: number) => void;
direction?: "column" | "row";
renderLayout?: (elements: ReactElement[]) => ReactElement;
renderField?: (element: ReactElement, field: IFieldType) => ReactElement;
renderRemove?: (element: ReactElement) => ReactElement;
}
const Root = styled("div")`
@ -28,6 +41,8 @@ const Root = styled("div")`
justify-content: flex-start;
align-items: flex-start;
column-gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
@media (max-width: 768px) {
column-gap: ${({ theme }) => theme.spacing(1)};
}
@ -38,29 +53,65 @@ const RemoveButton = styled(IconButton)``;
const Record: FunctionComponent<IRecordProps> = (
props: IRecordProps
): ReactElement => {
const { fields, index, onRemove } = props;
const { fields, index, onRemove, renderLayout, renderField, renderRemove } =
props;
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const renderLayoutWrapper = useMemo(
() => renderLayout || ((elements: ReactElement[]) => <>{elements}</>),
[renderLayout]
);
const renderFieldWrapper = useMemo(
() => renderField || ((element: ReactElement) => element),
[renderField]
);
const renderRemoveWrapper = useMemo(
() => renderRemove || ((element: ReactElement) => element),
[renderRemove]
);
return (
<Root>
{fields.map(({ type, name, placeholder, required, options }) => (
<Fragment key={name}>
{type === "text" && (
{renderLayoutWrapper(
fields.map((field) => (
<Fragment key={field.name}>
{renderFieldWrapper(
<>
{field.type === "text" && (
<TextField
id={name}
name={name}
placeholder={placeholder + (required ? "*" : "")}
required={required}
id={field.name}
name={field.name}
placeholder={
field.placeholder && !(field as any).label
? field.placeholder + (field.required ? "*" : "")
: ""
}
label={(field as any).label}
required={field.required}
/>
)}
{field.type === "toggle" && (
<Toggle
name={field.name}
label={field.placeholder || ""}
options={(field.options as any) || []}
/>
)}
{type === "toggle" && (
<Toggle name={name} label={placeholder} options={options || []} />
{field.type === "records" && (
<Records {...(field.options as any)} />
)}
</>,
field
)}
</Fragment>
))}
))
)}
{renderRemoveWrapper(
<RemoveButton
variant="soft"
size="sm"
@ -69,6 +120,7 @@ const Record: FunctionComponent<IRecordProps> = (
>
<MinusSmIcon className="h-5 w-5" />
</RemoveButton>
)}
</Root>
);
};

@ -0,0 +1,128 @@
import { Button, styled } from "@mui/joy";
import { Fragment, FunctionComponent, ReactElement, useCallback } from "react";
import { PlusIcon } from "@heroicons/react/outline";
import Record, { IFieldType } from "./Record";
import { useFormikContext } from "formik";
import lodash from "lodash";
export interface IRecordsProps {
modal: string;
title: string;
referred: string;
name: string;
fields: (index: number) => IFieldType[];
newValue: any;
renderLayout?: (elements: ReactElement[]) => ReactElement;
renderField?: (element: ReactElement, field: IFieldType) => ReactElement;
renderRemove?: (element: ReactElement) => ReactElement;
renderBorder?: () => ReactElement;
}
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 RecordList = styled("div")`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
row-gap: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const AddButton = styled(Button)`
min-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 Records: FunctionComponent<IRecordsProps> = (
props: IRecordsProps
): ReactElement => {
const {
modal,
title,
referred,
name,
fields,
newValue,
renderLayout,
renderField,
renderRemove,
renderBorder
} = props;
const formik = useFormikContext();
const items = lodash.get(formik.values, name);
const handleNew = useCallback(() => {
formik.setFieldValue(`${name}[${items.length}]`, newValue);
}, [formik]);
const handleRemove = useCallback(
(index: number) => {
const newOptions = items.filter(
(_: unknown, currentIndex: number) => currentIndex != index
);
formik.setFieldValue(name, newOptions);
},
[formik]
);
const empty = items && items.length === 0;
return (
<Group>
<GroupTitle>{title}</GroupTitle>
{!empty && (
<RecordList>
{items.map((_: unknown, index: number) => (
<Fragment key={index}>
<Record
index={index}
fields={fields(index)}
onRemove={handleRemove}
renderLayout={renderLayout}
renderField={renderField}
renderRemove={renderRemove}
/>
{renderBorder && renderBorder()}
</Fragment>
))}
</RecordList>
)}
{empty && (
<Description>
This {modal} does not have any {referred}.
<br />
Click "+ New {referred}" to add a new {referred}.
</Description>
)}
<AddButton size="sm" variant="plain" onClick={handleNew}>
<PlusIcon className="h-4 w-4 mr-2" />
New {referred}
</AddButton>
</Group>
);
};
export default Records;

@ -328,6 +328,12 @@ export interface IVolumeNodeItem extends INodeItem {
volumeConfig: Partial<IVolumeTopLevel>;
}
export interface INetworkNodeItem extends INodeItem {
outputs: string[];
canvasConfig: ICanvasConfig;
networkConfig: Partial<INetworkTopLevel>;
}
export interface IProjectPayload {
name: string;
data: {
@ -386,3 +392,26 @@ export interface IEditVolumeForm {
value: string;
}[];
}
export interface IEditNetworkForm {
entryName: string;
networkName: string;
driver: string;
configurations: {
subnet: string;
ipRange: string;
gateway: string;
auxAddresses: {
hostName: string;
ipAddress: string;
}[];
}[];
options: {
key: string;
value: string;
}[];
labels: {
key: string;
value: string;
}[];
}

@ -11,14 +11,7 @@ import {
values
} from "lodash";
import { LOCAL_STORAGE } from "../constants";
import {
IServiceNodeItem,
INodeLibraryItem,
INodeGroup,
ICanvasConfig,
INetworkTopLevel,
IVolumeTopLevel
} from "../types";
import { IServiceNodeItem, INodeLibraryItem, INodeGroup } from "../types";
export function ensure<T>(
argument: T | undefined | null,
@ -188,40 +181,6 @@ export const getNodeKeyFromConnectionId = (uuid: string) => {
return key;
};
export const topLevelVolumeConfigInitialValues =
(): Partial<IVolumeTopLevel> => {
return {
name: "unnamed"
};
};
export const topLevelNetworkConfigInitialValues =
(): Partial<INetworkTopLevel> => {
return {
name: "unnamed"
};
};
export const volumeConfigCanvasInitialValues = (): ICanvasConfig => {
return {
node_name: "unnamed",
node_icon: ""
};
};
export const networkConfigCanvasInitialValues = (): ICanvasConfig => {
return {
node_name: "unnamed"
};
};
export const serviceConfigCanvasInitialValues = (): ICanvasConfig => {
return {
node_name: "unnamed",
node_icon: ""
};
};
export const toaster = (message: string, type: string) => {
const toastConfig = {
duration: 3000,

@ -0,0 +1,3 @@
export const classNames = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
Loading…
Cancel
Save