1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-20 10:50:08 +03:00

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
This commit is contained in:
Sergio Betanzos 2020-09-28 15:26:45 +02:00 committed by GitHub
parent f5be13b78f
commit 4b2865e609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 623 additions and 430 deletions

View File

@ -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",

View File

@ -62,15 +62,21 @@ const NetworkCard = React.memo(
}}
/>
<CardActions>
<Button variant="contained" size="small" onClick={handleEdit}>
{Tr('Edit')}
</Button>
<Button variant="contained" size="small" onClick={handleClone}>
{Tr('Clone')}
</Button>
<Button size="small" onClick={handleRemove}>
{Tr('Remove')}
</Button>
{handleEdit && (
<Button variant="contained" size="small" onClick={handleEdit}>
{Tr('Edit')}
</Button>
)}
{handleClone && (
<Button variant="contained" size="small" onClick={handleClone}>
{Tr('Clone')}
</Button>
)}
{handleRemove && (
<Button size="small" onClick={handleRemove}>
{Tr('Remove')}
</Button>
)}
</CardActions>
</Card>
</Fade>

View File

@ -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 }) => (
<Card style={{ height: 100 }}>
<div>Custom node</div>
</Card>
));
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 (
<Box flexGrow={1} height={1}>
<ReactFlow
elements={elements}
onElementClick={onElementClick}
onElementsRemove={onElementsRemove}
onConnect={onConnect}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onSelectionDragStart={onSelectionDragStart}
onSelectionDrag={onSelectionDrag}
onSelectionDragStop={onSelectionDragStop}
onSelectionChange={onSelectionChange}
onMoveEnd={onMoveEnd}
onLoad={onLoad}
connectionLineStyle={connectionLineStyle}
snapToGrid
snapGrid={snapGrid}
nodeTypes={{ custom: CustomNode }}
>
<MiniMap
nodeColor={n => {
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';
}}
/>
<Fab
color="primary"
aria-label="add-role"
onClick={handleClick}
style={{ top: 10, left: 10, zIndex: 5 }}
>
<AddIcon />
</Fab>
<Background color="#aaa" gap={16} />
</ReactFlow>
</Box>
);
};
FlowWithFAB.propTypes = {
handleClick: PropTypes.func
};
FlowWithFAB.defaultProps = {
handleClick: evt => evt
};
export default FlowWithFAB;

View File

