diff --git a/build/app/assets/templates/alexander.json b/build/app/assets/templates/alexander.json index af9766a0f..c74ae36dd 100644 --- a/build/app/assets/templates/alexander.json +++ b/build/app/assets/templates/alexander.json @@ -19,6 +19,7 @@ "label": { "label": "Label", "help": "A short title for the node", + "duplicateWarning": "You’re entering a duplicate node. Do you want to View the Existing node, or Continue creating?", "_cmt4": "/// `Label` is always required and cannot be hidden" }, "type": { @@ -59,6 +60,11 @@ } ] }, + "degrees": { + "label": "Degrees", + "help": "Number of edges.", + "hidden": false + }, "notes": { "label": "Significance", "help": "Add some details.", @@ -68,7 +74,10 @@ "label": "Geocode or Date", "help": "Use latitude/longitude or a date mm/dd/yyy", "hidden": true - } + }, + "delete": { + "hidden": false + } }, diff --git a/build/app/unisys/server-database.js b/build/app/unisys/server-database.js index 64fcdfe4d..566e42b69 100644 --- a/build/app/unisys/server-database.js +++ b/build/app/unisys/server-database.js @@ -139,7 +139,7 @@ let DB = {}; }; /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - DB.PKT_Update = function ( pkt ) { - let { node, edge, edgeID } = pkt.Data(); + let { node, edge, nodeID, replacementNodeID, edgeID } = pkt.Data(); let retval = {}; // PROCESS NODE INSERT/UPDATE @@ -192,6 +192,43 @@ let DB = {}; return retval; } // if edge + // DELETE NODES + if (nodeID !== undefined) { + if (DBG) console.log(PR, `PKT_Update ${pkt.Info()} DELETE nodeID ${nodeID}`); + + // Log first so it's apparent what is triggering the edge changes + LOGGER.Write(pkt.Info(), `delete node`, nodeID); + + // handle edges + let edgesToProcess = EDGES.where((e) => { + return e.source === nodeID || e.target === nodeID; + }); + // `NaN` is not valid JSON, so we use `` + if (replacementNodeID !== '') { + // re-link edges to replacementNodeID + EDGES.findAndUpdate({ source: nodeID }, (e) => { + LOGGER.Write(`...`, pkt.Info(), `relinking edge`, e.id, `to`, replacementNodeID); + e.source = replacementNodeID; + }); + EDGES.findAndUpdate({ target: nodeID }, (e) => { + LOGGER.Write(`...`, pkt.Info(), `relinking edge`, e.id, `to`, replacementNodeID); + e.target = replacementNodeID; + }); + } else { + // delete edges + EDGES.findAndRemove({ source: nodeID }, (e) => { + LOGGER.Write(`...`, pkt.Info(), `deleting edge`, e.id, `from`, nodeID); + e.source = nodeID; + }); + EDGES.findAndRemove({ target: nodeID }, (e) => { + LOGGER.Write(`...`, pkt.Info(), `deleting edge`, e.id, `from`, nodeID); + e.target = nodeID; + }); + } + NODES.findAndRemove({ id: nodeID }); + return { op: 'delete', nodeID, replacementNodeID }; + } + // DELETE EDGES if (edgeID!==undefined) { if (DBG) console.log(PR,`PKT_Update ${pkt.Info()} DELETE edgeID ${edgeID}`); diff --git a/build/app/unisys/server.js b/build/app/unisys/server.js index 33b14886b..ec209b9fd 100644 --- a/build/app/unisys/server.js +++ b/build/app/unisys/server.js @@ -71,7 +71,8 @@ var UNISYS = {}; data.src = 'remote'; // fire update messages if (data.node) UNET.NetSend('SOURCE_UPDATE',data); - if (data.edge) UNET.NetSend('EDGE_UPDATE',data); + if (data.edge) UNET.NetSend('EDGE_UPDATE', data); + if (data.nodeID!==undefined) UNET.NetSend('NODE_DELETE', data); if (data.edgeID!==undefined) UNET.NetSend('EDGE_DELETE',data); // return SRV_DBUPDATE value (required) return { OK:true, info:'SRC_DBUPDATE' }; diff --git a/build/app/view/netcreate/NetCreate.jsx b/build/app/view/netcreate/NetCreate.jsx index 124f2cdab..bac32b373 100644 --- a/build/app/view/netcreate/NetCreate.jsx +++ b/build/app/view/netcreate/NetCreate.jsx @@ -47,9 +47,7 @@ const { Route } = require('react-router-dom'); const NetGraph = require('./components/NetGraph'); const Search = require('./components/Search'); const NodeSelector = require('./components/NodeSelector'); -const Help = require('./components/Help'); -const NodeTable = require('./components/NodeTable'); -const EdgeTable = require('./components/EdgeTable'); +const InfoPanel = require('./components/InfoPanel'); const NCLOGIC = require('./nc-logic'); // require to bootstrap data loading @@ -77,11 +75,17 @@ const NCLOGIC = require('./nc-logic'); // require to bootstrap data loading }); } + + + /// REACT LIFECYCLE METHODS /////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ This is the root component, so this fires after all subcomponents have been fully rendered by render(). /*/ componentDidMount () { + // Init dragger + let dragger = document.getElementById('dragger'); + dragger.onmousedown = this.handleMouseDown; } /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ Define the component structure of the web application @@ -100,9 +104,7 @@ const NCLOGIC = require('./nc-logic'); // require to bootstrap data loading
- - - +
Please contact Professor Kalani Craig, Institute for Digital Arts & Humanities at diff --git a/build/app/view/netcreate/components/AutoComplete.css b/build/app/view/netcreate/components/AutoComplete.css index e7e3b8f46..1261fedad 100644 --- a/build/app/view/netcreate/components/AutoComplete.css +++ b/build/app/view/netcreate/components/AutoComplete.css @@ -92,6 +92,11 @@ td { hyphens: auto; } +/* Bootstrap override */ +.nav-item { + cursor: pointer; +} + /* SVG styles */ /* REVIEW!!! These are global!!! */ svg { diff --git a/build/app/view/netcreate/components/AutoComplete.jsx b/build/app/view/netcreate/components/AutoComplete.jsx index 5e940718f..46b201714 100644 --- a/build/app/view/netcreate/components/AutoComplete.jsx +++ b/build/app/view/netcreate/components/AutoComplete.jsx @@ -340,7 +340,7 @@ class AutoComplete extends UNISYS.Component { https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html /*/ componentDidMount () { _IsMounted = true; - this.setState({ mode: this.props.inactiveMode }) + this.setState({ mode: this.props.inactiveMode }); } /*/ /*/ componentWillUnmount () { @@ -357,34 +357,75 @@ class AutoComplete extends UNISYS.Component { onChange : this.onInputChange }; let jsx; - switch (this.state.mode) { - case MODE_STATIC: - jsx = (

{this.props.disabledValue}

); - break; - case MODE_DISABLED: - jsx = ( ); - break; - case MODE_ACTIVE: - jsx = ( - - ); - break; - default: - throw Error(`AutoComplete: Unhandled mode '${this.state.mode}'`); + + // Show different widgets depending on mode. + // If MODE_ACTIVE is just show the active state, + // otherwise, use the current inactive mode in this.props.inactiveMode + // to define the inactive state + // because this.state.mode may not be up to date if the mode is inactive + // due to prop changes not triggering mode updates. + // e.g. if the parent container changed props from a disabled to + // static state, it does not trigger a mode update in AUTOCOMPLETE. + // This is mostly an edge case with EDGE_EDITs which will update props + // without a corresponding UNISYS message call to trigger the mode + // change. + if (this.state.mode === MODE_ACTIVE) { + jsx = ( + + ); + } else if (this.props.inactiveMode === MODE_STATIC) { + jsx = (

{this.props.disabledValue}

); + } else if (this.props.inactiveMode === MODE_DISABLED) { + jsx = (); + } else { + throw Error(`AutoComplete: Unhandled mode '${this.state.mode}'`); } + + // OLD METHOD + // This relied on mode being updated, but a change in props does not + // trigger a corresponding change in mode. + // switch (this.state.mode) { + // case MODE_STATIC: + // jsx = (

{this.props.disabledValue}

); + // break; + // case MODE_DISABLED: + // jsx = ( ); + // break; + // case MODE_ACTIVE: + // jsx = ( + // + // ); + // break; + // default: + // throw Error(`AutoComplete: Unhandled mode '${this.state.mode}'`); + // } + return jsx; } // render() diff --git a/build/app/view/netcreate/components/EdgeEditor.jsx b/build/app/view/netcreate/components/EdgeEditor.jsx index cd01ca032..f82447982 100644 --- a/build/app/view/netcreate/components/EdgeEditor.jsx +++ b/build/app/view/netcreate/components/EdgeEditor.jsx @@ -249,7 +249,7 @@ class EdgeEditor extends UNISYS.Component { // as a handler, otherwise object context is lost /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ SESSION is called by SessionSHell when the ID changes + /*/ SESSION is called by SessionShell when the ID changes set system-wide. data: { classId, projId, hashedId, groupId, isValid } /*/ this.OnAppStateChange('SESSION',this.onStateChange_SESSION); /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -313,12 +313,16 @@ class EdgeEditor extends UNISYS.Component { /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ populate formdata from D3DATA /*/ loadSourceAndTarget () { - if (DBG) console.log('EdgeEditor.loadSourceAndTarget!') + if (DBG) console.log('EdgeEditor.loadSourceAndTarget!') + let edgeID = this.props.edgeID || ''; - + // Clean Data + if (isNaN(edgeID)) { edgeID = parseInt(edgeID); } + let D3DATA = this.AppState('D3DATA'); - let edges = D3DATA.edges.filter( edge=>edge.id===edgeID ); + // parseInt in case of old bad string id + let edges = D3DATA.edges.filter( edge=>parseInt(edge.id)===edgeID ); if (!edges) { throw 'EdgeEditor: Passed edgeID'+edgeID+'not found!'; } @@ -344,7 +348,7 @@ class EdgeEditor extends UNISYS.Component { // Define `edge` so it can be loaded later during setState. edge = { id: edgeID, - source: sourceNodes[0].id, // REVIEW: d3data 'source' is id, rename this to 'sourceId'? + source: parseInt(sourceNodes[0].id), // REVIEW: d3data 'source' is id, rename this to 'sourceId'? // though after d3 processes, source does become an object. target: undefined, attributes: { @@ -365,8 +369,8 @@ class EdgeEditor extends UNISYS.Component { // LOAD EXISTING EDGE - sourceNodes = D3DATA.nodes.filter( node => node.id===edge.source.id ); - targetNodes = D3DATA.nodes.filter( node => node.id===edge.target.id ); + sourceNodes = D3DATA.nodes.filter( node => parseInt(node.id)===parseInt(edge.source.id) ); + targetNodes = D3DATA.nodes.filter( node => parseInt(node.id)===parseInt(edge.target.id) ); // Assume we have a valid target node this.setState({ @@ -388,7 +392,7 @@ class EdgeEditor extends UNISYS.Component { if (DBG) console.log('...EdgeEditor.loadSourceAndTarget: Setting formData sourceID to',edge.source,'and sourceNode to',sourceNode,'and targetNode to',targetNode); this.setState({ formData: { - id: edge.id || '', + id: parseInt(edge.id) || '', sourceId: edge.source, targetId: edge.target, relationship: edge.attributes["Relationship"] || '', // Make sure there's valid data @@ -430,7 +434,7 @@ class EdgeEditor extends UNISYS.Component { // SOURCE if (DBG) console.log('EdgeEditor.handleSelection:',this.props.edgeID,'setting source node to',node); - // Set sourceNpde state + // Set sourceNode state this.setState({ sourceNode: node }); @@ -447,7 +451,7 @@ class EdgeEditor extends UNISYS.Component { sourceIsEditable: false }); - } else { + } else if (this.state.targetIsEditable) { // TARGET if (DBG) console.log('EdgeEditor.handleSelection:',this.props.edgeID,'setting target node to',node); diff --git a/build/app/view/netcreate/components/EdgeTable.jsx b/build/app/view/netcreate/components/EdgeTable.jsx index 37b3ee2d5..b95cd3220 100644 --- a/build/app/view/netcreate/components/EdgeTable.jsx +++ b/build/app/view/netcreate/components/EdgeTable.jsx @@ -14,6 +14,13 @@ Set `DBG` to true to show the `ID` column. + ## 2018-12-07 Update + + Since we're not using tab navigation: + 1. The table isExpanded is now true by default. + 2. The "Show/Hide Table" button is hidden. + + Reset these to restore previous behavior. \*\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ * //////////////////////////////////////*/ @@ -38,7 +45,7 @@ class EdgeTable extends UNISYS.Component { this.state = { edgePrompts: this.AppState('TEMPLATE').edgePrompts, edges: [], - isExpanded: false, + isExpanded: true, sortkey: 'Citations' }; @@ -259,13 +266,27 @@ class EdgeTable extends UNISYS.Component { /*/ /*/ render () { let { edgePrompts } = this.state; + let { tableHeight } = this.props; + let styles = `thead, tbody { display: block; } + thead { position: relative; } + tbody { overflow: auto; } + .edgetable td:nth-child(1), .edgetable th:nth-child(1) {width: 2em; min-width: 2em;} + .edgetable td:nth-child(2), .edgetable th:nth-child(2) {width: 2em; min-width: 2em;} + .edgetable td:nth-child(3), .edgetable th:nth-child(3) {width: 4em; min-width: 4em;} + .edgetable td:nth-child(4), .edgetable th:nth-child(4) {width: 6em; min-width: 6em;} + .edgetable td:nth-child(5), .edgetable th:nth-child(5) {width: 14em; min-width: 14em;} + .edgetable td:nth-child(6), .edgetable th:nth-child(6) {width: 6em; min-width: 6em;} + .edgetable td:nth-child(7), .edgetable th:nth-child(7) {width: 6em; min-width: 6em;} + .edgetable td:nth-child(8), .edgetable th:nth-child(8) {min-width: 6em; }` return ( -
- @@ -301,7 +322,7 @@ class EdgeTable extends UNISYS.Component { >{edgePrompts.info.label} - + {this.state.edges.map( (edge,i) => ( diff --git a/build/app/view/netcreate/components/Help.jsx b/build/app/view/netcreate/components/Help.jsx index 1a5e25594..5ed33aa05 100644 --- a/build/app/view/netcreate/components/Help.jsx +++ b/build/app/view/netcreate/components/Help.jsx @@ -25,7 +25,7 @@ const UNISYS = require('unisys/client'); class Help extends UNISYS.Component { constructor (props) { super(props); - this.state = {isExpanded: false}; + this.state = {isExpanded: true}; this.onToggleExpanded = this.onToggleExpanded.bind(this); } // constructor @@ -54,15 +54,15 @@ class Help extends UNISYS.Component { /*/ render () { return (
-
+ + + + + + + + + + + + + + + + + + + + + + + + + ); + } + +} // class InfoPanel + + +/// EXPORT REACT COMPONENT //////////////////////////////////////////////////// +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +module.exports = InfoPanel; diff --git a/build/app/view/netcreate/components/NetGraph.jsx b/build/app/view/netcreate/components/NetGraph.jsx index c26be8fe7..3e4c85b22 100644 --- a/build/app/view/netcreate/components/NetGraph.jsx +++ b/build/app/view/netcreate/components/NetGraph.jsx @@ -33,6 +33,8 @@ var DBG = false; /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - const React = require('react') const ReactDOM = require('react-dom') +const ReactStrap = require('reactstrap'); +const { Button } = ReactStrap; const D3NetGraph = require('./d3-simplenetgraph') const UNISYS = require('unisys/client'); @@ -47,16 +49,35 @@ class NetGraph extends UNISYS.Component { this.state = { d3NetGraph: {} } + + this.onZoomReset = this.onZoomReset.bind(this); + this.onZoomIn = this.onZoomIn.bind(this); + this.onZoomOut = this.onZoomOut.bind(this); + } // constructor +/// CLASS PRIVATE METHODS ///////////////////////////////////////////////////// +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ +/*/ onZoomReset() { + this.AppCall('ZOOM_RESET', {}); + } +/*/ +/*/ onZoomIn() { + this.AppCall('ZOOM_IN', {}); + } +/*/ +/*/ onZoomOut() { + this.AppCall('ZOOM_OUT', {}); + } /// REACT LIFECYCLE /////////////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ /*/ componentDidMount () { // D3NetGraph Constructor - let el = ReactDOM.findDOMNode( this ) + let el = ReactDOM.findDOMNode(this); let d3NetGraph = new D3NetGraph(el); this.setState({ d3NetGraph }); } @@ -67,12 +88,21 @@ class NetGraph extends UNISYS.Component { // allowing D3 to handle the simulation animation updates // This is also necessary for D3 to handle the // drag events. - return false + return false; } /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ /*/ render () { - return (
NETGRAPH
) + return ( +
+
NETGRAPH
+
+   +   + +
+
+ ) } } // class NetGraph diff --git a/build/app/view/netcreate/components/NodeSelector.jsx b/build/app/view/netcreate/components/NodeSelector.jsx index 4e7fbf9a4..f0fa14c8e 100644 --- a/build/app/view/netcreate/components/NodeSelector.jsx +++ b/build/app/view/netcreate/components/NodeSelector.jsx @@ -8,7 +8,7 @@ NodeSelector does not modify any data. It passes all events (text updates, highlights, and suggestion selections) up to nc-logic. it should process the events and update the data accordingly. The - updated data is then rendered by NodeSelect. + updated data is then rendered by NodeSelector. ## USAGE @@ -42,6 +42,11 @@ isEditable The form fields are active and can be edited. + Delete Button + The Delete button is only displayed for an admin user. Right now we are detecting + this by displaying it only when the user is on `localhost`, + + ## STATES formData Node data that is shown in the form @@ -105,16 +110,22 @@ const PR = 'NodeSelector'; /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - const React = require('react'); const ReactStrap = require('reactstrap'); -const { Button, Col, Form, FormGroup, Label, Input, FormText } = ReactStrap; +const { Button, Col, Form, FormGroup, FormFeedback, FormText, Label, Input } = ReactStrap; const AutoComplete = require('./AutoComplete'); const NodeDetail = require('./NodeDetail'); const EdgeEditor = require('./EdgeEditor'); const UNISYS = require('unisys/client'); const DATASTORE = require('system/datastore'); +const SETTINGS = require('settings'); const thisIdentifier = 'nodeSelector'; // SELECTION identifier +const isLocalHost = (SETTINGS.EJSProp('client').ip === '127.0.0.1'); + +var UDATA = null; + + /// REACT COMPONENT /////////////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /// export a class object for consumption by brunch/require @@ -128,13 +139,17 @@ class NodeSelector extends UNISYS.Component { type: '', info: '', notes: '', - id: '', + id: '', // Always convert this to a Number isNewNode: true }, edges: [], isLocked: true, isEditable: false, - isValid: false + isValid: false, + isDuplicateNodeLabel: false, + duplicateNodeID: '', + replacementNodeID: '', + isValidReplacementNodeID: true }; // Bind functions to this component's object context this.clearForm = this.clearForm.bind(this); @@ -148,15 +163,23 @@ class NodeSelector extends UNISYS.Component { this.onTypeChange = this.onTypeChange.bind(this); this.onNotesChange = this.onNotesChange.bind(this); this.onInfoChange = this.onInfoChange.bind(this); + this.onReplacementNodeIDChange = this.onReplacementNodeIDChange.bind(this); this.onNewNodeButtonClick = this.onNewNodeButtonClick.bind(this); + this.onDeleteButtonClick = this.onDeleteButtonClick.bind(this); this.onEditButtonClick = this.onEditButtonClick.bind(this); this.onAddNewEdgeButtonClick = this.onAddNewEdgeButtonClick.bind(this); this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onEditOriginal = this.onEditOriginal.bind(this); + this.onCloseDuplicateDialog = this.onCloseDuplicateDialog.bind(this); this.onSubmit = this.onSubmit.bind(this); // NOTE: assign UDATA handlers AFTER functions have been bind()'ed // otherwise they will lose context + /// Initialize UNISYS DATA LINK for REACT + UDATA = UNISYS.NewDataLink(this); + + /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ SESSION is called by SessionSHell when the ID changes set system-wide. data: { classId, projId, hashedId, groupId, isValid } @@ -186,12 +209,16 @@ class NodeSelector extends UNISYS.Component { type: '', info: '', notes: '', - id: '', + id: '', // Always convert this to a Number isNewNode: true }, edges: [], isEditable: false, - isValid: false + isValid: false, + isDuplicateNodeLabel: false, + duplicateNodeID: '', + replacementNodeID: '', + isValidReplacementNodeID: true }); } // clearFform /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -282,7 +309,7 @@ class NodeSelector extends UNISYS.Component { // * force exit? // * prevent load? // * prevent selection? - if (DBG) console.log('NodeSelector: Already editing, ignoring SELECTION'); + if (DBG) console.error('NodeSelector: Already editing, ignoring SELECTION'); } this.validateForm(); @@ -311,11 +338,29 @@ class NodeSelector extends UNISYS.Component { // otherwise an Edge might be active let { activeAutoCompleteId } = this.AppState('ACTIVEAUTOCOMPLETE'); if ( activeAutoCompleteId!==thisIdentifier ) return; - let formData = this.state.formData; formData.label = data.searchLabel; + + // "Duplicate Node Label" is only a warning, not an error. + // We want to allow students to enter a duplicate label if necessary + // This is a case insensitive search + let isDuplicateNodeLabel = false; + let duplicateNodeID; + if (formData.label !== '' && + this.AppState('D3DATA').nodes.find(node => { + if ((node.id !== formData.id) && + (node.label.localeCompare(formData.label, 'en', { usage: 'search', sensitivity: 'base' })) === 0) { + duplicateNodeID = node.id; + return true; + } + })) { + isDuplicateNodeLabel = true; + } + this.setState({ - formData + formData, + isDuplicateNodeLabel, + duplicateNodeID }); this.validateForm(); @@ -330,7 +375,9 @@ class NodeSelector extends UNISYS.Component { // Clean data // REVIEW: Basic data structure probably needs updating let node = {attributes:{}}; - if (newNode.attributes===undefined) { newNode.attributes = {} } + if (newNode.attributes === undefined) { newNode.attributes = {} } + // Backward Compatibility: Always convert ids to a Number or loki lookups will fail. + if (isNaN(newNode.id)) { newNode.id = parseInt(newNode.id); } // node.label = newNode.label || ''; node.id = newNode.id || ''; @@ -347,7 +394,8 @@ class NodeSelector extends UNISYS.Component { id: node.id, isNewNode: false }, - isEditable: false + isEditable: false, + isDuplicateNodeLabel: false }); this.validateForm(); @@ -403,6 +451,22 @@ class NodeSelector extends UNISYS.Component { } // onInfoChange /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ +/*/ + onReplacementNodeIDChange(event) { + let replacementNodeID = parseInt( event.target.value ); + let isValid = false; + // Allow `` because we use a a blank field to indicate delete node without relinking edges. + if ((event.target.value === '') || + (this.AppState('D3DATA').nodes.find(node => { return node.id === replacementNodeID; })) ) { + isValid = true; + } + this.setState({ + replacementNodeID: replacementNodeID, + isValidReplacementNodeID: isValid + }); + } // onReplacementNodeIDChange +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ /*/ onNewNodeButtonClick (event) { event.preventDefault(); @@ -436,6 +500,23 @@ class NodeSelector extends UNISYS.Component { this.validateForm(); }); } // onNewNodeButtonClick + /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + /*/ + /*/ + onDeleteButtonClick() { + // nodeID needs to be a Number. It should have been set in loadFormFromNode + let nodeID = this.state.formData.id; + + // Re-link edges or delete edges? + // `NaN` is not valid JSON, so we need to pass `` + let replacementNodeID = this.state.replacementNodeID==='' ? '' : parseInt( this.state.replacementNodeID ); // '' = Delete edges by default + + this.clearForm(); + this.AppCall('DB_UPDATE', { + nodeID: nodeID, + replacementNodeID: replacementNodeID + }); + } /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ /*/ onEditButtonClick (event) { @@ -498,6 +579,28 @@ class NodeSelector extends UNISYS.Component { this.AppCall('AUTOCOMPLETE_SELECT', {id:'search'}); } } // onCancelButtonClick +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + /*/ Select the node for editing + /*/ + onEditOriginal(event) { + event.preventDefault(); + let duplicateNodeID = parseInt(this.state.duplicateNodeID); + this.clearForm(); + this.setState({ + isEditable: false, + isDuplicateNodeLabel: false + }, () => { + // Wait for the edit state to clear, then open up the original node + UDATA.LocalCall('SOURCE_SELECT', { nodeIDs: [duplicateNodeID] }); + }); + this.AppCall('AUTOCOMPLETE_SELECT', { id: 'search' }); + } +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + /*/ User confirms they want to edit the existing node. + /*/ + onCloseDuplicateDialog() { + this.setState({ isDuplicateNodeLabel: false }); + } /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ /*/ onSubmit ( event ) { @@ -554,6 +657,12 @@ class NodeSelector extends UNISYS.Component { shouldIgnoreSelection={this.state.isEditable} /> +
@@ -598,7 +707,7 @@ class NodeSelector extends UNISYS.Component { /> - + +
+ Re-link edges to this Node ID (leave blank to delete edge) + + + + Invalid Node ID! + + +
EDGES diff --git a/build/app/view/netcreate/components/NodeTable.jsx b/build/app/view/netcreate/components/NodeTable.jsx index e21e376d6..88283edf7 100644 --- a/build/app/view/netcreate/components/NodeTable.jsx +++ b/build/app/view/netcreate/components/NodeTable.jsx @@ -11,6 +11,14 @@ + ## 2018-12-07 Update + + Since we're not using tab navigation: + 1. The table isExpanded is now true by default. + 2. The "Show/Hide Table" button is hidden. + + Reset these to restore previous behavior. + \*\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ * //////////////////////////////////////*/ @@ -35,8 +43,9 @@ class NodeTable extends UNISYS.Component { this.state = { nodePrompts: this.AppState('TEMPLATE').nodePrompts, - nodes: [], - isExpanded: false, + nodes: [], + edgeCounts: {}, // {nodeID:count,...} + isExpanded: true, sortkey: 'label' }; @@ -65,12 +74,25 @@ class NodeTable extends UNISYS.Component { /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ Handle updated SELECTION -/*/ handleDataUpdate ( data ) { - if (data && data.nodes) { - this.setState({nodes: data.nodes}); - this.sortTable(); - } - } +/*/ +handleDataUpdate(data) { + if (data && data.nodes) { + this.countEdges(); + this.setState({nodes: data.nodes}); + this.sortTable(); + } +} +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ Build table of counts +/*/ +countEdges() { + let edgeCounts = this.state.edgeCounts; + this.AppState('D3DATA').edges.forEach( edge => { + edgeCounts[edge.source] = edgeCounts[edge.source]!==undefined ? edgeCounts[edge.source]+1 : 1; + edgeCounts[edge.target] = edgeCounts[edge.target]!== undefined ? edgeCounts[edge.target]+1 : 1; + }) + this.setState({ edgeCounts: edgeCounts }); +} /// UTILITIES ///////////////////////////////////////////////////////////////// @@ -89,6 +111,21 @@ class NodeTable extends UNISYS.Component { } /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ +/*/ sortByEdgeCount(nodes) { + if (nodes) { + let edgeCounts = this.state.edgeCounts; + return nodes.sort( (a, b) => { + let akey = edgeCounts[a.id] || 0, + bkey = edgeCounts[b.id] || 0; + // sort descending + if (akey > bkey) return -1; + if (akey < bkey) return 1; + return 0; + }); + } + } +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ /*/ sortByLabel (nodes) { if (nodes) { return nodes.sort( (a,b) => { @@ -121,6 +158,9 @@ class NodeTable extends UNISYS.Component { case 'id': this.sortByID(nodes); break; + case 'edgeCount': + this.sortByEdgeCount(nodes); + break; case 'type': this.sortByAttribute(nodes, 'Node_Type'); break; @@ -189,13 +229,25 @@ class NodeTable extends UNISYS.Component { /*/ /*/ render () { let { nodePrompts } = this.state; + let { tableHeight } = this.props; + let styles = `thead, tbody { display: block; } + thead { position: relative; } + tbody { overflow: auto; } + .nodetable td:nth-child(1), .nodetable th:nth-child(1) {width: 2em; min-width: 2em;} + .nodetable td:nth-child(2), .nodetable th:nth-child(2) {width: 2em; min-width: 2em;} + .nodetable td:nth-child(3), .nodetable th:nth-child(3) {width: 4em; min-width: 4em;} + .nodetable td:nth-child(4), .nodetable th:nth-child(4) {width: 12em; min-width: 12em;} + .nodetable td:nth-child(5), .nodetable th:nth-child(5) {width: 4em; min-width: 4em;} + .nodetable td:nth-child(6), .nodetable th:nth-child(6) {min-width: 2em; }` return ( -
-
@@ -204,6 +256,10 @@ class NodeTable extends UNISYS.Component { onClick={()=>this.setSortKey("id")} >ID + - + {this.state.nodes.map( (node,i) => @@ -234,6 +290,7 @@ class NodeTable extends UNISYS.Component { onClick={this.onButtonClick} >Edit + diff --git a/build/app/view/netcreate/components/d3-simplenetgraph.js b/build/app/view/netcreate/components/d3-simplenetgraph.js index 538d89a06..e48aba407 100644 --- a/build/app/view/netcreate/components/d3-simplenetgraph.js +++ b/build/app/view/netcreate/components/d3-simplenetgraph.js @@ -29,6 +29,8 @@ https://bl.ocks.org/mbostock/3808218 * Coderwall's zoom and pan method https://coderwall.com/p/psogia/simplest-way-to-add-zoom-pan-on-d3-js + * Vladyslav Babenko's zoom buttons example + https://jsfiddle.net/vbabenko/jcsqqu6j/9/ \*\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ * //////////////////////////////////////*/ @@ -46,8 +48,9 @@ var UDATA = null; /// PRIVATE VARS ////////////////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -let m_width = 1024; -let m_height = 1024; +let m_width = 800; +let m_height = 800; +let mouseoverNodeId = -1; // id of the node the mouse is currently over let m_forceProperties = { // values for all forces center: { x: 0.5, @@ -92,6 +95,7 @@ class D3NetGraph { this.rootElement = rootElement; this.d3svg = {}; + this.zoom = {}; this.zoomWrapper = {}; this.simulation = {}; this.data = {}; @@ -107,22 +111,42 @@ class D3NetGraph { /// D3 CODE /////////////////////////////////////////////////////////////////// /// note: this is all inside the class constructor function! /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + // Set up Zoom + this.zoom = d3.zoom().on("zoom", this._HandleZoom); + /*/ Create svg element which will contain our D3 DOM elements. Add default click handler so when clicking empty space, deselect all. NOTE: the svg element is actualy d3.selection object, not an svg obj. /*/ this.d3svg = d3.select(rootElement).append('svg') - .attr('width', "100%") // overrride m_width so SVG is wide - .attr('height',m_height) + .attr('id', 'netgraph') + .attr('width', "100%") // maximize width and height + .attr('height', "100%") // then set center dynamically below .on("click", ( e, event ) => { // Deselect UDATA.LocalCall('SOURCE_SELECT',{ nodeLabels: [] }); } ) - .call(d3.zoom().on("zoom", function () { - d3.select('.zoomer').attr("transform", d3.event.transform); - })); - this.zoomWrapper = this.d3svg.append('g').attr("class","zoomer"); + .on("mouseover", (d) => { + // Deselect edges + mouseoverNodeId = -1; + d3.selectAll('.edge') + .transition() + .duration(1500) + .style('stroke-width', this._UpdateLinkStrokeWidth) + d3.event.stopPropagation(); + }) + .call(this.zoom); + + this.zoomWrapper = this.d3svg.append('g').attr("class", "zoomer") + + // Set SVG size and centering. + let svg = document.getElementById('netgraph'); + m_width = svg.clientWidth; + m_height = svg.clientHeight; + this.simulation = d3.forceSimulation(); + /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /// END D3 CODE /////////////////////////////////////////////////////////////// @@ -133,6 +157,7 @@ class D3NetGraph { this._UpdateGraph = this._UpdateGraph.bind(this); this._UpdateForces = this._UpdateForces.bind(this); this._Tick = this._Tick.bind(this); + this._HandleZoom = this._HandleZoom.bind(this); this._Dragstarted = this._Dragstarted.bind(this); this._Dragged = this._Dragged.bind(this); this._Dragended = this._Dragended.bind(this); @@ -152,6 +177,23 @@ class D3NetGraph { this._UpdateGraph(); }); + UDATA.HandleMessage('ZOOM_RESET', (data) => { + if (DBG) console.log(PR, 'ZOOM_RESET got state D3DATA', data); + this.d3svg.transition() + .duration(200) + .call(this.zoom.scaleTo, 1); + }); + + UDATA.HandleMessage('ZOOM_IN', (data) => { + if (DBG) console.log(PR, 'ZOOM_IN got state D3DATA', data); + this._Transition(1.2); + }); + + UDATA.HandleMessage('ZOOM_OUT', (data) => { + if (DBG) console.log(PR, 'ZOOM_OUT got state D3DATA', data); + this._Transition(0.8); + }); + } @@ -243,7 +285,15 @@ class D3NetGraph { if (DBG) console.log('clicked on',d.label,d.id) UDATA.LocalCall('SOURCE_SELECT',{ nodeIDs: [d.id] }); d3.event.stopPropagation(); - }); + }) + .on("mouseover", (d) => { + mouseoverNodeId = d.id; + d3.selectAll('.edge') + .transition() + .duration(500) + .style('stroke-width', this._UpdateLinkStrokeWidth) + d3.event.stopPropagation(); + }) // enter node: also append 'circle' element of a calculated size elementG @@ -367,19 +417,25 @@ class D3NetGraph { // NOW TELL D3 HOW TO HANDLE NEW EDGE DATA // .insert will add an svg `line` before the objects classed `.node` + // .enter() sets the initial state of links as they are created linkElements.enter() .insert("line",".node") - .classed('edge', true) - .style('stroke-width', (d) => { return d.size**2 } ) // Use **2 to make size differences more noticeable - .on("click", (d) => { - if (DBG) console.log('clicked on',d.label,d.id) - this.edgeClickFn( d ) - }) - + .classed('edge', true) + .style('stroke', '#999') + // .style('stroke', 'rgba(0,0,0,0.1)') // don't use alpha unless we're prepared to handle layering -- reveals unmatching links + .style('stroke-width', this._UpdateLinkStrokeWidth ) + // old stroke setting + // .style('stroke-width', (d) => { return d.size**2 } ) // Use **2 to make size differences more noticeable + // Edge selection disabled. + // .on("click", (d) => { + // if (DBG) console.log('clicked on',d.label,d.id) + // this.edgeClickFn( d ) + // }) + + // .merge() updates the visuals whenever the data is updated. linkElements.merge(linkElements) .classed("selected", (d) => { return d.selected }) - // .style('stroke', 'rgba(0,0,0,0.1)') // don't use alpha unless we're prepared to handle layering -- reveals unmatching links - .style('stroke-width', (d) => { return d.size**2 } ) + .style('stroke-width', this._UpdateLinkStrokeWidth) linkElements.exit().remove() @@ -431,7 +487,7 @@ class D3NetGraph { gets drawn first -- the drawing order is determined by the ordering in the DOM. See the notes under link_update.enter() above for one technique for setting the ordering in the DOM. -/*/ _Tick() { +/*/ _Tick () { // Drawing the nodes: Update the location of each node group element // from the x, y fields of the corresponding node object. this.zoomWrapper.selectAll(".node") @@ -446,9 +502,40 @@ class D3NetGraph { .attr("y2", (d) => { return d.target.y; }) } +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ Sets the width of the links during update cycles + Used by linElements.enter() and linkElements.merge() + and mouseover events. +/*/ +_UpdateLinkStrokeWidth (edge) { + if (edge.selected || + (edge.source.id === mouseoverNodeId) || + (edge.target.id === mouseoverNodeId) || + (mouseoverNodeId === -1) + ) { + return edge.size ** 2; // Use **2 to make size differences more noticeable + } else { + return 0.175; // Barely visible if not selected + } +} /// UI EVENT HANDLERS ///////////////////////////////////////////////////////// +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ This primarily handles mousewheel zooms +/*/ +_HandleZoom() { + d3.select('.zoomer').attr("transform", d3.event.transform); +} +/*/ This handles zoom button zooms. +/*/ +_Transition(zoomLevel) { + this.d3svg.transition() + //.delay(100) + .duration(200) + .call(this.zoom.scaleBy, zoomLevel); +} + /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - /*/ /*/ _Dragstarted (d, self) { diff --git a/build/app/view/netcreate/nc-logic.js b/build/app/view/netcreate/nc-logic.js index fbb20d59b..8c909238d 100644 --- a/build/app/view/netcreate/nc-logic.js +++ b/build/app/view/netcreate/nc-logic.js @@ -228,7 +228,8 @@ const TARGET_COLOR = '#FF0000'; if (nodes.length>0) { let color = '#0000DD'; nodes.forEach( node => { - m_MarkNodeById(node.id,color); + m_MarkNodeById(node.id, color); + m_MarkSelectedEdges(edges, node); UNISYS.Log('select node',node.id,node.label); }); } else { @@ -361,6 +362,47 @@ const TARGET_COLOR = '#FF0000'; if (updatedNodes.length===0) D3DATA.nodes.push(node); UDATA.SetAppState('D3DATA',D3DATA); }); + /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - inside hook + /*/ NODE_DELETE is called by NodeSelector via datastore.js and Server.js when an node should be removed + /*/ + UDATA.HandleMessage('NODE_DELETE', function (data) { + let { nodeID, replacementNodeID } = data; + + // Remove or replace edges + let edgesToProcess; + if (replacementNodeID !== '') { + // replace + let replacementNode = m_FindNodeById(replacementNodeID); + edgesToProcess = D3DATA.edges.map((edge) => { + if (edge.source.id === nodeID) edge.source = replacementNode; + if (edge.target.id === nodeID) edge.target = replacementNode; + return edge; + }); + } else { + // delete nodes + edgesToProcess = D3DATA.edges.filter((edge) => { + let pass = false; + if ((edge.source.id !== nodeID) && (edge.target.id !== nodeID)) { + pass = true; + } + return pass; + }); + } + D3DATA.edges = edgesToProcess; + + // // Remove node + let updatedNodes = m_DeleteMatchingNodesByProp({ id: nodeID }); + D3DATA.nodes = updatedNodes; + UDATA.SetAppState('D3DATA', D3DATA); + + // Also update selection so nodes in EdgeEditor will update + UDATA.SetAppState('SELECTION', { + nodes: undefined, + edges: undefined + }); + // FIXME: need to also update AutoUpdate!!! + // FIXME: Need to also trigger resize! + }); /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - inside hook /*/ EDGE_UPDATE is called when the properties of an edge has changed NOTE: SOURCE_UPDATE can be invoked remotely by the server on a DATABASE @@ -484,6 +526,7 @@ const TARGET_COLOR = '#FF0000'; // register ONLY messages we want to make public UNISYS.RegisterMessagesPromise([ 'SOURCE_UPDATE', + `NODE_DELETE`, 'EDGE_UPDATE', 'EDGE_DELETE' ]) @@ -549,6 +592,20 @@ const TARGET_COLOR = '#FF0000'; /// NODE HELPERS ////////////////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ Return array of nodes that DON'T match del_me object keys/values +/*/ function m_DeleteMatchingNodesByProp(del_me = {}) { + let matches = D3DATA.nodes.filter((node) => { + let pass = false; + for (let key in del_me) { + if (del_me[key] !== node[key]) { + pass = true; break; + } + } + return pass; + }); + // return array of matches (can be empty array) + return matches; +} /*/ Return array of nodes that match the match_me object keys/values NOTE: make sure that strings are compared with strings, etc /*/ function m_FindMatchingNodeByProp( match_me={} ) { @@ -740,6 +797,23 @@ const TARGET_COLOR = '#FF0000'; m_SetMatchingNodesByLabel(searchString, matched, notmatched); UDATA.SetAppState('D3DATA',D3DATA); } +/// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/*/ Sets the 'selected' state of edges that are attached to the node +/*/ +function m_MarkSelectedEdges(edges, node) { + // Delesect all edges first + edges.forEach(edge => { edge.selected = false; }); + // Find connected edges + let id = node.id; + D3DATA.edges.forEach(edge => { + if ( (edge.source.id === id) || (edge.target.id === id) ) { + edge.selected = true; + } else { + edge.selected = false; + } + }) + UDATA.SetAppState('D3DATA', D3DATA); +} /// COMMAND LINE UTILITIES //////////////////////////////////////////////////// /// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -