|
|
@ -1,21 +1,14 @@
|
|
|
|
import React, { useEffect, useCallback } from 'react';
|
|
|
|
import React, { useEffect, useCallback, useRef } from 'react';
|
|
|
|
import dagre from 'dagre';
|
|
|
|
import dagre from 'dagre';
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
ReactFlow,
|
|
|
|
ReactFlow, useNodesState, useEdgesState, Background,
|
|
|
|
Background,
|
|
|
|
Controls, Node, Edge, Connection, MarkerType } from '@xyflow/react';
|
|
|
|
Controls,
|
|
|
|
|
|
|
|
Node,
|
|
|
|
|
|
|
|
Edge,
|
|
|
|
|
|
|
|
Connection,
|
|
|
|
|
|
|
|
MarkerType
|
|
|
|
|
|
|
|
} from '@xyflow/react';
|
|
|
|
|
|
|
|
import { CircularPlant, CircularProduct, CircularCenter } from './CircularData';
|
|
|
|
import { CircularPlant, CircularProduct, CircularCenter } from './CircularData';
|
|
|
|
import CustomNode, { CustomNodeData } from './NodesAndEdges';
|
|
|
|
import CustomNode, { CustomNodeData } from './NodesAndEdges';
|
|
|
|
import Section from '../Common/Section';
|
|
|
|
import Section from '../Common/Section';
|
|
|
|
import Card from '../Common/Card';
|
|
|
|
import Card from '../Common/Card';
|
|
|
|
import styles from './PipelineBlock.module.css';
|
|
|
|
import styles from './PipelineBlock.module.css';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface PipelineBlockProps {
|
|
|
|
interface PipelineBlockProps {
|
|
|
|
onAddPlant: () => void;
|
|
|
|
onAddPlant: () => void;
|
|
|
|
onAddProduct: () => void;
|
|
|
|
onAddProduct: () => void;
|
|
|
@ -30,68 +23,65 @@ interface PipelineBlockProps {
|
|
|
|
onRemovePlant: (id: string) => void;
|
|
|
|
onRemovePlant: (id: string) => void;
|
|
|
|
onRemoveProduct: (id: string) => void;
|
|
|
|
onRemoveProduct: (id: string) => void;
|
|
|
|
onRemoveCenter: (id: string) => void;
|
|
|
|
onRemoveCenter: (id: string) => void;
|
|
|
|
onRenameProduct: (prevName: string, newName: string) => void;
|
|
|
|
onRenameProduct: (uid: string, newName: string) => void;
|
|
|
|
onRenamePlant: (prevName: string, newName: string) => void;
|
|
|
|
onRenamePlant: (uid: string, newName: string) => void;
|
|
|
|
onRenameCenter: (prevName: string, newName: string) => void;
|
|
|
|
onRenameCenter: (uid: string, newName: string) => void;
|
|
|
|
products: Record<string, CircularProduct>;
|
|
|
|
products: Record<string, CircularProduct>;
|
|
|
|
plants: Record<string, CircularPlant>;
|
|
|
|
plants: Record<string, CircularPlant>;
|
|
|
|
centers: Record<string, CircularCenter>;
|
|
|
|
centers: Record<string, CircularCenter>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLayoutedNodesAndEdges(
|
|
|
|
function getLayouted(
|
|
|
|
nodes: Node<CustomNodeData>[],
|
|
|
|
nodes: Node<CustomNodeData>[],
|
|
|
|
edges: Edge[]
|
|
|
|
edges: Edge[]
|
|
|
|
): { nodes: Node<CustomNodeData>[]; edges: Edge[] } {
|
|
|
|
): { nodes: Node<CustomNodeData>[]; edges: Edge[] } {
|
|
|
|
const NODE_WIDTH = 125;
|
|
|
|
const W = 125, H = 45;
|
|
|
|
const NODE_HEIGHT = 45;
|
|
|
|
|
|
|
|
const g = new dagre.graphlib.Graph();
|
|
|
|
const g = new dagre.graphlib.Graph();
|
|
|
|
g.setDefaultEdgeLabel(() => ({}));
|
|
|
|
g.setDefaultEdgeLabel(() => ({}));
|
|
|
|
g.setGraph({ rankdir: 'LR' });
|
|
|
|
g.setGraph({ rankdir: 'LR' });
|
|
|
|
nodes.forEach(n => g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }));
|
|
|
|
nodes.forEach(n => g.setNode(n.id, { width: W, height: H }));
|
|
|
|
edges.forEach(e => g.setEdge(e.source, e.target));
|
|
|
|
edges.forEach(e => g.setEdge(e.source, e.target));
|
|
|
|
dagre.layout(g);
|
|
|
|
dagre.layout(g);
|
|
|
|
const layouted = nodes.map(n => {
|
|
|
|
|
|
|
|
const d = g.node(n.id)!;
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
...n,
|
|
|
|
nodes: nodes.map(n => {
|
|
|
|
position: {
|
|
|
|
const d = g.node(n.id)!;
|
|
|
|
x: d.x - NODE_WIDTH / 2,
|
|
|
|
return { ...n, position: { x: d.x - W/2, y: d.y - H/2 } };
|
|
|
|
y: d.y - NODE_HEIGHT / 2
|
|
|
|
}),
|
|
|
|
}
|
|
|
|
edges
|
|
|
|
};
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
return { nodes: layouted, edges };
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const PipelineBlock: React.FC<PipelineBlockProps> = props => {
|
|
|
|
const PipelineBlock: React.FC<PipelineBlockProps> = props => {
|
|
|
|
const nodes: Node<CustomNodeData>[] = [];
|
|
|
|
const mapRef = useRef<Record<string, 'plant'|'product'|'center'>>({});
|
|
|
|
const edges: Edge[] = [];
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
|
|
const mapNameToType: Record<string, 'plant' | 'product' | 'center'> = {};
|
|
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
|
let hasNullPositions = false;
|
|
|
|
|
|
|
|
Object.entries(props.products).forEach(([key, product]) => {
|
|
|
|
const rebuild = useCallback(() => {
|
|
|
|
if (!product.x || !product.y) hasNullPositions = true;
|
|
|
|
const m: Record<string, 'plant'|'product'|'center'> = {};
|
|
|
|
mapNameToType[key] = 'product';
|
|
|
|
const newNodes: Node<CustomNodeData>[] = [];
|
|
|
|
nodes.push({
|
|
|
|
const newEdges: Edge[] = [];
|
|
|
|
id: product.uid,
|
|
|
|
Object.entries(props.products).forEach(([key, p]) => {
|
|
|
|
|
|
|
|
m[key] = 'product';
|
|
|
|
|
|
|
|
newNodes.push({
|
|
|
|
|
|
|
|
id: p.uid,
|
|
|
|
type: 'default',
|
|
|
|
type: 'default',
|
|
|
|
data: { label: product.name, type: 'product' },
|
|
|
|
data: { label: p.name, type: 'product' },
|
|
|
|
position: { x: product.x, y: product.y },
|
|
|
|
position: { x: p.x, y: p.y },
|
|
|
|
className: 'ProductNode'
|
|
|
|
className: 'ProductNode'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
Object.entries(props.plants).forEach(([key, plant]) => {
|
|
|
|
|
|
|
|
if (!plant.x || !plant.y) hasNullPositions = true;
|
|
|
|
Object.entries(props.plants).forEach(([key, pl]) => {
|
|
|
|
mapNameToType[key] = 'plant';
|
|
|
|
m[key] = 'plant';
|
|
|
|
nodes.push({
|
|
|
|
newNodes.push({
|
|
|
|
id: plant.uid,
|
|
|
|
id: pl.uid,
|
|
|
|
type: 'default',
|
|
|
|
type: 'default',
|
|
|
|
data: { label: plant.name, type: 'plant' },
|
|
|
|
data: { label: pl.name, type: 'plant' },
|
|
|
|
position: { x: plant.x, y: plant.y },
|
|
|
|
position: { x: pl.x, y: pl.y },
|
|
|
|
className: 'PlantNode'
|
|
|
|
className: 'PlantNode'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
plant.inputs.forEach(input => {
|
|
|
|
|
|
|
|
edges.push({
|
|
|
|
pl.inputs.forEach(input => {
|
|
|
|
|
|
|
|
newEdges.push({
|
|
|
|
id: `${input}-${key}-in`,
|
|
|
|
id: `${input}-${key}-in`,
|
|
|
|
source: input,
|
|
|
|
source: input,
|
|
|
|
target: key,
|
|
|
|
target: key,
|
|
|
@ -100,8 +90,9 @@ const PipelineBlock: React.FC<PipelineBlockProps> = props => {
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
plant.outputs.forEach(output => {
|
|
|
|
|
|
|
|
edges.push({
|
|
|
|
pl.outputs.forEach(output => {
|
|
|
|
|
|
|
|
newEdges.push({
|
|
|
|
id: `${key}-${output}-out`,
|
|
|
|
id: `${key}-${output}-out`,
|
|
|
|
source: key,
|
|
|
|
source: key,
|
|
|
|
target: output,
|
|
|
|
target: output,
|
|
|
@ -111,142 +102,138 @@ const PipelineBlock: React.FC<PipelineBlockProps> = props => {
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
Object.entries(props.centers).forEach(([key, center]) => {
|
|
|
|
|
|
|
|
if (!center.x || !center.y) hasNullPositions = true;
|
|
|
|
Object.entries(props.centers).forEach(([key, c]) => {
|
|
|
|
mapNameToType[key] = 'center';
|
|
|
|
m[key] = 'center';
|
|
|
|
nodes.push({
|
|
|
|
newNodes.push({
|
|
|
|
id: center.uid,
|
|
|
|
id: c.uid,
|
|
|
|
type: 'default',
|
|
|
|
type: 'default',
|
|
|
|
data: { label: center.name, type: 'center' },
|
|
|
|
data: { label: c.name, type: 'center' },
|
|
|
|
position: { x: center.x, y: center.y },
|
|
|
|
position: { x: c.x, y: c.y },
|
|
|
|
className: 'CenterNode'
|
|
|
|
className: 'CenterNode'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
if (center.input) {
|
|
|
|
|
|
|
|
edges.push({
|
|
|
|
if (c.input) {
|
|
|
|
id: `${center.input}-${key}-in`,
|
|
|
|
newEdges.push({
|
|
|
|
source: center.input,
|
|
|
|
id: `${c.input}-${key}-in`,
|
|
|
|
|
|
|
|
source: c.input,
|
|
|
|
target: key,
|
|
|
|
target: key,
|
|
|
|
animated: true,
|
|
|
|
animated: true,
|
|
|
|
style: { stroke: 'black' },
|
|
|
|
style: { stroke: 'black' },
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
center.output.forEach(out => {
|
|
|
|
|
|
|
|
edges.push({
|
|
|
|
c.output.forEach(o => {
|
|
|
|
id: `${key}-${out}-out`,
|
|
|
|
newEdges.push({
|
|
|
|
|
|
|
|
id: `${key}-${o}-out`,
|
|
|
|
source: key,
|
|
|
|
source: key,
|
|
|
|
target: out,
|
|
|
|
target: o,
|
|
|
|
animated: true,
|
|
|
|
animated: true,
|
|
|
|
style: { stroke: 'black' },
|
|
|
|
style: { stroke: 'black' },
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
markerEnd: { type: MarkerType.ArrowClosed }
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
const onConnect = (params: Connection) => {
|
|
|
|
mapRef.current = m;
|
|
|
|
const { source, target } = params;
|
|
|
|
setNodes(newNodes);
|
|
|
|
if (!source || !target) return;
|
|
|
|
setEdges(newEdges);
|
|
|
|
const sourceType = mapNameToType[source];
|
|
|
|
}, [
|
|
|
|
const targetType = mapNameToType[target];
|
|
|
|
|
|
|
|
if (sourceType === 'product' && targetType === 'plant') props.onSetPlantInput(target, source);
|
|
|
|
props.products,
|
|
|
|
else if (sourceType === 'plant' && targetType === 'product') props.onAddPlantOutput(source, target);
|
|
|
|
props.plants,
|
|
|
|
else if (sourceType === 'product' && targetType === 'center') props.onAddCenterInput(target, source);
|
|
|
|
props.centers,
|
|
|
|
else if (sourceType === 'center' && targetType === 'product') props.onAddCenterOutput(source, target);
|
|
|
|
setNodes,
|
|
|
|
|
|
|
|
setEdges
|
|
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { rebuild(); }, [rebuild]);
|
|
|
|
|
|
|
|
const onConnect = (c: Connection) => {
|
|
|
|
|
|
|
|
const s = c.source!, t = c.target!;
|
|
|
|
|
|
|
|
const st = mapRef.current[s], tt = mapRef.current[t];
|
|
|
|
|
|
|
|
if (st==='product' && tt==='plant') props.onSetPlantInput(t, s);
|
|
|
|
|
|
|
|
else if (st==='plant' && tt==='product') props.onAddPlantOutput(s, t);
|
|
|
|
|
|
|
|
else if (st==='product' && tt==='center') props.onAddCenterInput(t, s);
|
|
|
|
|
|
|
|
else if (st==='center' && tt==='product') props.onAddCenterOutput(s, t);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onNodeDragStop = (_: any, node: Node<CustomNodeData>) => {
|
|
|
|
const onNodeDragStop = (_: any, n: Node<CustomNodeData>) => {
|
|
|
|
const { id, position, data } = node;
|
|
|
|
const { id, position, data } = n;
|
|
|
|
if (data.type === 'plant') props.onMovePlant(id, position.x, position.y);
|
|
|
|
if (data.type==='plant') props.onMovePlant(id, position.x, position.y);
|
|
|
|
if (data.type === 'product') props.onMoveProduct(id, position.x, position.y);
|
|
|
|
if (data.type==='product') props.onMoveProduct(id, position.x, position.y);
|
|
|
|
if (data.type === 'center') props.onMoveCenter(id, position.x, position.y);
|
|
|
|
if (data.type==='center') props.onMoveCenter(id, position.x, position.y);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNodesDelete = useCallback(
|
|
|
|
const handleNodesDelete = useCallback((deleted: Node<CustomNodeData>[]) => {
|
|
|
|
(deleted: Node<CustomNodeData>[]) => {
|
|
|
|
|
|
|
|
deleted.forEach(n => {
|
|
|
|
deleted.forEach(n => {
|
|
|
|
const type = mapNameToType[n.id];
|
|
|
|
const t = mapRef.current[n.id];
|
|
|
|
if (type === 'plant') props.onRemovePlant(n.id);
|
|
|
|
if (t==='plant') props.onRemovePlant(n.id);
|
|
|
|
else if (type === 'product') props.onRemoveProduct(n.id);
|
|
|
|
if (t==='product') props.onRemoveProduct(n.id);
|
|
|
|
else if (type === 'center') props.onRemoveCenter(n.id);
|
|
|
|
if (t==='center') props.onRemoveCenter(n.id);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
[props]
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const onNodeDoubleClick = (ev: React.MouseEvent, node: Node<CustomNodeData>) => {
|
|
|
|
}, [props]);
|
|
|
|
const oldName = node.data.label;
|
|
|
|
|
|
|
|
const newName = window.prompt("Enter new name", oldName);
|
|
|
|
const onNodeDoubleClick = (_: React.MouseEvent, n: Node<CustomNodeData>) => {
|
|
|
|
if (!newName|| newName.trim().length === 0) return;
|
|
|
|
const oldName = n.data.label;
|
|
|
|
if (newName in mapNameToType) return;
|
|
|
|
const newName = window.prompt('Enter new name', oldName);
|
|
|
|
if (node.data.type === "plant") {
|
|
|
|
console.log('after rename', newName);
|
|
|
|
props.onRenamePlant(oldName, newName);
|
|
|
|
const uniqueId = n.id;
|
|
|
|
} else if (node.data.type === "product") {
|
|
|
|
if (!newName || newName===oldName) return;
|
|
|
|
props.onRenameProduct(oldName, newName);
|
|
|
|
if (n.data.type==='plant') props.onRenamePlant(uniqueId, newName);
|
|
|
|
}
|
|
|
|
if (n.data.type==='product') props.onRenameProduct(uniqueId, newName);
|
|
|
|
else if (node.data.type === "center") {
|
|
|
|
if (n.data.type==='center') props.onRenameCenter(uniqueId, newName);
|
|
|
|
props.onRenameCenter(oldName, newName);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onLayout = () => {
|
|
|
|
const onLayout = () => {
|
|
|
|
const { nodes: ln, edges: le } = getLayoutedNodesAndEdges(nodes, edges);
|
|
|
|
const { nodes: ln, edges: le } = getLayouted(nodes, edges);
|
|
|
|
ln.forEach(n => {
|
|
|
|
ln.forEach(n => {
|
|
|
|
const { id, position, data } = n;
|
|
|
|
const { id, position, data } = n;
|
|
|
|
if (data.type === 'plant') props.onMovePlant(id, position.x, position.y);
|
|
|
|
if (data.type==='plant') props.onMovePlant(id, position.x, position.y);
|
|
|
|
else if (data.type === 'product') props.onMoveProduct(id, position.x, position.y);
|
|
|
|
else if (data.type==='product') props.onMoveProduct(id, position.x, position.y);
|
|
|
|
else props.onMoveCenter(id, position.x, position.y);
|
|
|
|
else props.onMoveCenter(id, position.x, position.y);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (hasNullPositions) onLayout();
|
|
|
|
|
|
|
|
}, [hasNullPositions]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<Section title="Pipeline" />
|
|
|
|
<Section title="Pipeline" />
|
|
|
|
<Card>
|
|
|
|
<Card>
|
|
|
|
<div className={styles.PipelineBlock}>
|
|
|
|
<div className={styles.PipelineBlock}>
|
|
|
|
<ReactFlow
|
|
|
|
<ReactFlow
|
|
|
|
nodes={nodes}
|
|
|
|
nodes={nodes}
|
|
|
|
edges={edges}
|
|
|
|
edges={edges}
|
|
|
|
|
|
|
|
onNodesChange={onNodesChange}
|
|
|
|
|
|
|
|
onEdgesChange={onEdgesChange}
|
|
|
|
onConnect={onConnect}
|
|
|
|
onConnect={onConnect}
|
|
|
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
|
|
onNodeDoubleClick={onNodeDoubleClick}
|
|
|
|
onNodeDragStop={onNodeDragStop}
|
|
|
|
onNodeDragStop={onNodeDragStop}
|
|
|
|
onNodesDelete={handleNodesDelete}
|
|
|
|
onNodesDelete={handleNodesDelete}
|
|
|
|
deleteKeyCode="delete"
|
|
|
|
deleteKeyCode="Delete"
|
|
|
|
maxZoom={1.25}
|
|
|
|
maxZoom={1.25}
|
|
|
|
minZoom={0.5}
|
|
|
|
minZoom={0.5}
|
|
|
|
snapToGrid
|
|
|
|
snapToGrid
|
|
|
|
preventScrolling
|
|
|
|
preventScrolling
|
|
|
|
nodeTypes={{ default: CustomNode }}
|
|
|
|
nodeTypes={{ default: CustomNode }}
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<Background />
|
|
|
|
<Background />
|
|
|
|
<Controls showInteractive={false} />
|
|
|
|
<Controls showInteractive={false} />
|
|
|
|
</ReactFlow>
|
|
|
|
</ReactFlow>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
|
|
|
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddProduct}>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddProduct}>Add product</button>
|
|
|
|
Add product
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddPlant}>Add plant</button>
|
|
|
|
</button>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddCenter}>Add center</button>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddPlant}>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={onLayout}>Auto Layout</button>
|
|
|
|
Add plant
|
|
|
|
<button title="Drag & connect. Double-click to rename. Delete to remove." style={{ margin: '0 8px' }}>?</button>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={props.onAddCenter}>
|
|
|
|
</Card>
|
|
|
|
Add center
|
|
|
|
</>
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button style={{ margin: '0 8px' }} onClick={onLayout}>
|
|
|
|
|
|
|
|
Auto Layout
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
style={{ margin: '0 8px' }}
|
|
|
|
|
|
|
|
title="Drag from one connector to another to create links. Double click to rename. Click to move. Press Delete to remove."
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
?
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
);
|
|
|
|
);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default PipelineBlock;
|
|
|
|
export default PipelineBlock;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|