diff --git a/services/frontend/src/components/Modal/Network/Create.tsx b/services/frontend/src/components/Modal/Network/Create.tsx deleted file mode 100644 index df9aac3..0000000 --- a/services/frontend/src/components/Modal/Network/Create.tsx +++ /dev/null @@ -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 ( - { - handleCreate(values, formik); - }} - validationSchema={validationSchema} - > - {(formik) => ( - <> -
- -
- -
- {openTab === "General" && } - {openTab === "IPam" && } - {openTab === "Labels" && } -
- -
- -
- - )} -
- ); -}; - -export default NetworkCreate; diff --git a/services/frontend/src/components/Modal/Network/Edit.tsx b/services/frontend/src/components/Modal/Network/Edit.tsx deleted file mode 100644 index 40cafa8..0000000 --- a/services/frontend/src/components/Modal/Network/Edit.tsx +++ /dev/null @@ -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 ( - { - handleUpdate(values); - }} - validationSchema={validationSchema} - > - {(formik) => ( - <> -
- -
- -
- {openTab === "General" && } - {openTab === "IPam" && } - {openTab === "Labels" && } -
- -
- - - -
- - )} -
- ); -}; - -export default NetworkEdit; diff --git a/services/frontend/src/components/Modal/Network/General.tsx b/services/frontend/src/components/Modal/Network/General.tsx deleted file mode 100644 index 12490db..0000000 --- a/services/frontend/src/components/Modal/Network/General.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import TextField from "../../global/FormElements/TextField"; - -const General = () => { - return ( - <> - - - - ); -}; - -export default General; diff --git a/services/frontend/src/components/Modal/Network/IPam.tsx b/services/frontend/src/components/Modal/Network/IPam.tsx deleted file mode 100644 index 2ea3f71..0000000 --- a/services/frontend/src/components/Modal/Network/IPam.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const IPam = () => { - return <>; -}; - -export default IPam; diff --git a/services/frontend/src/components/Modal/Network/Labels.tsx b/services/frontend/src/components/Modal/Network/Labels.tsx deleted file mode 100644 index 12d9e27..0000000 --- a/services/frontend/src/components/Modal/Network/Labels.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Labels = () => { - return <>; -}; - -export default Labels; diff --git a/services/frontend/src/components/Modal/Network/index.tsx b/services/frontend/src/components/Modal/Network/index.tsx deleted file mode 100644 index 43fb0ea..0000000 --- a/services/frontend/src/components/Modal/Network/index.tsx +++ /dev/null @@ -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; - onCreateNetwork: CallbackFunction; - onUpdateNetwork: CallbackFunction; - onDeleteNetwork: CallbackFunction; - onHide: CallbackFunction; -} - -const ModalNetwork = (props: IModalNetworkProps) => { - const { - networks, - onCreateNetwork, - onUpdateNetwork, - onDeleteNetwork, - onHide - } = props; - const [selectedNetwork, setSelectedNetwork] = useState(); - 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 ( -
-
-
-
-
-
-

Top level networks

- -
- - {networks && Object.keys(networks).length > 0 && ( -
- - - {selectedNetwork && ( - - )} -
- )} - - {!selectedNetwork && ( - - )} - - {selectedNetwork && ( - - )} -
-
-
-
- ); -}; - -export default ModalNetwork; diff --git a/services/frontend/src/components/Modal/network/CreateNetworkModal.tsx b/services/frontend/src/components/Modal/network/CreateNetworkModal.tsx new file mode 100644 index 0000000..586b3ca --- /dev/null +++ b/services/frontend/src/components/Modal/network/CreateNetworkModal.tsx @@ -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 = ( + props: ICreateNetworkModalProps +): ReactElement => { + const { onCreateNetwork } = props; + const [openTab, setOpenTab] = useState("General"); + const initialValues = useMemo(() => getInitialValues(), []); + + return ( + + {(formik) => ( + <> + + +
+ {openTab === "General" && } + {openTab === "IPAM" && } + {openTab === "Labels" && } +
+ + + + + + )} +
+ ); +}; + +export default CreateNetworkModal; diff --git a/services/frontend/src/components/Modal/network/EditNetworkModal.tsx b/services/frontend/src/components/Modal/network/EditNetworkModal.tsx new file mode 100644 index 0000000..734dc07 --- /dev/null +++ b/services/frontend/src/components/Modal/network/EditNetworkModal.tsx @@ -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) => ( + <> + + +
+ {openTab === "General" && } + {openTab === "IPAM" && } + {openTab === "Labels" && } +
+ + + + + + )} +
+ ); +}; + +export default EditNetworkModal; diff --git a/services/frontend/src/components/Modal/network/EmptyNetworks.tsx b/services/frontend/src/components/Modal/network/EmptyNetworks.tsx new file mode 100644 index 0000000..2cfa597 --- /dev/null +++ b/services/frontend/src/components/Modal/network/EmptyNetworks.tsx @@ -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 = ( + props: IEmptyNetworksProps +): ReactElement => { + const { onCreate } = props; + return ( + + +

+ We tried our best, but could not find any networks. +

+ + Create new network + +
+ ); +}; + +export default EmptyNetworks; diff --git a/services/frontend/src/components/Modal/network/General.tsx b/services/frontend/src/components/Modal/network/General.tsx new file mode 100644 index 0000000..04e9bcd --- /dev/null +++ b/services/frontend/src/components/Modal/network/General.tsx @@ -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 ( + + + + + ); +}; + +export default General; diff --git a/services/frontend/src/components/Modal/network/IPam.tsx b/services/frontend/src/components/Modal/network/IPam.tsx new file mode 100644 index 0000000..b28f2dc --- /dev/null +++ b/services/frontend/src/components/Modal/network/IPam.tsx @@ -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 ( + + + + [ + { + 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 => ( + {element} + ), + renderRemove: (element: ReactElement): ReactElement => ( + {element} + ) + } + } + ]} + newValue={{ + subnet: "", + ipRange: "", + gateway: "", + auxAddresses: [] + }} + renderLayout={(elements: ReactElement[]): ReactElement => ( + + + {elements[0]} + {elements[1]} + {elements[2]} + + {elements[3]} + + )} + renderField={( + element: ReactElement, + field: IFieldType + ): ReactElement => ( + + {element} + + )} + renderRemove={(element: ReactElement): ReactElement => ( + {element} + )} + renderBorder={() => } + /> + + [ + { + 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 => ( + {element} + )} + /> + + ); +}; + +export default IPam; diff --git a/services/frontend/src/components/Modal/network/Labels.tsx b/services/frontend/src/components/Modal/network/Labels.tsx new file mode 100644 index 0000000..55479ed --- /dev/null +++ b/services/frontend/src/components/Modal/network/Labels.tsx @@ -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 ( + + [ + { + name: `labels[${index}].key`, + placeholder: "Key", + required: true, + type: "text" + }, + { + name: `labels[${index}].value`, + placeholder: "Value", + required: true, + type: "text" + } + ]} + newValue={{ key: "", value: "" }} + /> + + ); +}; + +export default Labels; diff --git a/services/frontend/src/components/Modal/network/NetworkList.tsx b/services/frontend/src/components/Modal/network/NetworkList.tsx new file mode 100644 index 0000000..7c56ff9 --- /dev/null +++ b/services/frontend/src/components/Modal/network/NetworkList.tsx @@ -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; + 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")` + 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 = ( + props: INetworkListProps +): ReactElement => { + const { onNew, onRemove, onEdit, networks, selectedUuid } = props; + + const handleEdit = (networkUuid: string) => () => onEdit(networkUuid); + + const handleRemove = (networkUuid: string) => () => onRemove(networkUuid); + + return ( + + + {Object.keys(networks).map((networkUuid: string) => ( + + + {networks[networkUuid].canvasConfig.node_name} + + + + + + ))} + + + + + + + ); +}; + +export default NetworkList; diff --git a/services/frontend/src/components/Modal/network/form-utils.ts b/services/frontend/src/components/Modal/network/form-utils.ts new file mode 100644 index 0000000..4cb8f70 --- /dev/null +++ b/services/frontend/src/components/Modal/network/form-utils.ts @@ -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; +}; diff --git a/services/frontend/src/components/Modal/network/index.tsx b/services/frontend/src/components/Modal/network/index.tsx new file mode 100644 index 0000000..4832e3c --- /dev/null +++ b/services/frontend/src/components/Modal/network/index.tsx @@ -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; + 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(); + 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 ( +
+
+
+
+
+
+

Networks

+ +
+ + {networkKeys.length === 0 && !showCreate && ( + + )} + + {(networkKeys.length > 0 || showCreate) && ( + + {networkKeys.length > 0 && ( + + )} + + + {!selectedNetwork && ( + + )} + + {selectedNetwork && ( + + )} + + + )} +
+
+
+
+ ); +}; + +export default ModalNetwork; diff --git a/services/frontend/src/components/Modal/Service/Create.tsx b/services/frontend/src/components/Modal/service/Create.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/Create.tsx rename to services/frontend/src/components/Modal/service/Create.tsx diff --git a/services/frontend/src/components/Modal/Service/Edit.tsx b/services/frontend/src/components/Modal/service/Edit.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/Edit.tsx rename to services/frontend/src/components/Modal/service/Edit.tsx diff --git a/services/frontend/src/components/Modal/Service/Environment.tsx b/services/frontend/src/components/Modal/service/Environment.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/Environment.tsx rename to services/frontend/src/components/Modal/service/Environment.tsx diff --git a/services/frontend/src/components/Modal/Service/General.tsx b/services/frontend/src/components/Modal/service/General.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/General.tsx rename to services/frontend/src/components/Modal/service/General.tsx diff --git a/services/frontend/src/components/Modal/Service/Labels.tsx b/services/frontend/src/components/Modal/service/Labels.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/Labels.tsx rename to services/frontend/src/components/Modal/service/Labels.tsx diff --git a/services/frontend/src/components/Modal/Service/Volumes.tsx b/services/frontend/src/components/Modal/service/Volumes.tsx similarity index 100% rename from services/frontend/src/components/Modal/Service/Volumes.tsx rename to services/frontend/src/components/Modal/service/Volumes.tsx diff --git a/services/frontend/src/components/Modal/Service/form-utils.ts b/services/frontend/src/components/Modal/service/form-utils.ts similarity index 100% rename from services/frontend/src/components/Modal/Service/form-utils.ts rename to services/frontend/src/components/Modal/service/form-utils.ts diff --git a/services/frontend/src/components/Modal/volume/General.tsx b/services/frontend/src/components/Modal/volume/General.tsx index d2985ae..44855c5 100644 --- a/services/frontend/src/components/Modal/volume/General.tsx +++ b/services/frontend/src/components/Modal/volume/General.tsx @@ -11,8 +11,8 @@ const Root = styled("div")` const General = () => { return ( - - + + ); }; diff --git a/services/frontend/src/components/Modal/volume/form-utils.ts b/services/frontend/src/components/Modal/volume/form-utils.ts index ba451f4..8b986bc 100644 --- a/services/frontend/src/components/Modal/volume/form-utils.ts +++ b/services/frontend/src/components/Modal/volume/form-utils.ts @@ -20,8 +20,8 @@ export const validationSchema = yup.object({ }); const initialValues: IEditVolumeForm = { - entryName: "unknown", - volumeName: "unknown", + entryName: "", + volumeName: "", labels: [] }; diff --git a/services/frontend/src/components/Project/index.tsx b/services/frontend/src/components/Project/index.tsx index dabbb8a..9825055 100644 --- a/services/frontend/src/components/Project/index.tsx +++ b/services/frontend/src/components/Project/index.tsx @@ -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)} > - Add Network + Networks diff --git a/services/frontend/src/components/Record.tsx b/services/frontend/src/components/Record.tsx index b273fe7..058ef80 100644 --- a/services/frontend/src/components/Record.tsx +++ b/services/frontend/src/components/Record.tsx @@ -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?: { - text: string; - value: string; - }[]; + 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,37 +53,74 @@ const RemoveButton = styled(IconButton)``; const Record: FunctionComponent = ( 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 ( - {fields.map(({ type, name, placeholder, required, options }) => ( - - {type === "text" && ( - - )} - {type === "toggle" && ( - - )} - - ))} - - - + {renderLayoutWrapper( + fields.map((field) => ( + + {renderFieldWrapper( + <> + {field.type === "text" && ( + + )} + {field.type === "toggle" && ( + + )} + {field.type === "records" && ( + + )} + , + field + )} + + )) + )} + {renderRemoveWrapper( + + + + )} ); }; diff --git a/services/frontend/src/components/Records.tsx b/services/frontend/src/components/Records.tsx new file mode 100644 index 0000000..1f7b6e7 --- /dev/null +++ b/services/frontend/src/components/Records.tsx @@ -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 = ( + 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 ( + + {title} + {!empty && ( + + {items.map((_: unknown, index: number) => ( + + + {renderBorder && renderBorder()} + + ))} + + )} + {empty && ( + + This {modal} does not have any {referred}. +
+ Click "+ New {referred}" to add a new {referred}. +
+ )} + + + + New {referred} + +
+ ); +}; + +export default Records; diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index 059af69..76db33c 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -328,6 +328,12 @@ export interface IVolumeNodeItem extends INodeItem { volumeConfig: Partial; } +export interface INetworkNodeItem extends INodeItem { + outputs: string[]; + canvasConfig: ICanvasConfig; + networkConfig: Partial; +} + 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; + }[]; +} diff --git a/services/frontend/src/utils/index.ts b/services/frontend/src/utils/index.ts index 718bbe6..830c3cd 100644 --- a/services/frontend/src/utils/index.ts +++ b/services/frontend/src/utils/index.ts @@ -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( argument: T | undefined | null, @@ -188,40 +181,6 @@ export const getNodeKeyFromConnectionId = (uuid: string) => { return key; }; -export const topLevelVolumeConfigInitialValues = - (): Partial => { - return { - name: "unnamed" - }; - }; - -export const topLevelNetworkConfigInitialValues = - (): Partial => { - 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, diff --git a/services/frontend/src/utils/styles.tsx b/services/frontend/src/utils/styles.tsx new file mode 100644 index 0000000..1d73932 --- /dev/null +++ b/services/frontend/src/utils/styles.tsx @@ -0,0 +1,3 @@ +export const classNames = (...classes: string[]) => { + return classes.filter(Boolean).join(" "); +};