From 4b2865e6094c0431a532574f54d3dec2b12e0717 Mon Sep 17 00:00:00 2001 From: Sergio Betanzos Date: Mon, 28 Sep 2020 15:26:45 +0200 Subject: [PATCH] F #3951: Validations in flow graph (#251) * Change graph coordinates as fixed * Add cycle validations & remove function * Validations in flow graph * Add control when network is assigned --- src/fireedge/package.json | 1 + .../public/components/Cards/NetworkCard.js | 24 ++- .../public/components/Flows/FlowWithFAB.js | 101 ---------- .../components/FormStepper/MobileStepper.js | 55 ++--- .../public/components/FormStepper/Stepper.js | 48 ++--- .../public/components/FormStepper/index.js | 8 +- .../src/public/components/List/ListCards.js | 2 +- .../Create/Steps/Networking/index.js | 34 ++-- .../Create/Steps/Networking/schema.js | 189 ++++++++++-------- .../Create/Steps/Tiers/Flow/CustomNode.js | 83 ++++++-- .../Create/Steps/Tiers/Flow/index.js | 95 +++++++++ .../Create/Steps/Tiers/Flow/useFlowGraph.js | 98 +++++++++ .../Steps/Tiers/Steps/Networks/index.js | 12 +- .../Steps/Tiers/Steps/Networks/schema.js | 2 +- .../Steps/Tiers/Steps/Policies/schema.js | 2 - .../Steps/Tiers/Steps/Template/schema.js | 2 +- .../Create/Steps/Tiers/Steps/index.js | 14 +- .../Application/Create/Steps/Tiers/index.js | 157 ++++++--------- src/fireedge/src/public/hooks/useListForm.js | 120 ++++++----- .../src/public/hooks/useNearScreen.js | 1 - src/fireedge/src/public/utils/flow.js | 5 +- 21 files changed, 623 insertions(+), 430 deletions(-) delete mode 100644 src/fireedge/src/public/components/Flows/FlowWithFAB.js create mode 100644 src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/index.js create mode 100644 src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/useFlowGraph.js diff --git a/src/fireedge/package.json b/src/fireedge/package.json index f04f30550d..1033da0479 100644 --- a/src/fireedge/package.json +++ b/src/fireedge/package.json @@ -78,6 +78,7 @@ "sprintf-js": "^1.1.2", "upcast": "^4.0.0", "url": "^0.11.0", + "uuid": "^8.3.0", "window-or-global": "^1.0.1", "xml2js": "^0.4.23", "xmlrpc": "^1.3.2", diff --git a/src/fireedge/src/public/components/Cards/NetworkCard.js b/src/fireedge/src/public/components/Cards/NetworkCard.js index 0d7018e140..33b8077ff7 100644 --- a/src/fireedge/src/public/components/Cards/NetworkCard.js +++ b/src/fireedge/src/public/components/Cards/NetworkCard.js @@ -62,15 +62,21 @@ const NetworkCard = React.memo( }} /> - - - + {handleEdit && ( + + )} + {handleClone && ( + + )} + {handleRemove && ( + + )} diff --git a/src/fireedge/src/public/components/Flows/FlowWithFAB.js b/src/fireedge/src/public/components/Flows/FlowWithFAB.js deleted file mode 100644 index b0c71bd1c2..0000000000 --- a/src/fireedge/src/public/components/Flows/FlowWithFAB.js +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; - -import { Fab, Box, Card } from '@material-ui/core'; -import AddIcon from '@material-ui/icons/Add'; - -import ReactFlow, { - removeElements, - addEdge, - MiniMap, - Background, - isNode -} from 'react-flow-renderer'; - -const initialElements = []; - -const onNodeDragStart = (event, node) => console.log('drag start', node); -const onNodeDragStop = (event, node) => console.log('drag stop', node); -const onSelectionDrag = (event, nodes) => console.log('selection drag', nodes); -const onSelectionDragStart = (event, nodes) => - console.log('selection drag start', nodes); -const onSelectionDragStop = (event, nodes) => - console.log('selection drag stop', nodes); -const onElementClick = (event, element) => - console.log(`${isNode(element) ? 'node' : 'edge'} click:`, element); -const onSelectionChange = elements => console.log('selection change', elements); -const onLoad = reactFlowInstance => { - console.log('flow loaded:', reactFlowInstance); - reactFlowInstance.fitView(); -}; - -const onMoveEnd = transform => console.log('zoom/move end', transform); - -const connectionLineStyle = { stroke: '#ddd' }; -const snapGrid = [16, 16]; - -const CustomNode = React.memo(({ data }) => ( - -
Custom node
-
-)); - -const FlowWithFAB = ({ handleClick }) => { - const [elements, setElements] = useState(initialElements); - const onElementsRemove = elementsToRemove => - setElements(els => removeElements(elementsToRemove, els)); - const onConnect = params => setElements(els => addEdge(params, els)); - - return ( - - - { - if (n.style?.background) return n.style.background; - if (n.type === 'input') return '#9999ff'; - if (n.type === 'output') return '#79c9b7'; - if (n.type === 'default') return '#ff6060'; - - return '#eee'; - }} - /> - - - - - - - ); -}; - -FlowWithFAB.propTypes = { - handleClick: PropTypes.func -}; - -FlowWithFAB.defaultProps = { - handleClick: evt => evt -}; - -export default FlowWithFAB; diff --git a/src/fireedge/src/public/components/FormStepper/MobileStepper.js b/src/fireedge/src/public/components/FormStepper/MobileStepper.js index 500ae2bc0e..3c84942729 100644 --- a/src/fireedge/src/public/components/FormStepper/MobileStepper.js +++ b/src/fireedge/src/public/components/FormStepper/MobileStepper.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; import { styled, Button, MobileStepper } from '@material-ui/core'; @@ -14,31 +14,34 @@ const StickyMobileStepper = styled(MobileStepper)({ zIndex: 1 }); -const CustomMobileStepper = ({ - totalSteps, - activeStep, - lastStep, - disabledBack, - handleNext, - handleBack -}) => ( - - {Tr('Back')} - - } - nextButton={ - - } - /> +const CustomMobileStepper = memo( + ({ + totalSteps, + activeStep, + lastStep, + disabledBack, + handleNext, + handleBack + }) => ( + + {Tr('Back')} + + } + nextButton={ + + } + /> + ), + (prev, next) => prev.activeStep === next.activeStep ); CustomMobileStepper.propTypes = { diff --git a/src/fireedge/src/public/components/FormStepper/Stepper.js b/src/fireedge/src/public/components/FormStepper/Stepper.js index 2708d51f47..ed0d3f1f02 100644 --- a/src/fireedge/src/public/components/FormStepper/Stepper.js +++ b/src/fireedge/src/public/components/FormStepper/Stepper.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; import { @@ -20,31 +20,27 @@ const StickyStepper = styled(Stepper)({ zIndex: 1 }); -const CustomStepper = ({ - steps, - activeStep, - lastStep, - disabledBack, - handleNext, - handleBack -}) => ( - <> - - {steps?.map(({ label }) => ( - - {label} - - ))} - - - - - - +const CustomStepper = memo( + ({ steps, activeStep, lastStep, disabledBack, handleNext, handleBack }) => ( + <> + + {steps?.map(({ label }) => ( + + {label} + + ))} + + + + + + + ), + (prev, next) => prev.activeStep === next.activeStep ); CustomStepper.propTypes = { diff --git a/src/fireedge/src/public/components/FormStepper/index.js b/src/fireedge/src/public/components/FormStepper/index.js index 5c9802632b..a92b9fbf98 100644 --- a/src/fireedge/src/public/components/FormStepper/index.js +++ b/src/fireedge/src/public/components/FormStepper/index.js @@ -25,7 +25,7 @@ const FormStepper = ({ steps, initialValue, onSubmit }) => { reset({ ...formData }, { errors: false }); }, [formData]); - const handleNext = () => { + const handleNext = useCallback(() => { const { id } = steps[activeStep]; trigger(id).then(isValid => { @@ -40,7 +40,7 @@ const FormStepper = ({ steps, initialValue, onSubmit }) => { setActiveStep(prevActiveStep => prevActiveStep + 1); } }); - }; + }, [activeStep, watch]); const handleBack = useCallback(() => { if (activeStep <= FIRST_STEP) return; @@ -48,7 +48,7 @@ const FormStepper = ({ steps, initialValue, onSubmit }) => { setActiveStep(prevActiveStep => prevActiveStep - 1); }, [activeStep]); - const { id, content: Content } = React.useMemo(() => steps[activeStep], [ + const { id, content: Content } = useMemo(() => steps[activeStep], [ formData, activeStep, setFormData @@ -90,7 +90,7 @@ FormStepper.propTypes = { PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, label: PropTypes.string.isRequired, - content: PropTypes.any.isRequired + content: PropTypes.func.isRequired }) ), initialValue: PropTypes.objectOf(PropTypes.any), diff --git a/src/fireedge/src/public/components/List/ListCards.js b/src/fireedge/src/public/components/List/ListCards.js index 616b78e6d9..186b77fb1c 100644 --- a/src/fireedge/src/public/components/List/ListCards.js +++ b/src/fireedge/src/public/components/List/ListCards.js @@ -42,7 +42,7 @@ function ListCards({ handleCreate, list, CardComponent, cardsProps }) { {Array.isArray(list) && list?.map((value, index) => ( - + ))} diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Networking/index.js b/src/fireedge/src/public/containers/Application/Create/Steps/Networking/index.js index 34e64420cb..b5d047198a 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Networking/index.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Networking/index.js @@ -1,14 +1,15 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { useWatch } from 'react-hook-form'; + import useOpennebula from 'client/hooks/useOpennebula'; - -import { DialogForm } from 'client/components/Dialogs'; -import { NetworkCard } from 'client/components/Cards'; - import useListForm from 'client/hooks/useListForm'; import FormWithSchema from 'client/components/Forms/FormWithSchema'; import ListCards from 'client/components/List/ListCards'; +import { DialogForm } from 'client/components/Dialogs'; +import { NetworkCard } from 'client/components/Cards'; +import { STEP_ID as TIERS_ID } from 'client/containers/Application/Create/Steps/Tiers'; import { FORM_FIELDS, NETWORK_FORM_SCHEMA, STEP_FORM_SCHEMA } from './schema'; export const STEP_ID = 'networking'; @@ -18,6 +19,7 @@ const Networks = () => ({ label: 'Configure Networking', resolver: STEP_FORM_SCHEMA, content: useCallback(({ data, setFormData }) => { + const form = useWatch({}); const [showDialog, setShowDialog] = useState(false); const { getVNetworks, getVNetworksTemplates } = useOpennebula(); const { @@ -47,21 +49,27 @@ const Networks = () => ({ handleEdit(); setShowDialog(true); }} - cardsProps={({ index }) => ({ - handleEdit: () => { - handleEdit(index); - setShowDialog(true); - }, - handleClone: () => handleClone(index), - handleRemove: () => handleRemove(index) - })} + cardsProps={({ value: { id } }) => { + const isUsed = form[TIERS_ID].some(({ networks }) => + networks?.includes(id) + ); + + return { + handleEdit: () => { + handleEdit(id); + setShowDialog(true); + }, + handleClone: () => handleClone(id), + handleRemove: !isUsed ? () => handleRemove(id) : undefined + }; + }} /> {showDialog && ( { handleSave(values); setShowDialog(false); diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Networking/schema.js b/src/fireedge/src/public/containers/Application/Create/Steps/Networking/schema.js index da7757b928..76abf9c90d 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Networking/schema.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Networking/schema.js @@ -1,106 +1,121 @@ import * as yup from 'yup'; +import { v4 as uuidv4 } from 'uuid'; import { TYPE_INPUT } from 'client/constants'; import { getValidationFromFields } from 'client/utils/helpers'; import useOpennebula from 'client/hooks/useOpennebula'; -export const SELECT = { +const SELECT = { template: 'template', network: 'network' }; -export const TYPES_NETWORKS = [ +const TYPES_NETWORKS = [ { text: 'Create', value: 'template_id', select: SELECT.template }, { text: 'Reserve', value: 'reserve_from', select: SELECT.network }, { text: 'Existing', value: 'id', select: SELECT.network } ]; -export const FORM_FIELDS = [ - { - name: 'mandatory', - label: 'Mandatory', - type: TYPE_INPUT.CHECKBOX, - validation: yup - .boolean() - .required() - .default(false) - }, - { - name: 'name', - label: 'Name', - type: TYPE_INPUT.TEXT, - validation: yup - .string() - .trim() - .required('Name is a required field') - .default('') - }, - { - name: 'description', - label: 'Description', - type: TYPE_INPUT.TEXT, - multiline: true, - validation: yup - .string() - .trim() - .default('') - }, - { - name: 'type', - label: 'Select a type', - type: TYPE_INPUT.SELECT, - values: TYPES_NETWORKS, - validation: yup - .string() - .oneOf(TYPES_NETWORKS.map(({ value }) => value)) - .required('Type is required field') - .default(TYPES_NETWORKS[0].value) - }, - { - name: 'id', - label: `Select a network`, - type: TYPE_INPUT.AUTOCOMPLETE, - dependOf: 'type', - values: dependValue => { - const { vNetworks, vNetworksTemplates } = useOpennebula(); - const type = TYPES_NETWORKS.find(({ value }) => value === dependValue); +const ID = { + name: 'id', + validation: yup + .string() + .uuid() + .required() + .default(uuidv4) +}; - switch (type?.select) { - case SELECT.network: - return vNetworks.map(({ ID, NAME }) => ({ - text: NAME, - value: ID - })); - case SELECT.template: - return vNetworksTemplates.map(({ ID, NAME }) => ({ - text: NAME, - value: ID - })); - default: - return []; - } - }, - validation: yup - .string() - .trim() - .when('type', (type, schema) => - TYPES_NETWORKS.some( - ({ value, select }) => type === value && select === SELECT.network - ) - ? schema.required('Network is required field') - : schema.required('Network template is required field') - ) - .default(undefined) +const MANDATORY = { + name: 'mandatory', + label: 'Mandatory', + type: TYPE_INPUT.CHECKBOX, + validation: yup + .boolean() + .required() + .default(false) +}; + +const NAME = { + name: 'name', + label: 'Name', + type: TYPE_INPUT.TEXT, + validation: yup + .string() + .trim() + .required('Name is a required field') + .default('') +}; + +const DESCRIPTION = { + name: 'description', + label: 'Description', + type: TYPE_INPUT.TEXT, + multiline: true, + validation: yup + .string() + .trim() + .default('') +}; + +const TYPE = { + name: 'type', + label: 'Select a type', + type: TYPE_INPUT.SELECT, + values: TYPES_NETWORKS, + validation: yup + .string() + .oneOf(TYPES_NETWORKS.map(({ value }) => value)) + .required('Type is required field') + .default(TYPES_NETWORKS[0].value) +}; + +const ID_VNET = { + name: 'id_vnet', + label: `Select a network`, + type: TYPE_INPUT.AUTOCOMPLETE, + dependOf: TYPE.name, + values: dependValue => { + const { vNetworks, vNetworksTemplates } = useOpennebula(); + const type = TYPES_NETWORKS.find(({ value }) => value === dependValue); + + const values = + type?.select === SELECT.network ? vNetworks : vNetworksTemplates; + + return values + .map(({ ID: value, NAME: text }) => ({ text, value })) + .sort((a, b) => a.value - b.value); }, - { - name: 'extra', - label: 'Extra', - multiline: true, - type: TYPE_INPUT.TEXT, - validation: yup - .string() - .trim() - .default('') - } + validation: yup + .string() + .trim() + .when(TYPE.name, (type, schema) => + TYPES_NETWORKS.some( + ({ value, select }) => type === value && select === SELECT.network + ) + ? schema.required('Network is required field') + : schema.required('Network template is required field') + ) + .default(undefined) +}; + +const EXTRA = { + name: 'extra', + label: 'Extra', + multiline: true, + type: TYPE_INPUT.TEXT, + validation: yup + .string() + .trim() + .default('') +}; + +export const FORM_FIELDS = [ + ID, + MANDATORY, + NAME, + DESCRIPTION, + TYPE, + ID_VNET, + EXTRA ]; export const NETWORK_FORM_SCHEMA = yup.object( diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/CustomNode.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/CustomNode.js index 2911378328..7990c12de9 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/CustomNode.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/CustomNode.js @@ -1,14 +1,72 @@ -import React, { memo } from 'react'; +import React, { memo, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { Handle } from 'react-flow-renderer'; +import { + Handle, + useStoreState, + getOutgoers, + addEdge +} from 'react-flow-renderer'; import { TierCard } from 'client/components/Cards'; -const CustomNode = memo(({ data }) => { +const CustomNode = memo(({ data, selected, ...nodeProps }) => { const { tier, handleEdit } = data; + const elements = useStoreState(state => state.elements); + const nodes = useStoreState(state => state.nodes); - const isValidConnection = ({ target }) => !tier?.parents?.includes(target); + const detectCycleUtil = useCallback( + (node, elementsTemp, visited, recStack) => { + const { id: nodeId } = node.data; + + if (!visited[nodeId]) { + visited[nodeId] = true; + recStack[nodeId] = true; + + const children = getOutgoers(node, elementsTemp); + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + const { id: childId } = child.data; + if ( + !visited[childId] && + detectCycleUtil(child, elementsTemp, visited, recStack) + ) { + return true; + } else if (recStack[childId]) { + return true; + } + } + } + + recStack[nodeId] = false; + return false; + }, + [] + ); + + const detectCycle = useCallback( + params => { + const elementsTemp = addEdge(params, elements); + const visited = {}; + const recStack = {}; + + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + if (detectCycleUtil(node, elementsTemp, visited, recStack)) { + return false; + } + } + + return true; + }, + [nodes, elements, detectCycleUtil] + ); + + const isValidConnection = useCallback( + ({ source, target }) => + source !== target ? detectCycle({ source, target }) : false, + [detectCycle] + ); return ( <> @@ -16,19 +74,18 @@ const CustomNode = memo(({ data }) => { values={tier} handleEdit={handleEdit} cardProps={{ + elevation: selected ? 6 : 1, style: { minWidth: 200 } }} /> @@ -36,11 +93,13 @@ const CustomNode = memo(({ data }) => { }); CustomNode.propTypes = { - data: PropTypes.objectOf(PropTypes.any) + data: PropTypes.objectOf(PropTypes.any), + selected: PropTypes.bool }; CustomNode.defaultProps = { - data: {} + data: {}, + selected: false }; export default CustomNode; diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/index.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/index.js new file mode 100644 index 0000000000..bd62a09594 --- /dev/null +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/index.js @@ -0,0 +1,95 @@ +import React, { memo, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { makeStyles, Box } from '@material-ui/core'; +import { Add as AddIcon, SelectAll as SelectAllIcon } from '@material-ui/icons'; +import ReactFlow, { Background } from 'react-flow-renderer'; +import { useFormContext } from 'react-hook-form'; + +import SpeedDials from 'client/components/SpeedDials'; +import { STEP_ID as TIER_ID } from 'client/containers/Application/Create/Steps/Tiers'; + +import CustomNode from './CustomNode'; +import useFlowGraph from './useFlowGraph'; + +const NOT_KEY_CODE = -1; + +const useStyles = makeStyles(() => ({ + root: { + '& .react-flow__handle': { + backgroundColor: 'rgba(0,0,0,0.2)', + '&-connecting': { + backgroundColor: '#ff6060' + }, + '&-valid': { + backgroundColor: '#55dd99' + } + } + } +})); + +const Flow = memo(({ dataFields, handleCreate, handleEdit, handleSetData }) => { + const { watch } = useFormContext(); + const classes = useStyles(); + const { + flow, + handleRefreshFlow, + handleRemoveElements, + handleConnect, + handleUpdatePosition, + handleSelectAll + } = useFlowGraph({ nodeFields: dataFields, setList: handleSetData }); + + useEffect(() => { + handleRefreshFlow(watch(TIER_ID), ({ id }) => ({ + handleEdit: () => handleEdit(id) + })); + }, [watch]); + + const actions = useMemo( + () => [ + { + icon: , + name: 'Add', + handleClick: handleCreate + }, + { + icon: , + name: 'Select all', + handleClick: handleSelectAll + } + ], + [handleCreate, handleSelectAll] + ); + + return ( + + + + + ); +}); + +Flow.propTypes = { + dataFields: PropTypes.arrayOf(PropTypes.string), + handleCreate: PropTypes.func, + handleEdit: PropTypes.func, + handleSetData: PropTypes.func +}; + +Flow.defaultProps = { + dataFields: [], + handleCreate: undefined, + handleEdit: undefined, + handleSetData: undefined +}; + +export default Flow; diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/useFlowGraph.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/useFlowGraph.js new file mode 100644 index 0000000000..70ea8c9d7b --- /dev/null +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Flow/useFlowGraph.js @@ -0,0 +1,98 @@ +import { useCallback, useState } from 'react'; + +import { + isNode, + isEdge, + addEdge, + removeElements, + useStoreActions +} from 'react-flow-renderer'; + +const useFlowGraph = ({ nodeFields, setList }) => { + const [flow, setFlow] = useState([]); + const setSelectedElements = useStoreActions( + actions => actions.setSelectedElements + ); + + const getParents = (currentFlow, childId) => + currentFlow + .filter(isEdge) + .filter(({ target }) => childId === target) + .map(({ source }) => source); + + const getList = currentFlow => + currentFlow?.filter(isNode)?.map(({ data: nodeData, position }) => + Object.keys(nodeData) + .filter(key => nodeFields.includes(key)) + .reduce( + (res, key) => ({ + ...res, + [key]: nodeData[key], + parents: getParents(currentFlow, nodeData.id), + position + }), + {} + ) + ); + + const handleRefreshFlow = useCallback((data, extraItemProps) => { + setFlow( + data?.reduce( + (res, { position, parents, id, ...item }) => [ + ...res, + { + id, + type: 'tier', + position, + data: { id, ...item, ...extraItemProps({ id }) } + }, + ...(parents?.map(parent => ({ + id: `edge__${id}__${parent}`, + source: parent, + target: id, + animated: true + })) ?? []) + ], + [] + ) + ); + }, []); + + const updateList = newFlow => { + const list = getList(newFlow); + setList(list); + }; + + const handleRemoveElements = elements => { + const newFlow = removeElements(elements, flow); + updateList(newFlow); + }; + + const handleConnect = params => { + const newFlow = addEdge({ ...params, animated: true }, flow); + updateList(newFlow); + }; + + const handleUpdatePosition = (_, node) => { + const newFlow = flow.map(element => + element.id === node.id ? node : element + ); + updateList(newFlow); + }; + + const handleSelectAll = useCallback(() => { + const nodes = flow.filter(isNode); + setSelectedElements(nodes.map(({ id, type }) => ({ id, type }))); + }, [flow]); + + return { + flow, + handleRefreshFlow, + handleRemoveElements, + handleConnect, + handleUpdatePosition, + handleSelectAll + }; +}; + +export default useFlowGraph; diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/index.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/index.js index 1c81536dc4..1fa0cc6c43 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/index.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/index.js @@ -26,12 +26,12 @@ const Networks = () => ({ ({ - ID: String(index), - NAME: value?.name, - isSelected: data?.some(selected => selected === index), - handleSelect: () => handleSelect(index), - handleUnselect: () => handleUnselect(index) + cardsProps={({ value: { id, name } }) => ({ + ID: id, + NAME: name, + isSelected: data?.some(selected => selected === id), + handleSelect: () => handleSelect(id), + handleUnselect: () => handleUnselect(id) })} /> ); diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/schema.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/schema.js index cff9aa26b9..9878822e09 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/schema.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Networks/schema.js @@ -3,4 +3,4 @@ import * as yup from 'yup'; export const STEP_FORM_SCHEMA = yup .array() .of(yup.string().trim()) - .default(undefined); + .default([]); diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Policies/schema.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Policies/schema.js index 49cdc984bd..153de56d0c 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Policies/schema.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Policies/schema.js @@ -10,7 +10,6 @@ export const FORM_FIELDS = [ validation: yup .string() .trim() - .required() .default('') }, { @@ -20,7 +19,6 @@ export const FORM_FIELDS = [ validation: yup .string() .trim() - .required() .default('') } ]; diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Template/schema.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Template/schema.js index 2a8f7e0503..0267802009 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Template/schema.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/Template/schema.js @@ -18,5 +18,5 @@ export const FORM_FIELDS = [ export const STEP_FORM_SCHEMA = yup .object(getValidationFromFields(FORM_FIELDS)) - .required('Template is required') + // .required('Template is required') .default(undefined); diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/index.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/index.js index 1befb29856..e812db0639 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/index.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/Steps/index.js @@ -1,4 +1,5 @@ import * as yup from 'yup'; +import { v4 as uuidv4 } from 'uuid'; import BasicConfiguration from './BasicConfiguration'; import Networks from './Networks'; @@ -14,13 +15,22 @@ const Steps = () => { const steps = [basic, networks, template, policies]; const resolvers = yup.object({ + id: yup + .string() + .uuid() + .default(uuidv4), [basic.id]: basic.resolver, [networks.id]: networks.resolver, [template.id]: template.resolver, - [policies.id]: policies.resolver + [policies.id]: policies.resolver, + parents: yup.array().default([]), + position: yup.object({ + x: yup.number().default(0), + y: yup.number().default(0) + }) }); - const defaultValues = resolvers.default(); + const defaultValues = () => resolvers.default(); return { steps, defaultValues, resolvers }; }; diff --git a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/index.js b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/index.js index e6ece56aa7..84b88bfbf1 100644 --- a/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/index.js +++ b/src/fireedge/src/public/containers/Application/Create/Steps/Tiers/index.js @@ -1,24 +1,19 @@ import React, { useEffect, useState, useCallback, createContext } from 'react'; import * as yup from 'yup'; -import { useWatch } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; -import { Add as AddIcon, Refresh as RefreshIcon } from '@material-ui/icons'; -import ReactFlow, { - ReactFlowProvider, - Background, - addEdge -} from 'react-flow-renderer'; -import dagre from 'dagre'; +import { ReactFlowProvider } from 'react-flow-renderer'; +import { Box } from '@material-ui/core'; import useListForm from 'client/hooks/useListForm'; import FormStepper from 'client/components/FormStepper'; import { DialogForm } from 'client/components/Dialogs'; -import { generateFlow } from 'client/utils/flow'; -import SpeedDials from 'client/components/SpeedDials'; +import { STEP_ID as NETWORKING_ID } from 'client/containers/Application/Create/Steps/Networking'; +import { STEP_ID as NETWORKS_ID } from 'client/containers/Application/Create/Steps/Tiers/Steps/Networks'; import Steps from './Steps'; -import CustomNode from './Flow/CustomNode'; +import Flow from './Flow'; export const Context = createContext({}); export const STEP_ID = 'tiers'; @@ -26,6 +21,30 @@ export const STEP_ID = 'tiers'; const Tiers = () => { const { steps, defaultValues, resolvers } = Steps(); + const FRONTEND = { + ...defaultValues(), + parents: [], + tier: { name: 'frontend', cardinality: 1 } + }; + + const MASTER = { + ...defaultValues(), + parents: [FRONTEND.id], + tier: { name: 'master', cardinality: 1 } + }; + + const MINION = { + ...defaultValues(), + parents: [FRONTEND.id], + tier: { name: 'minion', cardinality: 1 } + }; + + const WORKER = { + ...defaultValues(), + parents: [MASTER.id, MINION.id], + tier: { name: 'worker', cardinality: 10 } + }; + return { id: STEP_ID, label: 'Tier Definition', @@ -34,122 +53,76 @@ const Tiers = () => { .of(resolvers) .min(1) .required() - .default([]), + .default([FRONTEND, MASTER, MINION, WORKER]), content: useCallback(({ data, setFormData }) => { - const [flow, setFlow] = useState([]); const [showDialog, setShowDialog] = useState(false); const [nestedForm, setNestedForm] = useState({}); const form = useWatch({}); - const { editingData, handleEdit, handleSave } = useListForm({ + const { + editingData, + handleSetList, + handleSave, + handleEdit + } = useListForm({ key: STEP_ID, list: data, setList: setFormData, - defaultValue: defaultValues + defaultValue: defaultValues() }); - const graph = new dagre.graphlib.Graph(); - graph.setGraph({}); - graph.setDefaultEdgeLabel(() => ({})); - - const reDrawFlow = () => { - setFlow( - generateFlow( - graph, - data?.map((item, index) => ({ - id: item.tier.name, - type: 'tier', - data: { - ...item, - handleEdit: () => { - handleEdit(index); - setShowDialog(true); - } - }, - parents: item.tier.parents ?? [] - })) ?? [] - ) - ); + const handleEditTier = id => { + handleEdit(id); + setShowDialog(true); }; useEffect(() => { setNestedForm(form); }, []); - const actions = [ - { - icon: , - name: 'Add', - handleClick: () => { - handleEdit(); - setShowDialog(true); - } - }, - { - icon: , - name: 'Refresh', - handleClick: () => reDrawFlow() - } - ]; + const formSteps = React.useMemo(() => { + const networking = nestedForm[NETWORKING_ID] ?? []; + + return steps.filter( + ({ id }) => id !== NETWORKS_ID || networking.length !== 0 + ); + }, [nestedForm]); return ( - -
- { - const indexChild = data?.findIndex( - item => item?.tier?.name === target - ); - const child = { ...data[indexChild] }; - child.tier.parents = [...(child?.tier?.parents ?? []), source]; - - handleEdit(indexChild); - handleSave(child); - setFlow(prevFlow => - addEdge({ source, target, animated: true }, prevFlow) - ); - }} - onLoad={reactFlowInstance => { - reDrawFlow(); - reactFlowInstance.fitView(); - }} - > - - - -
+ <> + + + handleEditTier()} + handleEdit={handleEditTier} + handleSetData={handleSetList} + /> + + {showDialog && ( setShowDialog(false)} > -
+ { handleSave(values); setShowDialog(false); - reDrawFlow(); }} /> -
+
)} -
+ ); }, []) }; diff --git a/src/fireedge/src/public/hooks/useListForm.js b/src/fireedge/src/public/hooks/useListForm.js index 0f141f13a3..98f03ea5a4 100644 --- a/src/fireedge/src/public/hooks/useListForm.js +++ b/src/fireedge/src/public/hooks/useListForm.js @@ -1,78 +1,108 @@ import { useCallback, useState } from 'react'; +const NEXT_INDEX = index => index + 1; +const EXISTS_INDEX = index => index !== -1; + +const getIndexById = (list, id) => + list.findIndex(({ id: itemId }) => itemId === id); + const useListSelect = ({ multiple, key, list, setList, defaultValue }) => { const [editingData, setEditingData] = useState({}); const handleSelect = useCallback( - index => - setList(prevData => ({ - ...prevData, - [key]: multiple ? [...(prevData[key] ?? []), index] : [index] + id => + setList(prevList => ({ + ...prevList, + [key]: multiple ? [...(prevList[key] ?? []), id] : [id] })), - [key, list, multiple] + [key, setList, multiple] ); const handleUnselect = useCallback( - indexRemove => - setList(prevData => ({ - ...prevData, - [key]: prevData[key]?.filter(index => index !== indexRemove) + id => + setList(prevList => ({ + ...prevList, + [key]: prevList[key]?.filter(item => item !== id) })), [key, list] ); - const handleSave = useCallback( - values => { - setList(prevData => ({ - ...prevData, - [key]: Object.assign(prevData[key], { - [editingData.index]: values - }) - })); - }, - [key, list, editingData] - ); - - const handleEdit = useCallback( - (index = list?.length) => { - const openData = list[index] ?? defaultValue; - - setEditingData({ index, data: openData }); - }, - [list, defaultValue] - ); - const handleClone = useCallback( - index => { - const item = list[index]; - const cloneItem = { ...item, name: `${item?.name}_clone` }; - const cloneData = [...list]; - cloneData.splice(index + 1, 0, cloneItem); + id => { + const itemIndex = getIndexById(list, id); + const { id: itemId, name, ...item } = list[itemIndex]; + const cloneList = [...list]; + const cloneItem = { + ...item, + id: defaultValue.id, + name: `${name ?? itemId}_clone` + }; - setList(prevData => ({ ...prevData, [key]: cloneData })); + const ZERO_DELETE_COUNT = 0; + cloneList.splice(NEXT_INDEX(itemIndex), ZERO_DELETE_COUNT, cloneItem); + + setList(prevList => ({ ...prevList, [key]: cloneList })); }, - [list] + [list, setList] ); const handleRemove = useCallback( - indexRemove => { + id => { // TODO confirmation?? - setList(prevData => ({ - ...prevData, - [key]: prevData[key]?.filter((_, index) => index !== indexRemove) + setList(prevList => ({ + ...prevList, + [key]: prevList[key]?.filter(item => item.id !== id) })); }, - [key, list] + [key, setList] + ); + + const handleSetList = useCallback( + newList => { + setList(prevList => ({ ...prevList, [key]: newList })); + }, + [key, setList] + ); + + const handleSave = useCallback( + (values, id) => { + setList(prevList => { + const itemIndex = getIndexById(prevList[key], id ?? editingData.id); + const index = EXISTS_INDEX(itemIndex) + ? itemIndex + : prevList[key].length; + + return { + ...prevList, + [key]: Object.assign(prevList[key], { + [index]: { ...editingData, ...values } + }) + }; + }); + }, + [key, setList, editingData] + ); + + const handleEdit = useCallback( + id => { + const index = list.findIndex(item => item.id === id); + const openData = list[index] ?? defaultValue; + + setEditingData(openData); + }, + [list, defaultValue] ); return { editingData, handleSelect, handleUnselect, - handleSave, - handleEdit, handleClone, - handleRemove + handleRemove, + handleSetList, + + handleSave, + handleEdit }; }; diff --git a/src/fireedge/src/public/hooks/useNearScreen.js b/src/fireedge/src/public/hooks/useNearScreen.js index 71526344d2..9e6f8ec5c8 100644 --- a/src/fireedge/src/public/hooks/useNearScreen.js +++ b/src/fireedge/src/public/hooks/useNearScreen.js @@ -26,7 +26,6 @@ const useNearScreen = ({ externalRef, distance, once = true } = {}) => { : import('intersection-observer') ).then(() => { observer = new IntersectionObserver(onChange, { - // root: listRef.current rootMargin: distance }); diff --git a/src/fireedge/src/public/utils/flow.js b/src/fireedge/src/public/utils/flow.js index b39f148847..8fc27a38e2 100644 --- a/src/fireedge/src/public/utils/flow.js +++ b/src/fireedge/src/public/utils/flow.js @@ -1,8 +1,11 @@ import dagre from 'dagre'; -const generateFlow = (graph, elements = []) => { +const generateFlow = (elements = []) => { const NODE_WIDTH = 400; const NODE_HEIGHT = 200; + const graph = new dagre.graphlib.Graph(); + graph.setGraph({}); + graph.setDefaultEdgeLabel(() => ({})); elements.forEach(({ id, type, data, parents = [] }) => { graph.setNode(id, {