@ -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
}) => (
<StickyMobileStepper
variant="progress"
position="static"
steps={totalSteps}
activeStep={activeStep}
backButton={
<Button size="small" onClick={handleBack} disabled={disabledBack}>
<KeyboardArrowLeft /> {Tr('Back')}
</Button>
}
nextButton={
<Button size="small" onClick={handleNext}>
{Tr(activeStep === lastStep ? 'Finish' : 'Next')}
<KeyboardArrowRight />
</Button>
}
/>
const CustomMobileStepper = memo(
({
totalSteps,
activeStep,
lastStep,
disabledBack,
handleNext,
handleBack
}) => (
<StickyMobileStepper
variant="progress"
position="static"
steps={totalSteps}
activeStep={activeStep}
backButton={
<Button size="small" onClick={handleBack} disabled={disabledBack}>
<KeyboardArrowLeft /> {Tr('Back')}
</Button>
}
nextButton={
<Button size="small" onClick={handleNext}>
{activeStep === lastStep ? Tr('Finish') : Tr('Next')}
<KeyboardArrowRight />
</Button>
}
/>
),
(prev, next) => prev.activeStep === next.activeStep
);
CustomMobileStepper.propTypes = {

View File

@ -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
}) => (
<>
<StickyStepper activeStep={activeStep}>
{steps?.map(({ label }) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</StickyStepper>
<Box marginY={2}>
<Button onClick={handleBack} disabled={disabledBack}>
{Tr('Back')}
</Button>
<Button variant="contained" color="primary" onClick={handleNext}>
{Tr(activeStep === lastStep ? 'Finish' : 'Next')}
</Button>
</Box>
</>
const CustomStepper = memo(
({ steps, activeStep, lastStep, disabledBack, handleNext, handleBack }) => (
<>
<StickyStepper activeStep={activeStep}>
{steps?.map(({ label }) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</StickyStepper>
<Box marginY={2}>
<Button onClick={handleBack} disabled={disabledBack}>
{Tr('Back')}
</Button>
<Button variant="contained" color="primary" onClick={handleNext}>
{activeStep === lastStep ? Tr('Finish') : Tr('Next')}
</Button>
</Box>
</>
),
(prev, next) => prev.activeStep === next.activeStep
);
CustomStepper.propTypes = {

View File

@ -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),

View File

@ -42,7 +42,7 @@ function ListCards({ handleCreate, list, CardComponent, cardsProps }) {
{Array.isArray(list) &&
list?.map((value, index) => (
<Grid key={`card-${index}`} item xs={12} sm={4} md={3} lg={2}>
<CardComponent value={value} {...cardsProps({ value, index })} />
<CardComponent value={value} {...cardsProps({ index, value })} />
</Grid>
))}
</Grid>

View File

@ -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 && (
<DialogForm
title={'Network form'}
resolver={NETWORK_FORM_SCHEMA}
open={showDialog}
values={editingData?.data}
values={editingData}
onSubmit={values => {
handleSave(values);
setShowDialog(false);

View File

@ -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(

View File

@ -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 }
}}
/>
<Handle
type="source"
position="bottom"
style={{ backgroundColor: 'rgba(0,0,0,0.2)' }}
type="target"
position="top"
isValidConnection={isValidConnection}
/>
<Handle
type="target"
position="top"
style={{ backgroundColor: 'rgba(0,0,0,0.2)' }}
type="source"
position="bottom"
isValidConnection={isValidConnection}
/>
</>
@ -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;

View File

@ -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: <AddIcon />,
name: 'Add',
handleClick: handleCreate
},
{
icon: <SelectAllIcon />,
name: 'Select all',
handleClick: handleSelectAll
}
],
[handleCreate, handleSelectAll]
);
return (
<ReactFlow
className={classes.root}
elements={flow}
nodeTypes={{ tier: CustomNode }}
onConnect={handleConnect}
onNodeDragStop={handleUpdatePosition}
onElementsRemove={handleRemoveElements}
selectionKeyCode={NOT_KEY_CODE}
>
<SpeedDials actions={actions} />
<Background color="#aaa" gap={16} />
</ReactFlow>
);
});
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;

View File

@ -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;

View File

@ -26,12 +26,12 @@ const Networks = () => ({
<ListCards
list={list[NETWORKING]}
CardComponent={SelectCard}
cardsProps={({ value, index }) => ({
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)
})}
/>
);

View File

@ -3,4 +3,4 @@ import * as yup from 'yup';
export const STEP_FORM_SCHEMA = yup
.array()
.of(yup.string().trim())
.default(undefined);
.default([]);

View File

@ -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('')
}
];

View File

@ -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);

View File

@ -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 };
};

View File

@ -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: <AddIcon />,
name: 'Add',
handleClick: () => {
handleEdit();
setShowDialog(true);
}
},
{
icon: <RefreshIcon />,
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 (
<ReactFlowProvider>
<div style={{ height: '100%', flexGrow: 1 }}>
<ReactFlow
elements={flow}
nodeTypes={{ tier: CustomNode }}
onConnect={({ source, target }) => {
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();
}}
>
<SpeedDials actions={actions} />
<Background color="#aaa" gap={16} />
</ReactFlow>
</div>
<>
<ReactFlowProvider>
<Box height={1} flexGrow={1}>
<Flow
dataFields={Object.keys(resolvers.fields)}
handleCreate={() => handleEditTier()}
handleEdit={handleEditTier}
handleSetData={handleSetList}
/>
</Box>
</ReactFlowProvider>
{showDialog && (
<Context.Provider value={{ nestedForm }}>
<DialogForm
open={showDialog}
title={'Tier form'}
resolver={resolvers}
values={editingData.data}
values={editingData}
onCancel={() => setShowDialog(false)}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Box display="flex" flexDirection="column" height={1}>
<FormStepper
steps={steps}
initialValue={editingData.data}
steps={formSteps}
initialValue={editingData}
onSubmit={values => {
handleSave(values);
setShowDialog(false);
reDrawFlow();
}}
/>
</div>
</Box>
</DialogForm>
</Context.Provider>
)}
</ReactFlowProvider>
</>
);
}, [])
};

View File

@ -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
};
};

View File

@ -26,7 +26,6 @@ const useNearScreen = ({ externalRef, distance, once = true } = {}) => {
: import('intersection-observer')
).then(() => {
observer = new IntersectionObserver(onChange, {
// root: listRef.current
rootMargin: distance
});

View File

@ -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, {