diff options
Diffstat (limited to 'graphgraph-fe')
39 files changed, 1553 insertions, 1292 deletions
diff --git a/graphgraph-fe/package.json b/graphgraph-fe/package.json index eaae97a..a124d60 100644 --- a/graphgraph-fe/package.json +++ b/graphgraph-fe/package.json @@ -1,41 +1,41 @@ { - "name": "graphgraph-fe", - "version": "0.0.1", - "private": true, - "dependencies": { - "bootstrap-css-only": "3.3.7", - "d3": "5.7.0", - "eslint-config-react-app": "3.0.6", - "react": "^16.8.6", - "react-table": "6.10.0", - "react-bootstrap": "0.32.4", - "react-dom": "^16.8.6", - "react-numeric-input": "2.2.3", - "react-scripts": "2.1.1", - "reactjs-popup": "1.3.2", - "underscore": "1.9.1" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": "react-app" - }, - "browserslist": [ - ">0.2%", - "not dead", - "not ie <= 11", - "not op_mini all" - ], - "devDependencieseslint": { - "eslint": "^5.6.0", - "eslint-config-standard": "^12.0.0", - "eslint-plugin-import": "^2.17.2", - "eslint-plugin-node": "^8.0.1", - "eslint-plugin-promise": "^4.1.1", - "eslint-plugin-standard": "^4.0.0" - } + "name": "graphgraph-fe", + "version": "0.0.1", + "private": true, + "dependencies": { + "bootstrap-css-only": "3.3.7", + "d3": "5.7.0", + "eslint-config-react-app": "3.0.6", + "react": "^16.8.6", + "react-table": "6.10.0", + "react-bootstrap": "0.32.4", + "react-dom": "^16.8.6", + "react-numeric-input": "2.2.3", + "react-scripts": "2.1.1", + "reactjs-popup": "1.3.2", + "underscore": "1.9.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ], + "devDependencieseslint": { + "eslint": "^5.6.0", + "eslint-config-standard": "^12.0.0", + "eslint-plugin-import": "^2.17.2", + "eslint-plugin-node": "^8.0.1", + "eslint-plugin-promise": "^4.1.1", + "eslint-plugin-standard": "^4.0.0" + } } diff --git a/graphgraph-fe/src/App.css b/graphgraph-fe/src/App.css deleted file mode 100644 index 74c1327..0000000 --- a/graphgraph-fe/src/App.css +++ /dev/null @@ -1,53 +0,0 @@ -.App { -width: 100%; -height: 100%; -position: absolute; -top: 0; -left: 0; -} - -.App-logo { - animation: App-logo-spin infinite 20s linear; - height: 40vmin; -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.root { -width: 100%; -height: 100%; -position: absolute; -top: 0; -left: 0; -} - -.graph-area{ - border-style: solid; - border-color: darkgray; - border-width: 1px 0 1px 0; - position: relative; - width: 100%; - height: 70%; -} diff --git a/graphgraph-fe/src/App.js b/graphgraph-fe/src/App.js deleted file mode 100644 index 788aca1..0000000 --- a/graphgraph-fe/src/App.js +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' -import './App.css' -import Graph from './Graph' -import GraphSettingsMenu from './GraphSettingsMenu' -import GraphInfoMenu from './GraphInfoMenu' -import _ from 'underscore' -import { nodeProperty, edgeProperty } from './requests' -import * as constants from './constants' - -var emptyState = { - selectedSchema: '', // currently selected schema - graph: { - nodeNames: [], // names of nodes - edges: [], // edges (each edge has source, target, type and a list of key value properties for tooltip) - paths: [] // all paths between start node and end node - }, - displayedProperties: [], // properties of currently thing (edge or node) - nodeStates: {}, // possible states CLICKED - the currently selected node PATH - currently displayed path - pathIndex: 0, // array index to paths i.e. which path from paths is currently displayed - selectedEdge: { source: 'none', target: 'none' } // defines currently selected edge like like a js object - {source: "pserver", target: "vserver"} -} - -class App extends React.Component { - constructor (props, context) { - super(props, context) - this.graphdata = this.graphdata.bind(this) - this.changePaths = this.changePaths.bind(this) - this.loadNodeProperties = this.loadNodeProperties.bind(this) - this.loadEdgeProperties = this.loadEdgeProperties.bind(this) - this.computeNodeStatesFromPath = this.computeNodeStatesFromPath.bind(this) - this.computeNodeStates = this.computeNodeStates.bind(this) - - this.state = emptyState - } - - loadNodeProperties (nodeName) { - var s = this.state - fetch(nodeProperty(s.selectedSchema, nodeName)) - .then(response => response.json()) - .then(p => { - s['displayedProperties'] = p - s['nodeStates'] = this.computeNodeStates(s['pathIndex']) - // select node - s['nodeStates'][nodeName] = constants.CLICKED - // unselect edge - s['selectedEdge']['source'] = '' - s['selectedEdge']['target'] = '' - this.setState(s) - }) - } - - loadEdgeProperties (source, target) { - var s = this.state - fetch(edgeProperty(s.selectedSchema, source, target)) - .then(response => response.json()) - .then(p => { - s['displayedProperties'] = p - // select edge - s['selectedEdge']['source'] = source - s['selectedEdge']['target'] = target - // unselect node - s['nodeStates'] = this.computeNodeStates(s['pathIndex']) - this.setState(s) - }) - } - - graphdata (data, selectedGraphSchema, graphFingerprint) { - var s = this.state - s['selectedSchema'] = selectedGraphSchema - s['graphFingerprint'] = graphFingerprint - s['graph'] = data - // TODO this should be handled more gracefully ... - if (_.isEmpty(data.edges)) { - alert('The graph has no edges, nothing to display') - } - s['displayedProperties'] = data.startNodeProperties - if (_.isArray(data.paths) && !_.isEmpty(data.paths)) { - s['paths'] = data.paths - s['nodeStates'] = this.computeNodeStatesFromPath(data.paths[0]) - s['pathIndex'] = 0 - } else { - s['paths'] = [] - s['nodeStates'] = {} - } - this.setState(s) - return data - } - - computeNodeStatesFromPath (path) { - return _.reduce(path, (acc, node) => { - acc[node.id] = constants.PATH - return acc - }, {}) - } - - computeNodeStates (pathIndex) { - return this.computeNodeStatesFromPath(this.state.paths[pathIndex]) - } - - changePaths (pathIndex, selectedNode) { - var s = this.state - s['pathIndex'] = pathIndex - this.setState(s) - this.loadNodeProperties(selectedNode) - } - - render () { - let n = _.invert(this.state.nodeStates)[constants.CLICKED] - - let selectedNode = _.isUndefined(n) ? '' : n - return ( - <div className="App"> - <GraphSettingsMenu graphData={this.graphdata} nodePropsLoader={this.loadNodeProperties} selectedNode={selectedNode}/> - <div className='graph-area'> - <Graph graphFingerprint={this.state.graphFingerprint} edgePropsLoader={this.loadEdgeProperties} selectedEdge={this.state.selectedEdge} nodes={this.state.graph.nodeNames} edges={this.state.graph.edges} nodeStates={this.state.nodeStates} nodePropsLoader={this.loadNodeProperties}/> - </div> - - <GraphInfoMenu pathCallback={this.changePaths} paths={ this.state.graph.paths } nodeProperties={ this.state.displayedProperties} /> - </div> - ) - } -} - -export default App diff --git a/graphgraph-fe/src/App.test.js b/graphgraph-fe/src/App.test.js deleted file mode 100644 index 4bf1935..0000000 --- a/graphgraph-fe/src/App.test.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' - -it('renders without crashing', () => { - const div = document.createElement('div') - ReactDOM.render(<App />, div) - ReactDOM.unmountComponentAtNode(div) -}) diff --git a/graphgraph-fe/src/DownloadExport.js b/graphgraph-fe/src/DownloadExport.js deleted file mode 100644 index b031773..0000000 --- a/graphgraph-fe/src/DownloadExport.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import { Button } from 'react-bootstrap' -import { exportSchema } from './requests' - - -class DownloadExport extends React.Component { - constructor (props, context) { - super(props, context) - this.download = this.download.bind(this) - } - - download() { - - setTimeout(() => { - const response = { - file: exportSchema(this.props.schemaVersion), - }; - window.open(response.file); - }, 100); - } - - render() { - return ( - <Button onClick={this.download}>Download as XMI</Button> - ); - } -} - -export default DownloadExport diff --git a/graphgraph-fe/src/Graph.js b/graphgraph-fe/src/Graph.js deleted file mode 100644 index c4b1aa0..0000000 --- a/graphgraph-fe/src/Graph.js +++ /dev/null @@ -1,342 +0,0 @@ -import React from 'react' -import * as d3 from 'd3' -import './Graph.css' -import _ from 'underscore' -import * as constants from './constants' - -const CLICKED_COLOR = 'blue' -const PATH_COLOR = 'red' -const EDGE_MOUSE_OVER_COLOR = 'yellow' -const EDGE_NORMAL_COLOR = '#ccc' -const EDGE_LABEL_COLOR = 'black' -const NODE_NORMAL_COLOR = '#E3E3E3' -const NODE_LABEL_COLOR = 'black' -const NODE_MOUSE_OVER_COLOR = '#F6F6F6' -const NODE_BORDER_COLOR = 'lightgray' - -// variable holds state in order to determine if graph should be redrawn completely -// it breaks the react concept, so a better approach is needed -var graphFingerprint = '' - -var htmlfyProperties = function (properties) { - return "<div class='d3-tip'>" + (_.reduce(properties, (html, e) => { return html + "<span style='color: lightgray'>" + e.propertyName + ':</span> <span>' + e.propertyValue + '</span><br/>' }, '')) + '</div>' -} - -var mouseOverEdge = function (edge, div) { - div.transition() - .duration(20) - .style('opacity', 0.9) - div.html(htmlfyProperties(edge.tooltipProperties)) - .style('left', `${d3.event.pageX}px`) - .style('top', `${d3.event.pageY - 28}px`) -} - -var mouseOutEdge = function (edge, div) { - div.transition() - .duration(6000) - .style('opacity', 0) -} - -var addEdgePaths = function (links, g) { - d3.select('body').append('div') - .attr('class', 'tooltip') - .style('opacity', 0) - g.selectAll('.edgepath') - .data(links) - .enter() - .append('path') - .attr('d', d => `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`) - .attr('class', 'edgepath') - .attr('fill-opacity', 0) - .attr('stroke-opacity', 0) - .attr('fill', EDGE_LABEL_COLOR) - .attr('id', (d, i) => `edgepath${i}`) -} - -var chooseColor = function (state) { - if (state === constants.CLICKED) { - return CLICKED_COLOR - } - - if (state === constants.PATH) { - return PATH_COLOR - } - - return NODE_NORMAL_COLOR -} - -var redrawNodeColors = function (nodeStates, selectedEdge) { - d3.selectAll('svg').selectAll('g').selectAll('circle').style('fill', n => chooseColor(nodeStates[n.id])) - d3.selectAll('svg').selectAll('g').selectAll('line').style('stroke', EDGE_NORMAL_COLOR).attr('oldStroke', EDGE_NORMAL_COLOR) - d3.selectAll('svg').selectAll('g').selectAll('line').filter(edge => edge.source.id === selectedEdge.source && edge.target.id === selectedEdge.target).attr('oldStroke', CLICKED_COLOR).style('stroke', CLICKED_COLOR) -} - -var addEdgeLabels = function (links, g, div) { - var edgelabels = g.selectAll('.edgelabel') - .data(links) - .enter() - .append('text') - .attr('class', 'edgelabel') - .attr('text-anchor', 'middle') - .attr('dx', 200) - .attr('dy', 0) - .attr('font-size', '22px') - .attr('id', (d, i) => `edgelabel${i}`) - - edgelabels.append('textPath') - .attr('xlink:href', (d, i) => `#edgepath${i}`) - .text((d, i) => d.type) -} - -var addNodeLabels = function (nodes, g) { - g.selectAll('.nodelabel') - .data(nodes) - .enter() - .append('text') - .attr('x', d => d.x - 14) - .attr('y', d => d.y - 17) - .attr('class', 'nodelabel') - .attr('fill', NODE_LABEL_COLOR) - .attr('font-size', '32px') - .text(d => d.id) - .on('mouseenter', onNodeLabelMouseOver) - .on('mouseout', onNodeLabelMouseOut) -} - -var addLinks = function (links, g, div, edgePropsLoader) { - let ss = _.filter(links, l => l.source.id === l.target.id) - - let selfLinks = _.isUndefined(ss) ? [] : ss - - g.selectAll('ellipse') - .data(selfLinks) - .enter().append('ellipse') - .attr('fill-opacity', 0) - .attr('rx', d => 100) - .attr('ry', d => 16) - .attr('cx', d => d.target.x + 80) - .attr('cy', d => d.target.y) - .style('stroke', NODE_BORDER_COLOR) - .attr('stroke-width', 5) - .on('click', edge => edgePropsLoader(edge.source.id, edge.target.id)) - .on('mouseenter', function (edge) { - mouseOverEdge(edge, div) - d3.select(this) - .transition() - .attr('oldStroke', EDGE_NORMAL_COLOR) - .duration(10) - .style('stroke', EDGE_MOUSE_OVER_COLOR) - }) - .on('mouseleave', function (edge) { - mouseOutEdge(edge, div) - var strokeColor = d3.select(this).attr('oldStroke') - d3.select(this) - .transition() - .duration(300) - .style('stroke', strokeColor) - }) - - g.selectAll('.edgelooplabel') - .data(selfLinks) - .enter() - .append('text') - .attr('x', d => d.source.x + 35) - .attr('y', d => d.source.y + 50) - .attr('class', 'edgelooplabel') - .attr('fill', NODE_LABEL_COLOR) - .attr('font-size', '22px') - .text(d => d.type) - - g.selectAll('line') - .data(links) - .enter().append('line') - .attr('stroke-width', 5) - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) - .attr('id', (d, i) => `edge${i}`) - .attr('marker-end', 'url(#arrowhead)') - .style('stroke', EDGE_NORMAL_COLOR) - .on('click', edge => edgePropsLoader(edge.source.id, edge.target.id)) - .on('mouseenter', function (edge) { - mouseOverEdge(edge, div) - d3.select(this) - .transition() - .attr('oldStroke', EDGE_NORMAL_COLOR) - .duration(10) - .style('stroke', EDGE_MOUSE_OVER_COLOR) - }) - .on('mouseleave', function (edge) { - mouseOutEdge(edge, div) - var strokeColor = d3.select(this).attr('oldStroke') - d3.select(this) - .transition() - .duration(300) - .style('stroke', strokeColor) - }) -} - -var addMarkers = function (g, svg) { - g.append('defs').append('marker') - .attr('id', 'arrowhead') - .attr('viewBox', '-0 -5 10 10') - .attr('refX', '20') - .attr('refY', '0') - .attr('orient', 'auto') - .attr('markerWidth', '4') - .attr('markerHeight', '4') - .attr('xoverflow', 'visible') - .append('svg:path') - .attr('d', 'M 0,-5 L 10 ,0 L 0,5') - .attr('fill', EDGE_NORMAL_COLOR) - .attr('stroke', EDGE_NORMAL_COLOR) - - var zoomHandler = d3.zoom() - .on('zoom', _ => g.attr('transform', d3.event.transform)) - - zoomHandler(svg) - zoomHandler.translateTo(svg, -7000, -4000) - zoomHandler.scaleTo(svg, 0.08) -} - -var drawGraph = function (nodes, links, g, simulation, svg, addNodes, edgePropsLoader) { - for (var i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; ++i) { - simulation.tick() - } - - var div = d3.select('body').append('div') - .attr('class', 'tooltip') - .style('opacity', 0) - - addLinks(links, g, div, edgePropsLoader) - addNodes(nodes, g) - addNodeLabels(nodes, g) - addEdgePaths(links, g) - addEdgeLabels(links, g, div) - addMarkers(g, svg) -} - -var prepareLinks = function (nodes, links) { - var result = [] - links.forEach(e => { - var sourceNode = nodes.filter(n => n.id === e.source)[0] - var targetNode = nodes.filter(n => n.id === e.target)[0] - - result.push({ - source: sourceNode, - target: targetNode, - type: e.type, - tooltipProperties: e.tooltipProperties - }) - }) - - return result -} - -var createSimulation = function (nodes, links) { - return d3.forceSimulation(nodes) - .force('charge', d3.forceManyBody().strength(-201)) - .force('link', d3.forceLink(links).distance(1200).strength(1).iterations(400)) - .force('collision', d3.forceCollide().radius(d => 310)) - .force('x', d3.forceX()) - .force('y', d3.forceY()) - .stop() -} - -var createSvg = function () { - var svg = d3.select('#graph').append('svg').attr('height', '100%').attr('width', '100%') - var g = svg.append('g') - - return { 'g': g, 'svg': svg } -} - -var onNodeLabelMouseOut = function () { - d3.select(this) - .transition() - .duration(600) - .attr('font-size', '32px') -} - -var onNodeLabelMouseOver = function () { - d3.select(this) - .transition() - .duration(200) - .attr('font-size', '232px') -} - -var onNodeMouseOver = function () { - var oldFill = d3.select(this).style('fill') - d3.select(this) - .transition() - .duration(200) - .attr('r', 31) - .attr('oldFill', oldFill) - .style('fill', NODE_MOUSE_OVER_COLOR) -} - -var onNodeMouseOut = function (nodeStates) { - var oldFill = d3.select(this).attr('oldFill') - d3.select(this) - .transition() - .duration(300) - .attr('r', 23) - .style('fill', oldFill) -} - -class Graph extends React.Component { - onNodeClick (x) { - // on mouse out the node will change color read from 'oldFill' attribute - d3.selectAll('svg').selectAll('g').selectAll('circle').filter(c => c.id === x.id).attr('oldFill', CLICKED_COLOR) - this.props.nodePropsLoader(x.id) - } - - addNodes (nodes, g) { - g.selectAll('circle') - .data(nodes) - .enter().append('circle') - .attr('cx', d => d.x) - .attr('cy', d => d.y) - .style('fill', n => { - return chooseColor(this.props.nodeStates[n.id]) - }) - .style('stroke', NODE_BORDER_COLOR) - .attr('stroke-width', 3) - .attr('r', 23) - .on('click', this.onNodeClick) - .on('mouseover', onNodeMouseOver) - .on('mouseout', onNodeMouseOut) - } - - reCreateGraph () { - d3.select('#graph').selectAll('*').remove() - - var nodes = this.props.nodes - var links = prepareLinks(this.props.nodes, this.props.edges) - var o = createSvg() - var simulation = createSimulation(nodes, links) - - drawGraph(nodes, links, o.g, simulation, o.svg, this.addNodes, this.props.edgePropsLoader) - } - - constructor (props, context) { - super(props, context) - - this.reCreateGraph = this.reCreateGraph.bind(this) - this.addNodes = this.addNodes.bind(this) - this.onNodeClick = this.onNodeClick.bind(this) - } - - render () { - if (this.props.graphFingerprint !== graphFingerprint) { - this.reCreateGraph() - graphFingerprint = this.props.graphFingerprint - } else { - redrawNodeColors(this.props.nodeStates, this.props.selectedEdge) - } - - return (<div id="graph"/>) - } -} - -export default Graph diff --git a/graphgraph-fe/src/GraphHops.css b/graphgraph-fe/src/GraphHops.css deleted file mode 100644 index b6207eb..0000000 --- a/graphgraph-fe/src/GraphHops.css +++ /dev/null @@ -1,9 +0,0 @@ - -.hops-input{ -display: flex; -flex-direction: column; -} - -.hops-input-field{ -width: 190px; -} diff --git a/graphgraph-fe/src/GraphHops.js b/graphgraph-fe/src/GraphHops.js deleted file mode 100644 index de7a4cc..0000000 --- a/graphgraph-fe/src/GraphHops.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import { Label } from 'react-bootstrap' -import NumericInput from 'react-numeric-input' -import './GraphHops.css' - -var createNumInput = function (label, callback, current) { - return ( - <div> - <Label>{label}</Label> - <NumericInput onChange={callback} min={1} max={500} value={current} className="hops-input-field" /> - </div> - ) -} - -class GraphHops extends React.Component { - constructor (props) { - super(props) - - this.state = { - value: this.props.defaultValue - } - let p = props.parentHops - let c = props.cousinHops - let ch = props.childHops - - this.onChangeParent = (e) => this._onChangeParent(e) - this.onChangeCousin = (e) => this._onChangeCousin(e) - this.onChangeChild = (e) => this._onChangeChild(e) - this.onChange = (hopsName, num) => this._onChange(hopsName, num) - this.state = { parentHops: p, childHops: ch, cousinHops: c } - } - - _onChange (hopsName, num) { - var s = this.state - s[hopsName] = num - this.setState(s) - this.props.updateHops(this.state.parentHops, this.state.cousinHops, this.state.childHops) - } - - _onChangeParent (e) { - this.onChange('parentHops', e) - } - - _onChangeCousin (e) { - this.onChange('cousinHops', e) - } - - _onChangeChild (e) { - this.onChange('childHops', e) - } - - render () { - if (this.props.edgeFilter === 'Edgerules'){ - return ( - <div className="hops-input"> - {createNumInput('edgerule hops', this.onChangeCousin, this.state.cousinHops)} - </div> - ) - } - - return ( - <div className="hops-input"> - {createNumInput('parent hops', this.onChangeParent, this.state.parentHops)} - {createNumInput('child hops', this.onChangeChild, this.state.childHops)} - </div> - ) - } -} - -export default GraphHops diff --git a/graphgraph-fe/src/GraphInfoMenu.js b/graphgraph-fe/src/GraphInfoMenu.js deleted file mode 100644 index d001d50..0000000 --- a/graphgraph-fe/src/GraphInfoMenu.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react' -import './GraphInfoMenu.css' -import PathBreadCrumb from './PathBreadCrumb' -import _ from 'underscore' -import ReactTable from "react-table"; -import "react-table/react-table.css"; - -class GraphInfoMenu extends React.Component { - - render () { - var paths = this.props.paths - var callback = this.props.pathCallback - var showPaths = _.isArray(paths) && !_.isEmpty(paths) - var breadcrumbs = _.map(paths, (path, i) => <PathBreadCrumb key={i} index={i} pathCallback={callback} path={path}/>) - return ( - <div className="node-property-list"> - <div className="fixed-height-container" style={{ display: showPaths ? 'block' : 'none' }}> - <p className='path-heading'>Paths</p> - {breadcrumbs} - </div> - <div className="kv-table datatable"> - <ReactTable pageSizeOptions={[4, 25]} data={this.props.nodeProperties} - columns={[ - { - Header: "Attribute", - accessor: "propertyName", - minWidth: 40 - }, - { - Header: "Type", - accessor: "type", - minWidth: 70 - }, - { - Header: "Description", - accessor: "description", - minWidth: 260 - }, - { - id: "Key", - Header: "Key", - accessor: p => p.key ? "yes" : "", - minWidth: 20 - }, - { - id: "Index", - Header: "Index", - accessor: p => p.index ? "yes" : "", - minWidth: 20 - }, - { - id: "Required", - Header: "Required", - accessor: p => p.required ? "yes" : "", - minWidth: 20 - } - ]} - defaultPageSize={4} - className="-striped -highlight" - /> - </div> - </div> - ) - } -} - -export default GraphInfoMenu diff --git a/graphgraph-fe/src/GraphSettings.js b/graphgraph-fe/src/GraphSettings.js deleted file mode 100644 index d511068..0000000 --- a/graphgraph-fe/src/GraphSettings.js +++ /dev/null @@ -1,239 +0,0 @@ -import React from 'react' -import _ from 'underscore' -import { DropdownButton, MenuItem, Label } from 'react-bootstrap' -import './GraphSettings.css' -import Popup from './PopupSettings' -import ValidationModal from './ValidationModal' -import DownloadExport from './DownloadExport' -import { validateSchema, pathGraph, basicGraph, schemas, nodeNames } from './requests' - -var emptyState = { - schemaProblems: [], - nodeNames: [], - fromNode: '', - graph: { - nodeNames: [], - edges: [] - }, - showHops: false, - enableDestinationNode: false, - toNode: '', - edgeFilter: 'Edgerules', - hops: { - parents: 1, - cousin: 1, - child: 1 - }, - selectedSchema: '' -} - -class GraphSettings extends React.Component { - constructor (props, context) { - super(props, context) - this.onChangeStartNode = this.onChangeStartNode.bind(this) - this.onSelectNode = this.onSelectNode.bind(this) - this.selectSchema = this.selectSchema.bind(this) - this.onChangeToNode = this.onChangeToNode.bind(this) - this.loadInitialGraph = this.loadInitialGraph.bind(this) - this.updateHops = this.updateHops.bind(this) - this.changeEdgeFilter = this.changeEdgeFilter.bind(this) - this.graphFingerprint = this.graphFingerprint.bind(this) - this.state = emptyState - } - // this serves as a config 'fingerprint' to know if the d3 visualisation should be redrawn from scratch or just updated - graphFingerprint (schema, from, to, parents, cousin, child, edgeFilter) { - return `${schema}:${from}:${to}:${parents}:${cousin}:${child}:${edgeFilter}` - } - - loadInitialGraph (startNode, endNode, parentHops, cousinHops, childHops, edgeFilter) { - if (this.state.selectedSchema === '' || startNode === 'none') { - var s = this.state - s['edgeFilter'] = edgeFilter - this.setState(s) - return - } - if (startNode === 'all') { - endNode = 'none' - } - - let requestUri = endNode === 'none' - ? basicGraph(this.state.selectedSchema, startNode, parentHops, cousinHops, childHops, edgeFilter) : pathGraph(this.state.selectedSchema, startNode, endNode, edgeFilter) - - fetch(requestUri) - .then(response => response.json()) - .then(g => { - let schema = this.state.selectedSchema - - let f = this.graphFingerprint(schema, startNode, endNode, parentHops, cousinHops, childHops, edgeFilter) - this.props.graphData(g, this.state.selectedSchema, f) - return g - }) - .then(g => { - var s = this.state - s['hops']['parents'] = parentHops - s['hops']['cousin'] = cousinHops - s['hops']['child'] = childHops - s['fromNode'] = startNode - s['toNode'] = endNode - s['graph'] = g - s['edgeFilter'] = edgeFilter - s['showHops'] = endNode === 'none' && startNode !== 'none' && startNode !== 'all' - s['enableDestinationNode'] = startNode !== 'none' && startNode !== 'all' - this.setState(s) - - if (startNode !== 'all') { - this.onSelectNode(startNode) - } - }) - } - - selectSchema (schema) { - var s = this.state - s['selectedSchema'] = schema - fetch(nodeNames(schema, s['edgeFilter'])) - .then(response => response.json()) - .then(nodeNames => { - s['fromNode'] = s['toNode'] = 'none' - s['nodeNames'] = nodeNames - this.setState(s) - }) - fetch(validateSchema(schema)) - .then(response => response.json()) - .then(p => { - s['schemaProblems'] = p.problems - this.setState(s) - }) - } - - changeEdgeFilter (edgeFilter) { - fetch(nodeNames(this.state.selectedSchema, edgeFilter)) - .then(response => response.json()) - .then(nodeNames => { - let s = this.state - s['edgeFilter'] = edgeFilter - s['fromNode'] = s['toNode'] = 'none' - s['nodeNames'] = nodeNames - this.setState(s) - }) - this.loadInitialGraph( - this.state.fromNode, - this.state.toNode, - this.state.hops.parents, - this.state.hops.cousin, - this.state.hops.child, - edgeFilter - ) - } - - updateHops (parentHops, cousinHops, childHops) { - this.loadInitialGraph( - this.state.fromNode, - this.state.toNode, - parentHops, - cousinHops, - childHops, - this.state.edgeFilter) - } - - onChangeToNode (eventKey) { - this.loadInitialGraph(this.state.fromNode, - eventKey, - this.state.hops.parents, - this.state.hops.cousin, - this.state.hops.child, - this.state.edgeFilter) - } - - onSelectNode (eventKey) { - this.props.nodePropsLoader(eventKey) - } - - onChangeStartNode (eventKey) { - this.loadInitialGraph(eventKey, this.state.toNode, - this.state.hops.parents, - this.state.hops.cousin, - this.state.hops.child, - this.state.edgeFilter) - } - - componentDidMount () { - fetch(schemas()) - .then(response => response.json()) - .then(schemas => { - let s = this.state - s['schemas'] = schemas - this.setState(s) - }) - } - - render () { - var schemas = _.map(this.state.schemas, (x, k) => <MenuItem key={k} eventKey={x}>{x}</MenuItem>) - - var items = _.map(this.state.nodeNames, (x, k) => <MenuItem key={k} eventKey={x.id}>{x.id}</MenuItem>) - let sortedNames = _.sortBy(this.state.graph.nodeNames, 'id') - var currentNodeNames = _.map(sortedNames, (x, k) => <MenuItem key={k} eventKey={x.id}>{x.id}</MenuItem>) - - var fromItems = items.slice() - fromItems.unshift(<MenuItem key='divider' divider />) - fromItems.unshift(<MenuItem key='all' eventKey='all'>all</MenuItem>) - - items.unshift(<MenuItem key='anotherdivider' divider />) - items.unshift(<MenuItem key='none' eventKey='none'>none</MenuItem>) - - let edgeFilterItems = [ - <MenuItem key='Edgerules' eventKey='Edgerules'>Edgerules</MenuItem>, - <MenuItem key='Parents' eventKey='Parents'>Parent-child (OXM structure)</MenuItem>, - ] - return ( - <div> - <div className="graph-menu"> - <div className="startendnode-dropdown"> - <div> - <Label>Schemas</Label> - <DropdownButton className="schemas-dropdown" onSelect={this.selectSchema} id="schemas" title={this.state.selectedSchema}> - {schemas} - </DropdownButton> - </div> - <div className="source-dropdown-div"> - <Label>Source Node</Label> - <DropdownButton className="node-dropdown" onSelect={this.onChangeStartNode} id="namesFrom" title={this.state.fromNode}> - {fromItems} - </DropdownButton> - </div> - <div> - <Label>Destination Node</Label> - <DropdownButton disabled={!this.state.enableDestinationNode} className="node-dropdown" onSelect={this.onChangeToNode} id="namesTo" title={this.state.toNode}> - {items} - </DropdownButton> - </div> - <div className="source-dropdown-div"> - <Label>Edge filter</Label> - <DropdownButton className="node-dropdown" onSelect={this.changeEdgeFilter} id="filterEdge" title={this.state.edgeFilter}> - {edgeFilterItems} - </DropdownButton> - </div> - <div className="source-dropdown-div"> - <Label>Selected Node</Label> - <DropdownButton className="node-dropdown" onSelect={this.onSelectNode} id="selectedNode" title={this.props.selectedNode}> - {currentNodeNames} - </DropdownButton> - </div> - - <Popup isDisabled={!this.state.showHops} edgeFilter={this.state.edgeFilter} parentHops={this.state.hops.parents} childHops={this.state.hops.child} cousinHops={this.state.hops.cousin} updateHops={this.updateHops}/> - <div className="modal-button"> - <ValidationModal schemaProblems={this.state.schemaProblems}/> - </div> - - <div className="modal-button"> - <DownloadExport schemaVersion={this.state.selectedSchema}/> - </div> - - </div> - - </div> - </div> - ) - } -} - -export default GraphSettings diff --git a/graphgraph-fe/src/GraphSettingsMenu.js b/graphgraph-fe/src/GraphSettingsMenu.js deleted file mode 100644 index 5ce1e12..0000000 --- a/graphgraph-fe/src/GraphSettingsMenu.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import GraphSettings from './GraphSettings' -import { Navbar, Nav } from 'react-bootstrap' -import './GraphSettingsMenu.css' - -class GraphSettingsMenu extends React.Component { - render () { - return ( - <Navbar className='navbar-adjust'> - <Navbar.Header> - <Navbar.Brand> - <a href="https://gerrit.onap.org/r/gitweb?p=aai/graphgraph.git">GraphGraph</a> - </Navbar.Brand> - </Navbar.Header> - <Nav className="mr-auto"> - <Navbar.Collapse className='mr-sm-2'> - <GraphSettings selectedNode={this.props.selectedNode} graphData={this.props.graphData} nodePropsLoader={this.props.nodePropsLoader} /> - </Navbar.Collapse> -</Nav> - </Navbar>) - } -} - -export default GraphSettingsMenu diff --git a/graphgraph-fe/src/PathBreadCrumb.js b/graphgraph-fe/src/PathBreadCrumb.js deleted file mode 100644 index a2c508a..0000000 --- a/graphgraph-fe/src/PathBreadCrumb.js +++ /dev/null @@ -1,26 +0,0 @@ -import _ from 'underscore' -import React from 'react' -import { Breadcrumb } from 'react-bootstrap' - -class PathBreadCrumb extends React.Component { - constructor (props, context) { - super(props, context) - this.pathSelected = this.pathSelected.bind(this) - } - - pathSelected (evt) { - evt.preventDefault() - // the data is only piggyback riding on the "target" property .. not nice but works - this.props.pathCallback(this.props.index, evt.target.getAttribute('target')) - } - - render () { - var path = this.props.path - var callback = this.pathSelected - var items = _.map(path, (item, i) => <Breadcrumb.Item key={i} target={item.id} onClick={callback}> {item.id} </Breadcrumb.Item>) - - return (<Breadcrumb>{items}</Breadcrumb>) - } -} - -export default PathBreadCrumb diff --git a/graphgraph-fe/src/PopupSettings.js b/graphgraph-fe/src/PopupSettings.js deleted file mode 100644 index cb8a533..0000000 --- a/graphgraph-fe/src/PopupSettings.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import Popup from 'reactjs-popup' -import './PopupSettings.css' -import GraphHops from './GraphHops' - -class PopupMenu extends React.Component { - render () { - return ( - <Popup trigger={<button className='settings-button' disabled={this.props.isDisabled}>Hops</button>} position="bottom right"> - {close => ( - <div> - <GraphHops edgeFilter={this.props.edgeFilter} parentHops={this.props.parentHops} childHops={this.props.childHops} cousinHops={this.props.cousinHops} updateHops={this.props.updateHops} /> - <button - type="button" - className="link-button, close" - onClick={close}> - × - </button> - </div> - )} - </Popup> - - ) - } -} - -export default PopupMenu diff --git a/graphgraph-fe/src/ValidationModal.js b/graphgraph-fe/src/ValidationModal.js deleted file mode 100644 index 8bf1989..0000000 --- a/graphgraph-fe/src/ValidationModal.js +++ /dev/null @@ -1,49 +0,0 @@ -import _ from 'underscore' -import React from 'react' -import './ValidationModal.css' -import { Button, Modal, ListGroup, ListGroupItem } from 'react-bootstrap' - - -class ValidationModal extends React.Component { - constructor(...args) { - super(...args); - this.state = { showModal: false }; - - this.close = () => { - this.setState({ showModal: false }); - }; - - this.open = () => { - this.setState({ showModal: true }); - }; - } - - renderBackdrop(props) { - return <div {...props} className="modal-backdrop" />; - } - - render() { - var problems = this.props.schemaProblems - var items = _.map(problems, (problem, i) => <ListGroupItem key={i}> {problem} </ListGroupItem>) - return ( - <div> - <Button onClick={this.open}>Validate schema</Button> - <Modal - onHide={this.close} - className="modal-validator" - aria-labelledby="modal-label" - show={this.state.showModal} - renderBackdrop={this.renderBackdrop} - > - <div className="modal-list"> - <ListGroup> - {items} - </ListGroup> - </div> - </Modal> - </div> - ); - } -} - -export default ValidationModal diff --git a/graphgraph-fe/src/app.css b/graphgraph-fe/src/app.css new file mode 100644 index 0000000..8c39ae0 --- /dev/null +++ b/graphgraph-fe/src/app.css @@ -0,0 +1,53 @@ +.App { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.App-logo { + animation: App-logo-spin infinite 20s linear; + height: 40vmin; +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.root { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; +} + +.graph-area{ + border-style: solid; + border-color: darkgray; + border-width: 1px 0 1px 0; + position: relative; + width: 100%; + height: 70%; +} diff --git a/graphgraph-fe/src/app.js b/graphgraph-fe/src/app.js new file mode 100644 index 0000000..30eb3d8 --- /dev/null +++ b/graphgraph-fe/src/app.js @@ -0,0 +1,152 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import './app.css'; +import Graph from './graph.js'; +import GraphSettingsMenu from './graph_settings_menu.js'; +import GraphInfoMenu from './graph_info_menu.js'; +import _ from 'underscore'; +import { nodeProperty, edgeProperty } from './requests.js'; +import * as constants from './constants.js'; + +var emptyState = { + // currently selected schema + selectedSchema: '', + graph: { + // names of nodes + nodeNames: [], + // edges (each edge has source, target, type and a list of key value properties for tooltip) + edges: [], + // all paths between start node and end node + paths: [] + }, + // properties of currently thing (edge or node) + displayedProperties: [], + // possible states: + // CLICKED - the currently selected node + // PATH - currently displayed path + nodeStates: {}, + // array index to paths i.e. which path from paths is currently displayed + pathIndex: 0, + // defines currently selected edge like a js object - {source: "pserver", target: "vserver"} + selectedEdge: { source: 'none', target: 'none' } +}; + +class App extends React.Component { + constructor (props, context) { + super(props, context); + this.graphdata = this.graphdata.bind(this); + this.changePaths = this.changePaths.bind(this); + this.loadNodeProperties = this.loadNodeProperties.bind(this); + this.loadEdgeProperties = this.loadEdgeProperties.bind(this); + this.computeNodeStatesFromPath = this.computeNodeStatesFromPath.bind(this); + this.computeNodeStates = this.computeNodeStates.bind(this); + this.state = emptyState; + } + + loadNodeProperties (nodeName) { + var s = this.state; + fetch(nodeProperty(s.selectedSchema, nodeName)) + .then(response => response.json()) + .then(p => { + s['displayedProperties'] = p; + s['nodeStates'] = this.computeNodeStates(s['pathIndex']); + // select node + s['nodeStates'][nodeName] = constants.CLICKED; + // unselect edge + s['selectedEdge']['source'] = ''; + s['selectedEdge']['target'] = ''; + this.setState(s); + }); + } + + loadEdgeProperties (source, target) { + var s = this.state; + fetch(edgeProperty(s.selectedSchema, source, target)) + .then(response => response.json()) + .then(p => { + s['displayedProperties'] = p; + // select edge + s['selectedEdge']['source'] = source; + s['selectedEdge']['target'] = target; + // unselect node + s['nodeStates'] = this.computeNodeStates(s['pathIndex']); + this.setState(s); + }); + } + + graphdata (data, selectedGraphSchema, graphFingerprint) { + var s = this.state; + s['selectedSchema'] = selectedGraphSchema; + s['graphFingerprint'] = graphFingerprint; + s['graph'] = data; + // TODO this should be handled more gracefully ... + if (_.isEmpty(data.edges)) { + alert('The graph has no edges, nothing to display'); + } + s['displayedProperties'] = data.startNodeProperties; + if (_.isArray(data.paths) && !_.isEmpty(data.paths)) { + s['paths'] = data.paths; + s['nodeStates'] = this.computeNodeStatesFromPath(data.paths[0]); + s['pathIndex'] = 0; + } else { + s['paths'] = []; + s['nodeStates'] = {}; + } + this.setState(s); + return data; + } + + computeNodeStatesFromPath (path) { + return _.reduce(path, (acc, node) => { + acc[node.id] = constants.PATH; + return acc; + }, {}); + } + + computeNodeStates (pathIndex) { + return this.computeNodeStatesFromPath(this.state.paths[pathIndex]); + } + + changePaths (pathIndex, selectedNode) { + var s = this.state; + s['pathIndex'] = pathIndex; + this.setState(s); + this.loadNodeProperties(selectedNode); + } + + render () { + let n = _.invert(this.state.nodeStates)[constants.CLICKED]; + + let selectedNode = _.isUndefined(n) ? '' : n; + return ( + <div className="App"> + <GraphSettingsMenu graphData={this.graphdata} nodePropsLoader={this.loadNodeProperties} selectedNode={selectedNode}/> + <div className='graph-area'> + <Graph graphFingerprint={this.state.graphFingerprint} edgePropsLoader={this.loadEdgeProperties} selectedEdge={this.state.selectedEdge} nodes={this.state.graph.nodeNames} edges={this.state.graph.edges} nodeStates={this.state.nodeStates} nodePropsLoader={this.loadNodeProperties}/> + </div> + <GraphInfoMenu pathCallback={this.changePaths} paths={ this.state.graph.paths } nodeProperties={ this.state.displayedProperties}/> + </div> + ); + } +} + +export default App; diff --git a/graphgraph-fe/src/app.test.js b/graphgraph-fe/src/app.test.js new file mode 100644 index 0000000..893bcbb --- /dev/null +++ b/graphgraph-fe/src/app.test.js @@ -0,0 +1,29 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './app.js'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(<App/>, div); + ReactDOM.unmountComponentAtNode(div); +}); diff --git a/graphgraph-fe/src/constants.js b/graphgraph-fe/src/constants.js index 6076e6f..9181850 100644 --- a/graphgraph-fe/src/constants.js +++ b/graphgraph-fe/src/constants.js @@ -1,2 +1,22 @@ -export const CLICKED = 'clicked' -export const PATH = 'on-path' +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +export const CLICKED = 'clicked'; +export const PATH = 'on-path'; diff --git a/graphgraph-fe/src/download_export.js b/graphgraph-fe/src/download_export.js new file mode 100644 index 0000000..21b2829 --- /dev/null +++ b/graphgraph-fe/src/download_export.js @@ -0,0 +1,43 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { exportSchema } from './requests.js'; + +class DownloadExport extends React.Component { + constructor (props, context) { + super(props, context); + this.download = this.download.bind(this); + } + + download() { + setTimeout(() => { + const response = { file: exportSchema(this.props.schemaVersion) }; + window.open(response.file); + }, 100); + } + + render() { + return <Button onClick={this.download}>Download as XMI</Button>; + } +} + +export default DownloadExport; diff --git a/graphgraph-fe/src/Graph.css b/graphgraph-fe/src/graph.css index 1e5c7d8..0c9460f 100644 --- a/graphgraph-fe/src/Graph.css +++ b/graphgraph-fe/src/graph.css @@ -17,4 +17,3 @@ color: #fff; border-radius: 2px; } - diff --git a/graphgraph-fe/src/graph.js b/graphgraph-fe/src/graph.js new file mode 100644 index 0000000..492959a --- /dev/null +++ b/graphgraph-fe/src/graph.js @@ -0,0 +1,372 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import * as d3 from 'd3'; +import './graph.css'; +import _ from 'underscore'; +import * as constants from './constants.js'; + +const CLICKED_COLOR = 'blue'; +const PATH_COLOR = 'red'; +const EDGE_MOUSE_OVER_COLOR = 'yellow'; +const EDGE_NORMAL_COLOR = '#ccc'; +const EDGE_LABEL_COLOR = 'black'; +const NODE_NORMAL_COLOR = '#E3E3E3'; +const NODE_LABEL_COLOR = 'black'; +const NODE_MOUSE_OVER_COLOR = '#F6F6F6'; +const NODE_BORDER_COLOR = 'lightgray'; + +// variable holds state in order to determine if graph should be redrawn completely +// it breaks the react concept, so a better approach is needed +var graphFingerprint = ''; + +var htmlfyProperties = function (properties) { + return "<div class='d3-tip'>" + (_.reduce(properties, (html, e) => { + return html + "<span style='color: lightgray'>" + + e.propertyName + ':</span> <span>' + + e.propertyValue + '</span><br/>'; + }, '')) + '</div>'; +}; + +var mouseOverEdge = function (edge, div) { + div.transition() + .duration(20) + .style('opacity', 0.9); + div.html(htmlfyProperties(edge.tooltipProperties)) + .style('left', `${d3.event.pageX}px`) + .style('top', `${d3.event.pageY - 28}px`); +}; + +var mouseOutEdge = function (edge, div) { + div.transition() + .duration(6000) + .style('opacity', 0); +}; + +var addEdgePaths = function (links, g) { + d3.select('body').append('div') + .attr('class', 'tooltip') + .style('opacity', 0); + g.selectAll('.edgepath') + .data(links) + .enter() + .append('path') + .attr('d', d => `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`) + .attr('class', 'edgepath') + .attr('fill-opacity', 0) + .attr('stroke-opacity', 0) + .attr('fill', EDGE_LABEL_COLOR) + .attr('id', (d, i) => `edgepath${i}`); +}; + +var chooseColor = function (state) { + if (state === constants.CLICKED) { + return CLICKED_COLOR; + } + if (state === constants.PATH) { + return PATH_COLOR; + } + return NODE_NORMAL_COLOR; +}; + +var redrawNodeColors = function (nodeStates, selectedEdge) { + d3.selectAll('svg').selectAll('g').selectAll('circle') + .style('fill', n => chooseColor(nodeStates[n.id])); + d3.selectAll('svg').selectAll('g').selectAll('line') + .style('stroke', EDGE_NORMAL_COLOR).attr('oldStroke', EDGE_NORMAL_COLOR); + d3.selectAll('svg').selectAll('g').selectAll('line') + .filter(edge => edge.source.id === selectedEdge.source && edge.target.id === selectedEdge.target) + .attr('oldStroke', CLICKED_COLOR) + .style('stroke', CLICKED_COLOR); +}; + +var addEdgeLabels = function (links, g, div) { + var edgelabels = g.selectAll('.edgelabel') + .data(links) + .enter() + .append('text') + .attr('class', 'edgelabel') + .attr('text-anchor', 'middle') + .attr('dx', 200) + .attr('dy', 0) + .attr('font-size', '22px') + .attr('id', (d, i) => `edgelabel${i}`); + + edgelabels.append('textPath') + .attr('xlink:href', (d, i) => `#edgepath${i}`) + .text((d, i) => d.type); +}; + +var addNodeLabels = function (nodes, g) { + g.selectAll('.nodelabel') + .data(nodes) + .enter() + .append('text') + .attr('x', d => d.x - 14) + .attr('y', d => d.y - 17) + .attr('class', 'nodelabel') + .attr('fill', NODE_LABEL_COLOR) + .attr('font-size', '32px') + .text(d => d.id) + .on('mouseenter', onNodeLabelMouseOver) + .on('mouseout', onNodeLabelMouseOut); +}; + +var addLinks = function (links, g, div, edgePropsLoader) { + let ss = _.filter(links, l => l.source.id === l.target.id); + + let selfLinks = _.isUndefined(ss) ? [] : ss; + + g.selectAll('ellipse') + .data(selfLinks) + .enter().append('ellipse') + .attr('fill-opacity', 0) + .attr('rx', d => 100) + .attr('ry', d => 16) + .attr('cx', d => d.target.x + 80) + .attr('cy', d => d.target.y) + .style('stroke', NODE_BORDER_COLOR) + .attr('stroke-width', 5) + .on('click', edge => edgePropsLoader(edge.source.id, edge.target.id)) + .on('mouseenter', function (edge) { + mouseOverEdge(edge, div); + d3.select(this) + .transition() + .attr('oldStroke', EDGE_NORMAL_COLOR) + .duration(10) + .style('stroke', EDGE_MOUSE_OVER_COLOR); + }) + .on('mouseleave', function (edge) { + mouseOutEdge(edge, div); + var strokeColor = d3.select(this).attr('oldStroke'); + d3.select(this) + .transition() + .duration(300) + .style('stroke', strokeColor); + }); + + g.selectAll('.edgelooplabel') + .data(selfLinks) + .enter() + .append('text') + .attr('x', d => d.source.x + 35) + .attr('y', d => d.source.y + 50) + .attr('class', 'edgelooplabel') + .attr('fill', NODE_LABEL_COLOR) + .attr('font-size', '22px') + .text(d => d.type); + + g.selectAll('line') + .data(links) + .enter().append('line') + .attr('stroke-width', 5) + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y) + .attr('id', (d, i) => `edge${i}`) + .attr('marker-end', 'url(#arrowhead)') + .style('stroke', EDGE_NORMAL_COLOR) + .on('click', edge => edgePropsLoader(edge.source.id, edge.target.id)) + .on('mouseenter', function (edge) { + mouseOverEdge(edge, div); + d3.select(this) + .transition() + .attr('oldStroke', EDGE_NORMAL_COLOR) + .duration(10) + .style('stroke', EDGE_MOUSE_OVER_COLOR); + }) + .on('mouseleave', function (edge) { + mouseOutEdge(edge, div); + var strokeColor = d3.select(this).attr('oldStroke'); + d3.select(this) + .transition() + .duration(300) + .style('stroke', strokeColor); + }); +}; + +var addMarkers = function (g, svg) { + g.append('defs').append('marker') + .attr('id', 'arrowhead') + .attr('viewBox', '-0 -5 10 10') + .attr('refX', '20') + .attr('refY', '0') + .attr('orient', 'auto') + .attr('markerWidth', '4') + .attr('markerHeight', '4') + .attr('xoverflow', 'visible') + .append('svg:path') + .attr('d', 'M 0,-5 L 10 ,0 L 0,5') + .attr('fill', EDGE_NORMAL_COLOR) + .attr('stroke', EDGE_NORMAL_COLOR); + + var zoomHandler = d3.zoom() + .on('zoom', _ => g.attr('transform', d3.event.transform)); + + zoomHandler(svg); + zoomHandler.translateTo(svg, -7000, -4000); + zoomHandler.scaleTo(svg, 0.08); +}; + +var drawGraph = function (nodes, links, g, simulation, svg, addNodes, edgePropsLoader) { + for (var i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); + i < n; ++i) { + simulation.tick(); + } + + var div = d3.select('body').append('div') + .attr('class', 'tooltip') + .style('opacity', 0); + + addLinks(links, g, div, edgePropsLoader); + addNodes(nodes, g); + addNodeLabels(nodes, g); + addEdgePaths(links, g); + addEdgeLabels(links, g, div); + addMarkers(g, svg); +}; + +var prepareLinks = function (nodes, links) { + var result = []; + links.forEach(e => { + var sourceNode = nodes.filter(n => n.id === e.source)[0]; + var targetNode = nodes.filter(n => n.id === e.target)[0]; + + result.push({ + source: sourceNode, + target: targetNode, + type: e.type, + tooltipProperties: e.tooltipProperties + }); + }); + + return result; +}; + +var createSimulation = function (nodes, links) { + return d3.forceSimulation(nodes) + .force('charge', d3.forceManyBody().strength(-201)) + .force('link', d3.forceLink(links).distance(1200).strength(1).iterations(400)) + .force('collision', d3.forceCollide().radius(d => 310)) + .force('x', d3.forceX()) + .force('y', d3.forceY()) + .stop(); +}; + +var createSvg = function () { + var svg = d3.select('#graph').append('svg').attr('height', '100%').attr('width', '100%'); + var g = svg.append('g'); + + return { 'g': g, 'svg': svg }; +}; + +var onNodeLabelMouseOut = function () { + d3.select(this) + .transition() + .duration(600) + .attr('font-size', '32px'); +}; + +var onNodeLabelMouseOver = function () { + d3.select(this) + .transition() + .duration(200) + .attr('font-size', '232px'); +}; + +var onNodeMouseOver = function () { + var oldFill = d3.select(this).style('fill'); + d3.select(this) + .transition() + .duration(200) + .attr('r', 31) + .attr('oldFill', oldFill) + .style('fill', NODE_MOUSE_OVER_COLOR); +}; + +var onNodeMouseOut = function (nodeStates) { + var oldFill = d3.select(this).attr('oldFill'); + d3.select(this) + .transition() + .duration(300) + .attr('r', 23) + .style('fill', oldFill); +}; + +class Graph extends React.Component { + onNodeClick (x) { + // on mouse out the node will change color read from 'oldFill' attribute + d3.selectAll('svg').selectAll('g').selectAll('circle') + .filter(c => c.id === x.id) + .attr('oldFill', CLICKED_COLOR); + this.props.nodePropsLoader(x.id); + } + + addNodes (nodes, g) { + g.selectAll('circle') + .data(nodes) + .enter().append('circle') + .attr('cx', d => d.x) + .attr('cy', d => d.y) + .style('fill', n => { + return chooseColor(this.props.nodeStates[n.id]); + }) + .style('stroke', NODE_BORDER_COLOR) + .attr('stroke-width', 3) + .attr('r', 23) + .on('click', this.onNodeClick) + .on('mouseover', onNodeMouseOver) + .on('mouseout', onNodeMouseOut); + } + + reCreateGraph () { + d3.select('#graph').selectAll('*').remove(); + + var nodes = this.props.nodes; + var links = prepareLinks(this.props.nodes, this.props.edges); + var o = createSvg(); + var simulation = createSimulation(nodes, links); + + drawGraph(nodes, links, o.g, simulation, o.svg, this.addNodes, this.props.edgePropsLoader); + } + + constructor (props, context) { + super(props, context); + + this.reCreateGraph = this.reCreateGraph.bind(this); + this.addNodes = this.addNodes.bind(this); + this.onNodeClick = this.onNodeClick.bind(this); + } + + render () { + if (this.props.graphFingerprint !== graphFingerprint) { + this.reCreateGraph(); + graphFingerprint = this.props.graphFingerprint; + } else { + redrawNodeColors(this.props.nodeStates, this.props.selectedEdge); + } + + return <div id="graph"/>; + } +} + +export default Graph; diff --git a/graphgraph-fe/src/graph_hops.css b/graphgraph-fe/src/graph_hops.css new file mode 100644 index 0000000..474b661 --- /dev/null +++ b/graphgraph-fe/src/graph_hops.css @@ -0,0 +1,8 @@ +.hops-input{ + display: flex; + flex-direction: column; +} + +.hops-input-field{ + width: 190px; +} diff --git a/graphgraph-fe/src/graph_hops.js b/graphgraph-fe/src/graph_hops.js new file mode 100644 index 0000000..da98db2 --- /dev/null +++ b/graphgraph-fe/src/graph_hops.js @@ -0,0 +1,79 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import { Label } from 'react-bootstrap'; +import NumericInput from 'react-numeric-input'; +import './graph_hops.css'; + +var createNumInput = function (label, callback, current) { + return ( + <div> + <Label>{label}</Label> + <NumericInput onChange={callback} min={1} max={500} value={current} className="hops-input-field"/> + </div> + ); +}; + +class GraphHops extends React.Component { + constructor (props) { + super(props); + + this.state = { value: this.props.defaultValue }; + let p = props.parentHops; + let c = props.cousinHops; + let ch = props.childHops; + + this.onChangeParent = (e) => this._onChangeParent(e); + this.onChangeCousin = (e) => this._onChangeCousin(e); + this.onChangeChild = (e) => this._onChangeChild(e); + this.onChange = (hopsName, num) => this._onChange(hopsName, num); + this.state = { parentHops: p, childHops: ch, cousinHops: c }; + } + + _onChange (hopsName, num) { + var s = this.state; + s[hopsName] = num; + this.setState(s); + this.props.updateHops(this.state.parentHops, this.state.cousinHops, this.state.childHops); + } + + _onChangeParent (e) { + this.onChange('parentHops', e); + } + + _onChangeCousin (e) { + this.onChange('cousinHops', e); + } + + _onChangeChild (e) { + this.onChange('childHops', e); + } + + render () { + if (this.props.edgeFilter === 'Edgerules') { + return <div className="hops-input">{createNumInput('edgerule hops', this.onChangeCousin, this.state.cousinHops)}</div>; + } + + return <div className="hops-input">{createNumInput('parent hops', this.onChangeParent, this.state.parentHops)} {createNumInput('child hops', this.onChangeChild, this.state.childHops)}</div>; + } +} + +export default GraphHops; diff --git a/graphgraph-fe/src/GraphInfoMenu.css b/graphgraph-fe/src/graph_info_menu.css index 24a2719..6182210 100644 --- a/graphgraph-fe/src/GraphInfoMenu.css +++ b/graphgraph-fe/src/graph_info_menu.css @@ -6,15 +6,15 @@ } .pagination { - margin: 0 12px 0 20px !important; + margin: 0 12px 0 20px !important; } .fixed-height-container { overflow: scroll; float:top; height: 200px; - width:40%; - padding:3px; + width:40%; + padding:3px; background:white; } @@ -70,4 +70,3 @@ margin-right: 0; margin-left: 0; } - diff --git a/graphgraph-fe/src/graph_info_menu.js b/graphgraph-fe/src/graph_info_menu.js new file mode 100644 index 0000000..66ffbba --- /dev/null +++ b/graphgraph-fe/src/graph_info_menu.js @@ -0,0 +1,81 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import './graph_info_menu.css'; +import PathBreadcrumb from './path_breadcrumb.js'; +import _ from 'underscore'; +import ReactTable from "react-table"; +import "react-table/react-table.css"; + +class GraphInfoMenu extends React.Component { + render () { + var paths = this.props.paths; + var callback = this.props.pathCallback; + var showPaths = _.isArray(paths) && !_.isEmpty(paths); + var breadcrumbs = _.map( + paths, (path, i) => <PathBreadcrumb key={i} index={i} pathCallback={callback} path={path}/>); + return ( + <div className="node-property-list"> + <div className="fixed-height-container" style={{ display: showPaths ? 'block' : 'none' }}><p className='path-heading'>Paths</p>{breadcrumbs}</div> + <div className="kv-table datatable"> + <ReactTable pageSizeOptions={[4, 25]} data={this.props.nodeProperties} columns={[ + { + Header: "Attribute", + accessor: "propertyName", + minWidth: 40 + }, + { + Header: "Type", + accessor: "type", + minWidth: 70 + }, + { + Header: "Description", + accessor: "description", + minWidth: 260 + }, + { + id: "Key", + Header: "Key", + accessor: p => p.key ? "yes" : "", + minWidth: 20 + }, + { + id: "Index", + Header: "Index", + accessor: p => p.index ? "yes" : "", + minWidth: 20 + }, + { + id: "Required", + Header: "Required", + accessor: p => p.required ? "yes" : "", + minWidth: 20 + } + ]} defaultPageSize={4} className="-striped -highlight" + /> + </div> + </div> + ); + } +} + +export default GraphInfoMenu; diff --git a/graphgraph-fe/src/GraphSettings.css b/graphgraph-fe/src/graph_settings.css index 6fb4550..c30cda9 100644 --- a/graphgraph-fe/src/GraphSettings.css +++ b/graphgraph-fe/src/graph_settings.css @@ -29,7 +29,7 @@ } -.modal-button +.modal-button { padding-top: 20px; margin: 0; diff --git a/graphgraph-fe/src/graph_settings.js b/graphgraph-fe/src/graph_settings.js new file mode 100644 index 0000000..c8f03f1 --- /dev/null +++ b/graphgraph-fe/src/graph_settings.js @@ -0,0 +1,256 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import _ from 'underscore'; +import { DropdownButton, MenuItem, Label } from 'react-bootstrap'; +import './graph_settings.css'; +import Popup from './popup_settings.js'; +import ValidationModal from './validation_modal.js'; +import DownloadExport from './download_export.js'; +import { validateSchema, pathGraph, basicGraph, schemas, nodeNames } from './requests.js'; + +var emptyState = { + schemaProblems: [], + nodeNames: [], + fromNode: '', + graph: { + nodeNames: [], + edges: [] + }, + showHops: false, + enableDestinationNode: false, + toNode: '', + edgeFilter: 'Edgerules', + hops: { + parents: 1, + cousin: 1, + child: 1 + }, + selectedSchema: '' +}; + +class GraphSettings extends React.Component { + constructor (props, context) { + super(props, context); + this.onChangeStartNode = this.onChangeStartNode.bind(this); + this.onSelectNode = this.onSelectNode.bind(this); + this.selectSchema = this.selectSchema.bind(this); + this.onChangeToNode = this.onChangeToNode.bind(this); + this.loadInitialGraph = this.loadInitialGraph.bind(this); + this.updateHops = this.updateHops.bind(this); + this.changeEdgeFilter = this.changeEdgeFilter.bind(this); + this.graphFingerprint = this.graphFingerprint.bind(this); + this.state = emptyState; + } + + /* this serves as a config 'fingerprint' to know if the d3 visualisation + should be redrawn from scratch or just updated */ + graphFingerprint (schema, from, to, parents, cousin, child, edgeFilter) { + return `${schema}:${from}:${to}:${parents}:${cousin}:${child}:${edgeFilter}`; + } + + loadInitialGraph (startNode, endNode, parentHops, cousinHops, childHops, edgeFilter) { + if (this.state.selectedSchema === '' || startNode === 'none') { + var s = this.state; + s['edgeFilter'] = edgeFilter; + this.setState(s); + return; + } + if (startNode === 'all') { + endNode = 'none'; + } + + let requestUri = endNode === 'none' + ? basicGraph( + this.state.selectedSchema, startNode, parentHops, cousinHops, childHops, edgeFilter) + : pathGraph( + this.state.selectedSchema, startNode, endNode, edgeFilter); + + fetch(requestUri) + .then(response => response.json()) + .then(g => { + let schema = this.state.selectedSchema; + let f = this.graphFingerprint( + schema, startNode, endNode, parentHops, cousinHops, childHops, edgeFilter); + this.props.graphData(g, this.state.selectedSchema, f); + return g; + }) + .then(g => { + var s = this.state; + s['hops']['parents'] = parentHops; + s['hops']['cousin'] = cousinHops; + s['hops']['child'] = childHops; + s['fromNode'] = startNode; + s['toNode'] = endNode; + s['graph'] = g; + s['edgeFilter'] = edgeFilter; + s['showHops'] = endNode === 'none' && startNode !== 'none' && startNode !== 'all'; + s['enableDestinationNode'] = startNode !== 'none' && startNode !== 'all'; + this.setState(s); + + if (startNode !== 'all') { + this.onSelectNode(startNode); + } + }); + } + + selectSchema (schema) { + var s = this.state; + s['selectedSchema'] = schema; + fetch(nodeNames(schema, s['edgeFilter'])) + .then(response => response.json()) + .then(nodeNames => { + s['fromNode'] = s['toNode'] = 'none'; + s['nodeNames'] = nodeNames; + this.setState(s); + }); + fetch(validateSchema(schema)) + .then(response => response.json()) + .then(p => { + s['schemaProblems'] = p.problems; + this.setState(s); + }); + } + + changeEdgeFilter (edgeFilter) { + fetch(nodeNames(this.state.selectedSchema, edgeFilter)) + .then(response => response.json()) + .then(nodeNames => { + let s = this.state; + s['edgeFilter'] = edgeFilter; + s['fromNode'] = s['toNode'] = 'none'; + s['nodeNames'] = nodeNames; + this.setState(s); + }); + this.loadInitialGraph( + this.state.fromNode, + this.state.toNode, + this.state.hops.parents, + this.state.hops.cousin, + this.state.hops.child, + edgeFilter + ); + } + + updateHops (parentHops, cousinHops, childHops) { + this.loadInitialGraph( + this.state.fromNode, + this.state.toNode, + parentHops, + cousinHops, + childHops, + this.state.edgeFilter + ); + } + + onChangeToNode (eventKey) { + this.loadInitialGraph( + this.state.fromNode, + eventKey, + this.state.hops.parents, + this.state.hops.cousin, + this.state.hops.child, + this.state.edgeFilter + ); + } + + onSelectNode (eventKey) { + this.props.nodePropsLoader(eventKey); + } + + onChangeStartNode (eventKey) { + this.loadInitialGraph( + eventKey, + this.state.toNode, + this.state.hops.parents, + this.state.hops.cousin, + this.state.hops.child, + this.state.edgeFilter + ); + } + + componentDidMount () { + fetch(schemas()) + .then(response => response.json()) + .then(schemas => { + let s = this.state; + s['schemas'] = schemas; + this.setState(s); + }); + } + + render () { + var schemas = _.map(this.state.schemas, (x, k) => <MenuItem key={k} eventKey={x}>{x}</MenuItem>); + + var items = _.map(this.state.nodeNames, (x, k) => <MenuItem key={k} eventKey={x.id}>{x.id}</MenuItem>); + let sortedNames = _.sortBy(this.state.graph.nodeNames, 'id'); + var currentNodeNames = _.map(sortedNames, (x, k) => <MenuItem key={k} eventKey={x.id}>{x.id}</MenuItem>); + + var fromItems = items.slice(); + fromItems.unshift(<MenuItem key='divider' divider/>); + fromItems.unshift(<MenuItem key='all' eventKey='all'>all</MenuItem>); + + items.unshift(<MenuItem key='anotherdivider' divider/>); + items.unshift(<MenuItem key='none' eventKey='none'>none</MenuItem>); + + let edgeFilterItems = [ + <MenuItem key='Edgerules' eventKey='Edgerules'>Edgerules</MenuItem>, + <MenuItem key='Parents' eventKey='Parents'>Parent-child (OXM structure)</MenuItem>, + ]; + return ( + <div> + <div className="graph-menu"> + <div className="startendnode-dropdown"> + <div> + <Label>Schemas</Label> + <DropdownButton className="schemas-dropdown" onSelect={this.selectSchema} id="schemas" title={this.state.selectedSchema}>{schemas}</DropdownButton> + </div> + <div className="source-dropdown-div"> + <Label>Source Node</Label> + <DropdownButton className="node-dropdown" onSelect={this.onChangeStartNode} id="namesFrom" title={this.state.fromNode}>{fromItems}</DropdownButton> + </div> + <div> + <Label>Destination Node</Label> + <DropdownButton disabled={!this.state.enableDestinationNode} className="node-dropdown" onSelect={this.onChangeToNode} id="namesTo" title={this.state.toNode}>{items}</DropdownButton> + </div> + <div className="source-dropdown-div"> + <Label>Edge filter</Label> + <DropdownButton className="node-dropdown" onSelect={this.changeEdgeFilter} id="filterEdge" title={this.state.edgeFilter}>{edgeFilterItems}</DropdownButton> + </div> + <div className="source-dropdown-div"> + <Label>Selected Node</Label> + <DropdownButton className="node-dropdown" onSelect={this.onSelectNode} id="selectedNode" title={this.props.selectedNode}>{currentNodeNames}</DropdownButton> + </div> + <Popup isDisabled={!this.state.showHops} edgeFilter={this.state.edgeFilter} parentHops={this.state.hops.parents} childHops={this.state.hops.child} cousinHops={this.state.hops.cousin} updateHops={this.updateHops}/> + <div className="modal-button"> + <ValidationModal schemaProblems={this.state.schemaProblems}/> + </div> + <div className="modal-button"> + <DownloadExport schemaVersion={this.state.selectedSchema}/> + </div> + </div> + </div> + </div> + ); + } +} + +export default GraphSettings; diff --git a/graphgraph-fe/src/GraphSettingsMenu.css b/graphgraph-fe/src/graph_settings_menu.css index 466d07d..718dfa0 100644 --- a/graphgraph-fe/src/GraphSettingsMenu.css +++ b/graphgraph-fe/src/graph_settings_menu.css @@ -1,12 +1,11 @@ - .navbar.navbar-adjust{ -margin-bottom: 0px; + margin-bottom: 0px; } .navbar-adjust .container { -margin-left: 0; + margin-left: 0; } .navbar-adjust .container .navbar-header { -margin-right: 250px; + margin-right: 250px; } diff --git a/graphgraph-fe/src/graph_settings_menu.js b/graphgraph-fe/src/graph_settings_menu.js new file mode 100644 index 0000000..05def48 --- /dev/null +++ b/graphgraph-fe/src/graph_settings_menu.js @@ -0,0 +1,45 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import GraphSettings from './graph_settings.js'; +import { Navbar, Nav } from 'react-bootstrap'; +import './graph_settings_menu.css'; + +class GraphSettingsMenu extends React.Component { + render () { + return ( + <Navbar className='navbar-adjust'> + <Navbar.Header> + <Navbar.Brand> + <a href="https://gerrit.onap.org/r/gitweb?p=aai/graphgraph.git">GraphGraph</a> + </Navbar.Brand> + </Navbar.Header> + <Nav className="mr-auto"> + <Navbar.Collapse className='mr-sm-2'> + <GraphSettings selectedNode={this.props.selectedNode} graphData={this.props.graphData} nodePropsLoader={this.props.nodePropsLoader}/> + </Navbar.Collapse> + </Nav> + </Navbar> + ); + } +} + +export default GraphSettingsMenu; diff --git a/graphgraph-fe/src/index.css b/graphgraph-fe/src/index.css index cee5f34..cf5eae8 100644 --- a/graphgraph-fe/src/index.css +++ b/graphgraph-fe/src/index.css @@ -1,14 +1,14 @@ body { - margin: 0; - padding: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; } diff --git a/graphgraph-fe/src/index.js b/graphgraph-fe/src/index.js index bdd3da2..4269967 100644 --- a/graphgraph-fe/src/index.js +++ b/graphgraph-fe/src/index.js @@ -1,13 +1,33 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import './index.css' -import App from './App' -import * as serviceWorker from './serviceWorker' -import 'bootstrap-css-only/css/bootstrap.css' +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ -ReactDOM.render(<App />, document.getElementById('root')) +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import App from './app.js'; +import * as serviceWorker from './service_worker.js'; +import 'bootstrap-css-only/css/bootstrap.css'; + +ReactDOM.render(<App/>, document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: http://bit.ly/CRA-PWA -serviceWorker.unregister() +serviceWorker.unregister(); diff --git a/graphgraph-fe/src/path_breadcrumb.js b/graphgraph-fe/src/path_breadcrumb.js new file mode 100644 index 0000000..6ed9d16 --- /dev/null +++ b/graphgraph-fe/src/path_breadcrumb.js @@ -0,0 +1,46 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import _ from 'underscore'; +import React from 'react'; +import { Breadcrumb } from 'react-bootstrap'; + +class PathBreadcrumb extends React.Component { + constructor (props, context) { + super(props, context); + this.pathSelected = this.pathSelected.bind(this); + } + + pathSelected (evt) { + evt.preventDefault(); + // the data is only piggyback riding on the "target" property .. not nice but works + this.props.pathCallback(this.props.index, evt.target.getAttribute('target')); + } + + render () { + var path = this.props.path; + var callback = this.pathSelected; + var items = _.map(path, (item, i) => <Breadcrumb.Item key={i} target={item.id} onClick={callback}> {item.id} </Breadcrumb.Item>); + + return <Breadcrumb>{items}</Breadcrumb>; + } +} + +export default PathBreadcrumb; diff --git a/graphgraph-fe/src/PopupSettings.css b/graphgraph-fe/src/popup_settings.css index f80e264..2a548af 100644 --- a/graphgraph-fe/src/PopupSettings.css +++ b/graphgraph-fe/src/popup_settings.css @@ -13,4 +13,3 @@ text-decoration: none; font-size: 25px; } - diff --git a/graphgraph-fe/src/popup_settings.js b/graphgraph-fe/src/popup_settings.js new file mode 100644 index 0000000..2430ab2 --- /dev/null +++ b/graphgraph-fe/src/popup_settings.js @@ -0,0 +1,40 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import React from 'react'; +import Popup from 'reactjs-popup'; +import './popup_settings.css'; +import GraphHops from './graph_hops.js'; + +class PopupMenu extends React.Component { + render () { + return ( + <Popup trigger={<button className='settings-button' disabled={this.props.isDisabled}>Hops</button>} position="bottom right"> + {close => ( + <div> + <GraphHops edgeFilter={this.props.edgeFilter} parentHops={this.props.parentHops} childHops={this.props.childHops} cousinHops={this.props.cousinHops} updateHops={this.props.updateHops}/> + <button type="button" className="link-button, close" onClick={close}>×</button> + </div> + )}</Popup> + ); + } +} + +export default PopupMenu; diff --git a/graphgraph-fe/src/requests.js b/graphgraph-fe/src/requests.js index 8a86e0c..ae41f78 100644 --- a/graphgraph-fe/src/requests.js +++ b/graphgraph-fe/src/requests.js @@ -1,35 +1,55 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + const host = window.location.hostname; const port = window.location.port; const protocol = window.location.protocol; export function schemas () { - return `${protocol}//${host}:${port}/schemas` -} + return `${protocol}//${host}:${port}/schemas`; +}; export function validateSchema (schema) { - return `${protocol}//${host}:${port}/schemas/${schema}/validation` -} + return `${protocol}//${host}:${port}/schemas/${schema}/validation`; +}; export function exportSchema (schema) { - return `${protocol}//${host}:${port}/schemas/${schema}/xmiexport` -} + return `${protocol}//${host}:${port}/schemas/${schema}/xmiexport`; +}; export function nodeNames (schema, edgeFilter) { - return `${protocol}//${host}:${port}/schemas/${schema}/nodes?edgeFilter=${edgeFilter}` -} + return `${protocol}//${host}:${port}/schemas/${schema}/nodes?edgeFilter=${edgeFilter}`; +}; export function basicGraph (schema, node, parentHops, cousinHops, childHops, edgeFilter) { - return `${protocol}//${host}:${port}/schemas/${schema}/graph/basic?node=${node}&parentHops=${parentHops}&cousinHops=${cousinHops}&childHops=${childHops}&edgeFilter=${edgeFilter}` -} + return `${protocol}//${host}:${port}/schemas/${schema}/graph/basic?node=${node}&parentHops=${parentHops}&cousinHops=${cousinHops}&childHops=${childHops}&edgeFilter=${edgeFilter}`; +}; export function pathGraph (schema, fromNode, toNode, edgeFilter) { - return `${protocol}//${host}:${port}/schemas/${schema}/graph/paths?fromNode=${fromNode}&toNode=${toNode}&edgeFilter=${edgeFilter}` -} + return `${protocol}//${host}:${port}/schemas/${schema}/graph/paths?fromNode=${fromNode}&toNode=${toNode}&edgeFilter=${edgeFilter}`; +}; export function nodeProperty (schema, node) { - return `${protocol}//${host}:${port}/schemas/${schema}/nodes/${node}` -} + return `${protocol}//${host}:${port}/schemas/${schema}/nodes/${node}`; +}; export function edgeProperty (schema, fromNode, toNode) { - return `${protocol}//${host}:${port}/schemas/${schema}/edges?fromNode=${fromNode}&toNode=${toNode}` -} + return `${protocol}//${host}:${port}/schemas/${schema}/edges?fromNode=${fromNode}&toNode=${toNode}`; +}; diff --git a/graphgraph-fe/src/serviceWorker.js b/graphgraph-fe/src/serviceWorker.js deleted file mode 100644 index 5c6ead6..0000000 --- a/graphgraph-fe/src/serviceWorker.js +++ /dev/null @@ -1,135 +0,0 @@ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read http://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.1/8 is considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -) - -export function register (config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config) - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit http://bit.ly/CRA-PWA' - ) - }) - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config) - } - }) - } -} - -function registerValidSW (swUrl, config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing - if (installingWorker == null) { - return - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' - ) - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration) - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.') - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration) - } - } - } - } - } - }) - .catch(error => { - console.error('Error during service worker registration:', error) - }) -} - -function checkValidServiceWorker (swUrl, config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type') - if ( - response.status === 404 || - (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload() - }) - }) - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config) - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ) - }) -} - -export function unregister () { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.unregister() - }) - } -} diff --git a/graphgraph-fe/src/service_worker.js b/graphgraph-fe/src/service_worker.js new file mode 100644 index 0000000..0ac740b --- /dev/null +++ b/graphgraph-fe/src/service_worker.js @@ -0,0 +1,146 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read http://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) +); + +export function register (config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit http://bit.ly/CRA-PWA' + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + }); + } +} + +function registerValidSW (swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log('New content is available and will be used when all ' + + 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker (swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log('No internet connection found. App is running in offline mode.'); + }); +} + +export function unregister () { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +}; diff --git a/graphgraph-fe/src/ValidationModal.css b/graphgraph-fe/src/validation_modal.css index 56b4567..4fe12a9 100644 --- a/graphgraph-fe/src/ValidationModal.css +++ b/graphgraph-fe/src/validation_modal.css @@ -1,7 +1,7 @@ .modal-content { -height: 100%; -width: 100%; + height: 100%; + width: 100%; } .modal-validator @@ -16,9 +16,9 @@ width: 100%; backgroundColor: 'white'; boxShadow: '0 5px 15px rgba(0,0,0,.5)'; padding: 0; -} +} -.modal-backdrop +.modal-backdrop { position: 'fixed'; zIndex: 1040; diff --git a/graphgraph-fe/src/validation_modal.js b/graphgraph-fe/src/validation_modal.js new file mode 100644 index 0000000..1944c36 --- /dev/null +++ b/graphgraph-fe/src/validation_modal.js @@ -0,0 +1,58 @@ +/* + * ============LICENSE_START======================================================= + * org.onap.aai + * ================================================================================ + * Copyright © 2019-2020 Orange Intellectual Property. All rights reserved. + * ================================================================================ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============LICENSE_END========================================================= + */ + +import _ from 'underscore'; +import React from 'react'; +import './validation_modal.css'; +import { Button, Modal, ListGroup, ListGroupItem } from 'react-bootstrap'; + +class ValidationModal extends React.Component { + constructor(...args) { + super(...args); + this.state = { showModal: false }; + this.close = () => { + this.setState({ showModal: false }); + }; + this.open = () => { + this.setState({ showModal: true }); + }; + } + + renderBackdrop(props) { + return <div {...props} className="modal-backdrop"/>; + } + + render() { + var problems = this.props.schemaProblems; + var items = _.map(problems, (problem, i) => <ListGroupItem key={i}>{problem}</ListGroupItem>); + return ( + <div> + <Button onClick={this.open}>Validate schema</Button> + <Modal onHide={this.close} className="modal-validator" aria-labelledby="modal-label" show={this.state.showModal} renderBackdrop={this.renderBackdrop}> + <div className="modal-list"> + <ListGroup>{items}</ListGroup> + </div> + </Modal> + </div> + ); + } +} + +export default ValidationModal; |