├── .gitignore ├── .nvmrc ├── README.md ├── layout-preview.jpg ├── package.json ├── public ├── add-node.png ├── delete-node.png ├── favicon.ico └── index.html ├── src ├── App.css ├── App.js ├── App.test.js ├── createStore.js ├── index.css ├── index.js ├── logo.svg ├── modules │ ├── core │ │ └── containers │ │ │ └── StageWithRedux.js │ ├── editor │ │ ├── __mock__ │ │ │ └── initialState.js │ │ ├── __tests__ │ │ │ └── store.js │ │ ├── components │ │ │ ├── ConnectorEdge.js │ │ │ ├── ContextualDelete.js │ │ │ ├── Edge.js │ │ │ ├── Node.js │ │ │ ├── NodeModel.js │ │ │ ├── NodeRelation.js │ │ │ └── assets │ │ │ │ ├── contextual-delete-active.svg │ │ │ │ ├── contextual-delete.svg │ │ │ │ ├── node.svg │ │ │ │ └── relation.svg │ │ ├── containers │ │ │ ├── ConnectorEdge.js │ │ │ ├── ContextualDelete.js │ │ │ └── Editor.js │ │ ├── lib │ │ │ ├── __tests__ │ │ │ │ └── middlePositions.js │ │ │ ├── helpers.js │ │ │ └── positions.js │ │ └── store.js │ └── sidebar │ │ ├── components │ │ ├── Card.js │ │ └── Sidebar.js │ │ ├── containers │ │ ├── CodeContainer.js │ │ ├── SidebarContainer.js │ │ └── TogglerContainer.js │ │ └── store.js └── withStore.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | .vscode 19 | 20 | 21 | .vercel 22 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.18.2 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Explorer 2 | 3 | GraphQL Explorer is an application that helps you define entities related to your business domains, relate them, and then generate GraphQL schemas that can be used to connect different parts of your software, such as client and server. 4 | 5 | ## Demo app 6 | 7 | Through the following URL you can find a demo app: https://graphql-explorer.sebas5384.vercel.app/ 8 | 9 | ## Pre-requirements 10 | 11 | - [git](https://git-scm.com/downloads) 12 | - [Node.js](https://nodejs.org/en/) LTS version 13 | 14 | ## Cloning the project: 15 | 16 | In the terminal of your computer, run `git clone https://github.com/sebas5384/graphql-explorer.git`. 17 | 18 | After cloning the repo, access the directory of it and then follow the installation instructions below. 19 | 20 | ## Installation 21 | 22 | Run `yarn` to install the application dependencies and dev dependencies. 23 | 24 | ## Initializing the local environment 25 | 26 | Run `yarn start` to start the application in dev mode on your own computer. 27 | 28 | If everything run as expected, the application should be automatically opened in your default browser in the following address: http://localhost:3000/, and you should see something like this: 29 | 30 | ![Layout draft](layout-preview.jpg) 31 | 32 | ## How it works 33 | 34 | TBD. 35 | 36 | ## Contributing 37 | 38 | You can contribute to this project in the following ways: 39 | 40 | - Finding and reporting bugs 41 | - Fixing bugs or implementing new features 42 | - Improving the documentation 43 | - Improving the code structure 44 | 45 | ### Steps to contribute 46 | 47 | - [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your computer; 48 | - Install the project and dev dependencies (run `yarn`); 49 | - Make the necessary changes and ensure that the tests are passing using `yarn test` (implement new tests if needed); 50 | - Send a [pull request](https://help.github.com/articles/about-pull-requests/) and I'll be happy to review it 🙌; 51 | - Wait for feedback or approval (this should not take too long). 52 | -------------------------------------------------------------------------------- /layout-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebas5384/graphql-explorer/0be8a36bd7cb557b67e6c5f4436c439ccf131c88/layout-preview.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-explorer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "babel-runtime": "^6.23.0", 7 | "copy-to-clipboard": "^3.3.1", 8 | "immutable": "^4.0.0-rc.12", 9 | "is-hotkey": "^0.1.1", 10 | "konva": "^7.0.2", 11 | "lscache": "^1.1.0", 12 | "ramda": "^0.27.0", 13 | "react": "^16.1.1", 14 | "react-dom": "^16.0.0", 15 | "react-konva": "^16.13.0-3", 16 | "react-motion": "^0.5.2", 17 | "react-motion-loop": "^2.0.0", 18 | "react-redux": "^7.2.0", 19 | "react-router": "^5.2.0", 20 | "react-router-dom": "^5.2.0", 21 | "react-use": "^15.3.2", 22 | "recompose": "^0.30.0", 23 | "redux": "^4.0.5", 24 | "redux-actions": "2.6.5", 25 | "redux-boot": "^1.1.2", 26 | "redux-promise": "^0.6.0", 27 | "strman": "^2.0.0", 28 | "styled-components": "^5.1.1" 29 | }, 30 | "devDependencies": { 31 | "react-scripts": "3.4.1", 32 | "redux-devtools-extension": "^2.13.0" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "react-scripts test --env=jsdom", 38 | "eject": "react-scripts eject" 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/add-node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebas5384/graphql-explorer/0be8a36bd7cb557b67e6c5f4436c439ccf131c88/public/add-node.png -------------------------------------------------------------------------------- /public/delete-node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebas5384/graphql-explorer/0be8a36bd7cb557b67e6c5f4436c439ccf131c88/public/delete-node.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebas5384/graphql-explorer/0be8a36bd7cb557b67e6c5f4436c439ccf131c88/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebas5384/graphql-explorer/0be8a36bd7cb557b67e6c5f4436c439ccf131c88/src/App.css -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { compose, withHandlers } from 'recompose' 3 | import { connect } from 'react-redux' 4 | import styled from 'styled-components' 5 | import isHotKey from 'is-hotkey' 6 | import { useMouse, useKey } from 'react-use'; 7 | 8 | import SidebarContainer from './modules/sidebar/containers/SidebarContainer' 9 | import Editor from './modules/editor/containers/Editor' 10 | import { 11 | addNode, resetConnector, resetSelectedNode, 12 | normalizePosWithStage, resetContextualDelete, deleteTargetedNodes 13 | } from './modules/editor/store' 14 | import { normalizeNodeName } from './modules/editor/lib/helpers' 15 | import { resetSidebar } from './modules/sidebar/store' 16 | 17 | const NodeAdd = styled.a` 18 | background: url('/add-node.png') no-repeat; 19 | width: 115px; 20 | height: 116px; 21 | display: block; 22 | cursor: pointer; 23 | text-indent: -999px; 24 | overflow: hidden; 25 | position: fixed; 26 | bottom: 10px; 27 | right: 30px; 28 | z-index: 1; 29 | ` 30 | 31 | const NodeDelete = styled.a` 32 | background: url('/delete-node.png') no-repeat; 33 | width: 115px; 34 | height: 116px; 35 | display: block; 36 | cursor: pointer; 37 | text-indent: -999px; 38 | overflow: hidden; 39 | position: fixed; 40 | bottom: 18px; 41 | right: 30px; 42 | z-index: 1; 43 | ` 44 | 45 | function useResetKey(dispatch) { 46 | useKey(isHotKey('esc'), () => { 47 | dispatch(resetConnector()) 48 | dispatch(resetSelectedNode()) 49 | dispatch(resetContextualDelete()) 50 | dispatch(resetSidebar()) 51 | }) 52 | } 53 | 54 | const App = ({ handleAddNode, showAdd, showDelete, handleDeleteNode, dispatch }) => { 55 | let ref = React.useRef(null) 56 | const {elX, elY} = useMouse(ref) 57 | const cursorPosition = { x: elX, y: elY } 58 | 59 | useResetKey(dispatch) 60 | 61 | return ( 62 |
63 | { showAdd && ADD NODE } 64 | { showDelete && DELETE NODE } 65 | 66 |
67 | 68 |
69 |
70 | ) 71 | } 72 | 73 | const handleAddNode = ({ dispatch, stage }) => event => { 74 | const name = prompt("What's the name of this new Type?") 75 | if (!name || name.length < 1) return 76 | const pos = normalizePosWithStage({ stage, pos: { x: 150, y: 150 } }) 77 | const newNode = normalizeNodeName({ name, pos, type: 'model' }) 78 | dispatch(addNode(newNode)) 79 | } 80 | 81 | const handleDeleteNode = ({ dispatch, stage }) => event => { 82 | dispatch(deleteTargetedNodes()) 83 | } 84 | 85 | const mapStateToProps = ({ nodes, stage, contextualDelete }) => ({ 86 | nodes, 87 | stage, 88 | showAdd: contextualDelete.targets.length === 0, 89 | showDelete: contextualDelete.targets.length > 0, 90 | }) 91 | 92 | export default compose( 93 | connect(mapStateToProps), 94 | withHandlers({ handleAddNode, handleDeleteNode }), 95 | )(App) 96 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | xit('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /src/createStore.js: -------------------------------------------------------------------------------- 1 | import boot from 'redux-boot' 2 | import { composeWithDevTools } from 'redux-devtools-extension' 3 | 4 | import editorModule from './modules/editor/store' 5 | import sidebarModule from './modules/sidebar/store' 6 | 7 | // DevTools 8 | const devToolsModule = { 9 | enhancer: composeWithDevTools() 10 | } 11 | 12 | const modules = [ 13 | editorModule, 14 | sidebarModule, 15 | devToolsModule, 16 | ] 17 | 18 | const createStore = () => boot({}, modules) 19 | 20 | export default createStore 21 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Arial; 5 | width: 100%; 6 | height: 100%; 7 | position: relative; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { compose } from 'recompose' 4 | import AppContainer from './App' 5 | import './index.css' 6 | 7 | import createStore from './createStore' 8 | import withStore from './withStore' 9 | 10 | const renderApp = store => { 11 | const App = compose( 12 | withStore(store) 13 | )(AppContainer) 14 | 15 | ReactDOM.render( 16 | , 17 | document.getElementById('root') 18 | ) 19 | } 20 | 21 | createStore() 22 | .then(({ store }) => renderApp(store)) 23 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/modules/core/containers/StageWithRedux.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ReactReduxContext, Provider } from 'react-redux' 3 | import { Stage } from 'react-konva' 4 | 5 | const StageWithRedux = ({ children, ...props }) => ( 6 | 7 | {({ store }) => ( 8 | 9 | {children} 10 | 11 | )} 12 | 13 | ) 14 | 15 | export default StageWithRedux 16 | -------------------------------------------------------------------------------- /src/modules/editor/__mock__/initialState.js: -------------------------------------------------------------------------------- 1 | const initialStateComplex = { 2 | nodes: [ 3 | { 4 | name: 'Vaccine', 5 | pos: { 6 | x: 1027, 7 | y: 313 8 | }, 9 | type: 'model', 10 | fields: [], 11 | selected: false 12 | }, 13 | { 14 | name: 'ShotOrder', 15 | pos: { 16 | x: 426, 17 | y: 605 18 | }, 19 | type: 'model', 20 | fields: [ 21 | { name: 'applicationPlaces', type: '[ApplicationPlace]' }, 22 | { name: 'company', type: '[Company]' } 23 | ], 24 | selected: false 25 | }, 26 | { 27 | name: 'Campaign', 28 | pos: { 29 | x: 844, 30 | y: 50 31 | }, 32 | type: 'model', 33 | fields: [ 34 | { name: 'vaccines', type: '[Vaccine]' } 35 | ], 36 | selected: false 37 | }, 38 | { 39 | name: 'unity', 40 | pos: { 41 | x: 323.5, 42 | y: 332 43 | }, 44 | type: 'relation', 45 | selected: false, 46 | cardinality: 'hasOne' 47 | }, 48 | { 49 | name: 'company', 50 | pos: { 51 | x: 240, 52 | y: 619 53 | }, 54 | type: 'relation', 55 | selected: false, 56 | cardinality: 'hasOne' 57 | }, 58 | { 59 | name: 'Company', 60 | pos: { 61 | x: 147, 62 | y: 453 63 | }, 64 | type: 'model', 65 | fields: [ 66 | { name: 'unity', type: 'Unity' } 67 | ], 68 | selected: false 69 | }, 70 | { 71 | name: 'Unity', 72 | pos: { 73 | x: 164, 74 | y: 243 75 | }, 76 | type: 'model', 77 | fields: [], 78 | selected: false 79 | }, 80 | { 81 | name: 'ApplicationPlace', 82 | pos: { 83 | x: 505, 84 | y: 204 85 | }, 86 | type: 'model', 87 | fields: [ 88 | { name: 'unity', type: 'Unity' }, 89 | { name: 'shotPackages', type: '[ShotPackage]' } 90 | ], 91 | selected: false 92 | }, 93 | { 94 | name: 'cdaLot', 95 | pos: { 96 | x: 841.5, 97 | y: 633 98 | }, 99 | type: 'relation', 100 | selected: false, 101 | cardinality: 'hasOne' 102 | }, 103 | { 104 | name: 'CdaLot', 105 | pos: { 106 | x: 989, 107 | y: 531 108 | }, 109 | type: 'model', 110 | fields: [ 111 | { name: 'vaccine', type: 'Vaccine' } 112 | ], 113 | selected: false 114 | }, 115 | { 116 | name: 'vaccine', 117 | pos: { 118 | x: 872, 119 | y: 401 120 | }, 121 | type: 'relation', 122 | selected: false, 123 | cardinality: 'hasOne' 124 | }, 125 | { 126 | name: 'Person', 127 | pos: { 128 | x: 701, 129 | y: 489 130 | }, 131 | type: 'model', 132 | fields: [ 133 | { name: 'applicationPlace', type: 'ApplicationPlace' }, 134 | { name: 'vaccine', type: 'Vaccine' }, 135 | { name: 'shotOrder', type: 'ShotOrder' }, 136 | { name: 'cdaLot', type: 'CdaLot' }, 137 | ], 138 | selected: false 139 | }, 140 | { 141 | name: 'shotOrder', 142 | pos: { 143 | x: 603.5, 144 | y: 641.5 145 | }, 146 | type: 'relation', 147 | selected: false, 148 | cardinality: 'hasOne' 149 | }, 150 | { 151 | name: 'applicationPlace', 152 | pos: { 153 | x: 568.5, 154 | y: 387.5 155 | }, 156 | type: 'relation', 157 | selected: false 158 | }, 159 | { 160 | name: 'ShotPackage', 161 | pos: { 162 | x: 691, 163 | y: 268 164 | }, 165 | type: 'model', 166 | fields: [ 167 | { name: 'vaccine', type: 'Vaccine' } 168 | ], 169 | selected: false 170 | }, 171 | { 172 | name: 'shotPackages', 173 | pos: { 174 | x: 649.5, 175 | y: 86.5 176 | }, 177 | type: 'relation', 178 | selected: false, 179 | cardinality: 'hasMany' 180 | }, 181 | { 182 | name: 'vaccines', 183 | pos: { 184 | x: 881.5, 185 | y: 232 186 | }, 187 | type: 'relation', 188 | selected: false, 189 | cardinality: 'hasMany' 190 | }, 191 | { 192 | name: 'applicationPlaces', 193 | pos: { 194 | x: 430.5, 195 | y: 444 196 | }, 197 | type: 'relation', 198 | selected: false, 199 | cardinality: 'hasMany' 200 | } 201 | ], 202 | edges: [ 203 | { 204 | type: 'hasMany', 205 | nodes: [ 206 | 'Campaign', 207 | 'vaccines' 208 | ], 209 | points: [ 210 | 905, 211 | 111, 212 | 926.5, 213 | 277 214 | ] 215 | }, 216 | { 217 | type: 'hasMany', 218 | nodes: [ 219 | 'vaccines', 220 | 'Vaccine' 221 | ], 222 | points: [ 223 | 926.5, 224 | 277, 225 | 1088, 226 | 374 227 | ] 228 | }, 229 | { 230 | type: 'hasOne', 231 | nodes: [ 232 | 'Company', 233 | 'unity' 234 | ], 235 | points: [ 236 | 208, 237 | 514, 238 | 368.5, 239 | 377 240 | ] 241 | }, 242 | { 243 | type: 'hasOne', 244 | nodes: [ 245 | 'unity', 246 | 'Unity' 247 | ], 248 | points: [ 249 | 368.5, 250 | 377, 251 | 225, 252 | 304 253 | ] 254 | }, 255 | { 256 | type: 'hasOne', 257 | nodes: [ 258 | 'ApplicationPlace', 259 | 'unity' 260 | ], 261 | points: [ 262 | 566, 263 | 265, 264 | 368.5, 265 | 377 266 | ] 267 | }, 268 | { 269 | type: 'hasOne', 270 | nodes: [ 271 | 'ShotOrder', 272 | 'company' 273 | ], 274 | points: [ 275 | 487, 276 | 666, 277 | 285, 278 | 664 279 | ] 280 | }, 281 | { 282 | type: 'hasOne', 283 | nodes: [ 284 | 'company', 285 | 'Company' 286 | ], 287 | points: [ 288 | 285, 289 | 664, 290 | 208, 291 | 514 292 | ] 293 | }, 294 | { 295 | type: 'hasOne', 296 | nodes: [ 297 | 'Person', 298 | 'vaccine' 299 | ], 300 | points: [ 301 | 762, 302 | 550, 303 | 917, 304 | 446 305 | ] 306 | }, 307 | { 308 | type: 'hasOne', 309 | nodes: [ 310 | 'vaccine', 311 | 'Vaccine' 312 | ], 313 | points: [ 314 | 917, 315 | 446, 316 | 1088, 317 | 374 318 | ] 319 | }, 320 | { 321 | type: 'hasOne', 322 | nodes: [ 323 | 'Person', 324 | 'applicationPlace' 325 | ], 326 | points: [ 327 | 762, 328 | 550, 329 | 613.5, 330 | 432.5 331 | ] 332 | }, 333 | { 334 | type: 'hasOne', 335 | nodes: [ 336 | 'applicationPlace', 337 | 'ApplicationPlace' 338 | ], 339 | points: [ 340 | 613.5, 341 | 432.5, 342 | 566, 343 | 265 344 | ] 345 | }, 346 | { 347 | type: 'hasOne', 348 | nodes: [ 349 | 'Person', 350 | 'cdaLot' 351 | ], 352 | points: [ 353 | 762, 354 | 550, 355 | 886.5, 356 | 678 357 | ] 358 | }, 359 | { 360 | type: 'hasOne', 361 | nodes: [ 362 | 'cdaLot', 363 | 'CdaLot' 364 | ], 365 | points: [ 366 | 886.5, 367 | 678, 368 | 1050, 369 | 592 370 | ] 371 | }, 372 | { 373 | type: 'hasOne', 374 | nodes: [ 375 | 'CdaLot', 376 | 'vaccine' 377 | ], 378 | points: [ 379 | 1050, 380 | 592, 381 | 917, 382 | 446 383 | ] 384 | }, 385 | { 386 | type: 'hasMany', 387 | nodes: [ 388 | 'ShotOrder', 389 | 'applicationPlaces' 390 | ], 391 | points: [ 392 | 487, 393 | 666, 394 | 475.5, 395 | 489 396 | ] 397 | }, 398 | { 399 | type: 'hasMany', 400 | nodes: [ 401 | 'applicationPlaces', 402 | 'ApplicationPlace' 403 | ], 404 | points: [ 405 | 475.5, 406 | 489, 407 | 566, 408 | 265 409 | ] 410 | }, 411 | { 412 | type: 'hasMany', 413 | nodes: [ 414 | 'ApplicationPlace', 415 | 'shotPackages' 416 | ], 417 | points: [ 418 | 566, 419 | 265, 420 | 694.5, 421 | 131.5 422 | ] 423 | }, 424 | { 425 | type: 'hasMany', 426 | nodes: [ 427 | 'shotPackages', 428 | 'ShotPackage' 429 | ], 430 | points: [ 431 | 694.5, 432 | 131.5, 433 | 752, 434 | 329 435 | ] 436 | }, 437 | { 438 | type: 'hasOne', 439 | nodes: [ 440 | 'ShotPackage', 441 | 'vaccine' 442 | ], 443 | points: [ 444 | 752, 445 | 329, 446 | 917, 447 | 446 448 | ] 449 | }, 450 | { 451 | type: 'hasOne', 452 | nodes: [ 453 | 'Person', 454 | 'shotOrder' 455 | ], 456 | points: [ 457 | 762, 458 | 550, 459 | 648.5, 460 | 686.5 461 | ] 462 | }, 463 | { 464 | type: 'hasOne', 465 | nodes: [ 466 | 'shotOrder', 467 | 'ShotOrder' 468 | ], 469 | points: [ 470 | 648.5, 471 | 686.5, 472 | 487, 473 | 666 474 | ] 475 | } 476 | ], 477 | stage: { 478 | pos: { 479 | x: 0, 480 | y: 0 481 | } 482 | }, 483 | connector: { 484 | isConnecting: false, 485 | connectedTo: null 486 | } 487 | } 488 | 489 | const initialStateSimple = { 490 | nodes: [], 491 | edges: [], 492 | stage: { pos: { x: -1, y: 0 } } 493 | } 494 | 495 | export default initialStateComplex; 496 | -------------------------------------------------------------------------------- /src/modules/editor/__tests__/store.js: -------------------------------------------------------------------------------- 1 | import { getModelFromRelation } from '../store' 2 | 3 | describe('[Editor] Store', () => { 4 | it('should get the model node from a relation node', () => { 5 | const edgesMock = [ 6 | { nodes: ['ModelA', 'modelB'] }, 7 | { nodes: ['modelB', 'ModelB'] }, 8 | ] 9 | 10 | const result = getModelFromRelation({ edges: edgesMock, nodeB: 'modelB' }) 11 | expect(result).toBe('ModelB') 12 | }) 13 | }) -------------------------------------------------------------------------------- /src/modules/editor/components/ConnectorEdge.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Group, Line } from 'react-konva' 3 | 4 | const ConnectorEdge = ({ points, active = false }) => { 5 | const color = active ? '#FF68C5' : '#9B9B9B' 6 | 7 | const lineDefaultProps = { 8 | fill: color, 9 | stroke: color, 10 | strokeWidth: 5, 11 | dashEnabled: true, 12 | dash: [8, 3] 13 | } 14 | 15 | return ( 16 | 17 | 21 | 22 | ) 23 | } 24 | 25 | export default ConnectorEdge 26 | -------------------------------------------------------------------------------- /src/modules/editor/components/ContextualDelete.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image } from 'react-konva' 3 | import { compose, withState, lifecycle } from 'recompose' 4 | 5 | import activeDeleteImg from './assets/contextual-delete-active.svg' 6 | import defaultDeleteImg from './assets/contextual-delete.svg' 7 | 8 | const imgSize = { width: 64, height: 49 } 9 | 10 | const ContextualDelete = ({ pos, image, handleOnClick, handleOnMouseLeave }) => { 11 | return ( 12 | 20 | ) 21 | } 22 | 23 | function componentWillMount() { 24 | const { setImage } = this.props 25 | 26 | const defaultImage = new window.Image() 27 | defaultImage.src = defaultDeleteImg 28 | defaultImage.onload = () => setImage(defaultImage) 29 | 30 | const activeImage = new window.Image() 31 | activeImage.src = activeDeleteImg 32 | activeImage.onload = () => { 33 | setTimeout(() => setImage(activeImage), 100) 34 | } 35 | } 36 | 37 | export default compose( 38 | withState('image', 'setImage', null), 39 | lifecycle({ componentWillMount }) 40 | )(ContextualDelete) -------------------------------------------------------------------------------- /src/modules/editor/components/Edge.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Group, Line, Arc } from 'react-konva' 3 | 4 | const calculateRotation = diffs => (Math.atan2(...diffs) * -1) * 180 / Math.PI 5 | 6 | // Positions must be [[x1, x2], [y1, y2]]. 7 | const rotationFromPositions = positions => { 8 | const diffs = positions.map(([posA, posB]) => posA - posB) 9 | const rotation = calculateRotation(diffs) 10 | 11 | // @TODO find a way to not need this tweek. 12 | const adjustAngle = value => value - 95 13 | 14 | return adjustAngle(rotation) 15 | } 16 | 17 | const hasOneRadiusByType = type => { 18 | switch (type) { 19 | case 'model': 20 | return { innerRadius: 65, outerRadius: 68 } 21 | case 'relation': 22 | return { innerRadius: 48, outerRadius: 51 } 23 | default: 24 | } 25 | } 26 | 27 | const hasManyRadiusByType = type => { 28 | switch (type) { 29 | case 'model': 30 | return { innerRadius: 70, outerRadius: 73 } 31 | case 'relation': 32 | return { innerRadius: 53, outerRadius: 56 } 33 | default: 34 | } 35 | } 36 | 37 | const Edge = ({ points, name, type, active = true, connectedTo }) => { 38 | const [posAX, posAY, posBX, posBY] = points 39 | const rotationB = rotationFromPositions([[posBX, posAX], [posBY, posAY]]) 40 | 41 | const color = active ? '#FF68C5' : '#9B9B9B' 42 | 43 | const lineDefaultProps = { 44 | fill: color, 45 | stroke: color, 46 | strokeWidth: 4, 47 | // dashEnabled: true, 48 | // dash: [8, 3] 49 | } 50 | 51 | const hasOneRadius = hasOneRadiusByType(connectedTo.type) 52 | const arcHasOneProps = { 53 | ...hasOneRadius, 54 | fill: color, 55 | angle: 11, 56 | } 57 | 58 | const hasManyRadius = hasManyRadiusByType(connectedTo.type) 59 | const arcManyProps = { 60 | ...hasManyRadius, 61 | fill: color, 62 | angle: 10, 63 | } 64 | 65 | return ( 66 | 67 | {/* */} 68 | 69 | { /many/i.test(type) && 70 | 71 | } 72 | 76 | 77 | ) 78 | } 79 | 80 | export default Edge 81 | -------------------------------------------------------------------------------- /src/modules/editor/components/Node.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import NodeModel from './NodeModel' 4 | import NodeRelation from './NodeRelation' 5 | 6 | const Node = props => { 7 | const { 8 | connector: { connectedTo }, name, selected, type 9 | } = props 10 | const isConnected = (connectedTo === name) || (connectedTo && selected) 11 | 12 | switch (type) { 13 | case 'model': 14 | return 15 | case 'relation': 16 | return 17 | default: 18 | } 19 | } 20 | 21 | export default Node 22 | -------------------------------------------------------------------------------- /src/modules/editor/components/NodeModel.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image, Group, Circle, Text } from 'react-konva' 3 | import { compose, lifecycle, withState } from 'recompose' 4 | import { spring, Motion } from 'react-motion' 5 | import { connect } from 'react-redux' 6 | 7 | import nodeImg from './assets/node.svg' 8 | import nodeRelationImg from './assets/relation.svg' 9 | 10 | const size = { width: 124, height: 124 } 11 | 12 | const strokeCircleProps = { 13 | ...size, 14 | fill: 'white', 15 | stroke: 'white', 16 | strokeWidth: 5.7, 17 | x: 61, 18 | y: 61, 19 | } 20 | 21 | const selectedCircleProps = { 22 | radius: 83, 23 | stroke: '#0099FF', 24 | strokeWidth: 2, 25 | x: 61, 26 | y: 61, 27 | dash: [6, 4], 28 | dashEnabled: true 29 | } 30 | 31 | const TextLabel = ({ text }) => ( 32 | 44 | ) 45 | 46 | const strokeColor = ({ active, deleting }) => { 47 | if (deleting) return '#FF0000' 48 | if (active) return '#FF68C5' 49 | return '#0099FF' 50 | } 51 | 52 | const AnimatedCircle = ({ active = false, deleting = false }) => ( 53 | 57 | { ({ rotation }) => ( 58 | 63 | ) } 64 | 65 | ) 66 | 67 | const Node = ({ 68 | draggable, 69 | dragBoundFunc, 70 | selected, 71 | image, 72 | onClick, 73 | name, 74 | setImage, 75 | connector: { isConnecting, connectedTo }, 76 | isConnected, 77 | isDeleting, 78 | ...props 79 | }) => { 80 | return ( 81 | 82 | 83 | { (!isConnecting && selected && !isDeleting) && 84 | 85 | } 86 | { ((isConnecting && selected) || isConnected || isDeleting) && 87 | 88 | } 89 | 95 | 96 | 97 | 98 | 99 | ) 100 | } 101 | 102 | function componentWillMount () { 103 | const { setImage, type } = this.props 104 | const image = new window.Image() 105 | image.src = type === 'relation' 106 | ? nodeRelationImg 107 | : nodeImg 108 | image.onload = () => setImage(image) 109 | } 110 | 111 | const mapStateToProps = ({ contextualDelete: { targets, isActive } }, { name }) => ({ 112 | isDeleting: targets.some(targetName => targetName === name) 113 | }) 114 | 115 | export default compose( 116 | connect(mapStateToProps), 117 | withState('image', 'setImage', null), 118 | lifecycle({ componentWillMount }) 119 | )(Node) 120 | -------------------------------------------------------------------------------- /src/modules/editor/components/NodeRelation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image, Group, Circle, Text } from 'react-konva' 3 | import { compose, lifecycle, withState } from 'recompose' 4 | import { spring, Motion } from 'react-motion' 5 | import { connect } from 'react-redux' 6 | 7 | import nodeImg from './assets/relation.svg' 8 | 9 | const size = { width: 90, height: 90 } 10 | 11 | const strokeCircleProps = { 12 | ...size, 13 | fill: 'white', 14 | stroke: 'white', 15 | strokeWidth: 5.7, 16 | x: 45, 17 | y: 45, 18 | } 19 | 20 | const selectedCircleProps = { 21 | radius: 60, 22 | stroke: '#0099FF', 23 | strokeWidth: 2, 24 | x: 45, 25 | y: 45, 26 | dash: [6, 4], 27 | dashEnabled: true 28 | } 29 | 30 | const TextLabel = ({ text }) => ( 31 | 43 | ) 44 | 45 | const strokeColor = ({ active, deleting }) => { 46 | if (deleting) return '#FF0000' 47 | if (active) return '#FF68C5' 48 | return '#0099FF' 49 | } 50 | 51 | const AnimatedCircle = ({ active = false, deleting = false }) => ( 52 | 56 | { ({ rotation }) => ( 57 | 62 | ) } 63 | 64 | ) 65 | 66 | const Node = ({ 67 | draggable, 68 | dragBoundFunc, 69 | selected, 70 | image, 71 | onClick, 72 | name, 73 | setImage, 74 | connector: { isConnecting, connectedTo }, 75 | isConnected, 76 | isDeleting, 77 | ...props 78 | }) => { 79 | return ( 80 | 81 | 82 | { (!isConnecting && selected && !isDeleting) && 83 | 84 | } 85 | { ((isConnecting && selected) || isConnected || isDeleting) && 86 | 87 | } 88 | 94 | 95 | 96 | 97 | 98 | ) 99 | } 100 | 101 | function componentWillMount () { 102 | const { setImage } = this.props 103 | const image = new window.Image() 104 | image.src = nodeImg 105 | image.onload = () => setImage(image) 106 | } 107 | 108 | const mapStateToProps = ({ contextualDelete: { targets, isActive } }, { name }) => ({ 109 | isDeleting: targets.some(targetName => targetName === name) 110 | }) 111 | 112 | export default compose( 113 | connect(mapStateToProps), 114 | withState('image', 'setImage', null), 115 | lifecycle({ componentWillMount }) 116 | )(Node) 117 | -------------------------------------------------------------------------------- /src/modules/editor/components/assets/contextual-delete-active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Contextual menu two Copy 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/modules/editor/components/assets/contextual-delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Contextual menu two 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/modules/editor/components/assets/node.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Desktop HD 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/modules/editor/components/assets/relation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Node Role Copy 5 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/modules/editor/containers/ConnectorEdge.js: -------------------------------------------------------------------------------- 1 | import { compose, mapProps } from 'recompose' 2 | import { connect } from 'react-redux' 3 | 4 | import ConnectorEdge from '../components/ConnectorEdge' 5 | import { 6 | getSelectedNode, 7 | getConnectedNode, 8 | normalizePosWithStage, 9 | centralizePositions 10 | } from '../store' 11 | 12 | const mapStateToProps = ({ nodes, stage, connector: { connectedTo } }) => ({ 13 | stage, 14 | node: getSelectedNode(nodes), 15 | active: connectedTo !== null, 16 | connectedNode: getConnectedNode({ nodes, connectedTo }) 17 | }) 18 | 19 | const connectWithPoints = ({ 20 | node, 21 | stage, 22 | cursorPosition, 23 | active, 24 | connectedNode 25 | }) => { 26 | const { x: posAX, y: posAY } = centralizePositions(node) 27 | 28 | const destinationPosition = active 29 | ? centralizePositions(connectedNode) 30 | : normalizePosWithStage({ stage, pos: cursorPosition }) 31 | 32 | const { x: posBX, y: posBY } = destinationPosition 33 | return { points: [posAX, posAY, posBX, posBY], active } 34 | } 35 | 36 | const ConnectorEdgeContainer = compose( 37 | connect(mapStateToProps), 38 | mapProps(connectWithPoints) 39 | )(ConnectorEdge) 40 | 41 | export default ConnectorEdgeContainer 42 | -------------------------------------------------------------------------------- /src/modules/editor/containers/ContextualDelete.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from "react"; 2 | import { useSelector, useDispatch } from "react-redux"; 3 | 4 | import ContextualDelete from "../components/ContextualDelete"; 5 | import { 6 | updateContextualDelete, 7 | getSelectedNode, 8 | markNodeReadyToDelete, 9 | } from "../store"; 10 | 11 | const ContextualDeleteContainer = (props) => { 12 | const state = useSelector(mapStateToProps); 13 | const dispatch = useDispatch(); 14 | 15 | const handleOnMouseLeave = useCallback(() => { 16 | dispatch(updateContextualDelete({ isActive: false })); 17 | }, [dispatch]); 18 | 19 | const { name, type } = state.selectedNode || {}; 20 | const handleOnClick = useCallback(() => { 21 | dispatch(updateContextualDelete({ isActive: false })); 22 | // Avoid recursive when is a relation node. 23 | dispatch(markNodeReadyToDelete({ name, recursive: type !== "relation" })); 24 | }, [name, type, dispatch]); 25 | 26 | const { isActive } = state; 27 | 28 | useEffect(() => { 29 | isActive 30 | ? (document.body.style.cursor = "pointer") 31 | : (document.body.style.cursor = "default"); 32 | 33 | return () => (document.body.style.cursor = "default"); 34 | }, [isActive]); 35 | 36 | if (!state.isActive) return null; 37 | 38 | return ( 39 | 45 | ); 46 | }; 47 | 48 | const mapStateToProps = ({ contextualDelete, nodes }) => { 49 | return { 50 | ...contextualDelete, 51 | selectedNode: getSelectedNode(nodes), 52 | }; 53 | }; 54 | 55 | export default ContextualDeleteContainer; 56 | -------------------------------------------------------------------------------- /src/modules/editor/containers/Editor.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { compose, withHandlers } from 'recompose' 4 | import { Layer } from 'react-konva' 5 | import { useWindowSize } from 'react-use' 6 | 7 | import Stage from '../../core/containers/StageWithRedux' 8 | import Node from '../components/Node' 9 | import Edge from '../components/Edge' 10 | import ContextualDelete from '../containers/ContextualDelete' 11 | import ConnectorEdge from '../containers/ConnectorEdge' 12 | 13 | import { 14 | updateStage, 15 | updateNode, 16 | selectNode, 17 | getSelectedNode, 18 | getConnectedNode, 19 | updateConnector, 20 | resetConnector, 21 | normalizePosWithStage, 22 | addRelation, 23 | updateContextualDelete, 24 | resetContextualDelete 25 | } from '../store' 26 | 27 | const isRightClick = ({ button = 0 }) => button === 2 28 | const isLeftClick = ({ button = 0 }) => button === 0 29 | 30 | // @TODO Convert all these handlers to use withHandlers. 31 | const handleDragStage = ({ dispatch, stage }) => function (pos, event) { 32 | if (event && isRightClick(event)) return stage.pos 33 | dispatch(updateStage({ pos })) 34 | return pos 35 | } 36 | 37 | const handleOnNodeDrag = ({ dispatch, stage }) => ({ name }) => function (pos) { 38 | const normalizedPos = normalizePosWithStage({ stage, pos }) 39 | dispatch(updateNode({ name, pos: normalizedPos })) 40 | return pos 41 | } 42 | 43 | const handleOnDoubleClick = ({ selectedNode, dispatch }) => ({ name }) => event => { 44 | // Only Model nodes can create connection. 45 | if (selectedNode.type !== 'model') return 46 | dispatch(updateConnector({ isConnecting: true })) 47 | } 48 | 49 | const handleMouseOver = ({ name, selectedNode, connector: { isConnecting }, dispatch }) => event => { 50 | if (isConnecting) { 51 | dispatch(updateConnector({ connectedTo: name })) 52 | } 53 | } 54 | 55 | const handleMouseOut = ({ name, connector: { isConnecting, connectedTo }, dispatch }) => event => { 56 | if (isConnecting && name === connectedTo) { 57 | dispatch(updateConnector({ connectedTo: null })) 58 | } 59 | } 60 | 61 | const edgeIsActive = ({ edgeNodes, selectedNode = {} }) => edgeNodes 62 | .some(nodeName => ( 63 | !selectedNode.hasOwnProperty('name') || nodeName === selectedNode.name 64 | )) 65 | 66 | const preventContextmenuEvent = ({ evt }) => evt.preventDefault() 67 | 68 | const Editor = ({ 69 | stage, nodes, edges, selectedNode, dispatch, cursorPosition, connector, 70 | onNodeClick: handleOnNodeClick, onStageClick: handleOnStageClick, handleOnStageContextmenu, 71 | handleOnNodeDrag, handleOnDoubleClick, 72 | ...rest 73 | }) => { 74 | const { width, height } = useWindowSize(); 75 | const style = { 76 | position: 'fixed' 77 | } 78 | 79 | return ( 80 | 90 | 91 | { edges.map(({ points, type, nodes: edgeNodes, connectedTo }) => ( 92 | 99 | ))} 100 | 101 | { connector.isConnecting && 102 | 103 | } 104 | 105 | { nodes.map(({ name, pos, selected, type }) => ( 106 | 120 | )) } 121 | 122 | 123 | 124 | 125 | ) 126 | } 127 | 128 | const onNodeClick = ({ dispatch, stage, edges, selectedNode, connector, nodes }) => ({ name }) => event => { 129 | // Connecting Nodes. 130 | const { isConnecting, connectedTo } = connector 131 | if (isConnecting && connectedTo) { 132 | 133 | const connectedToNode = getConnectedNode({ nodes, connectedTo }) 134 | 135 | // Connection from: Model to Model node. 136 | if ([selectedNode, connectedToNode].every(({ type }) => type === 'model')) { 137 | const name = prompt("What's the name of the Field?") 138 | const type = prompt("Type of the relation?\n(hasMany, hasOne)") 139 | 140 | if (name && type) { 141 | dispatch( 142 | addRelation({ name, nodeA: selectedNode.name, nodeB: connectedTo, type }) 143 | ) 144 | } 145 | } 146 | 147 | // Connection from: Model to Relation node. 148 | if (selectedNode.type === 'model' && connectedToNode.type === 'relation') { 149 | dispatch( 150 | addRelation({ 151 | name, 152 | nodeA: selectedNode.name, 153 | nodeB: connectedTo, 154 | type: connectedToNode.cardinality, 155 | isModelToRelation: true 156 | }) 157 | ) 158 | } 159 | 160 | return dispatch(resetConnector()) 161 | } 162 | 163 | // Selecting a Node. 164 | dispatch(selectNode({ name })) 165 | 166 | // Reset contextual delete menu when selecting a node. 167 | if (isLeftClick(event.evt)) { 168 | dispatch(resetContextualDelete()) 169 | } 170 | // Contextual menu (right click) of Node. 171 | if (isRightClick(event.evt)) { 172 | dispatch(updateContextualDelete({ 173 | isActive: true, 174 | pos: normalizePosWithStage({ 175 | stage, pos: { x: event.evt.x - 10, y: event.evt.y - 10 } 176 | }) 177 | })) 178 | } 179 | } 180 | 181 | const onStageClick = ({ 182 | dispatch, connector: { isConnecting, connectedTo } 183 | }) => event => (isConnecting && !connectedTo) && dispatch(resetConnector()) 184 | 185 | const edgesWithDestinationNode = ({ nodes, edges }) => edges.map(edge => ({ 186 | ...edge, connectedTo: nodes.find(({ name }) => edge.nodes[1] === name) 187 | })) 188 | 189 | const mapStateToProps = ({ stage, nodes, edges, connector, contextualDelete }) => ({ 190 | stage, 191 | edges: edgesWithDestinationNode({ edges, nodes }), 192 | nodes, 193 | selectedNode: getSelectedNode(nodes), 194 | connector 195 | }) 196 | 197 | export default compose( 198 | connect(mapStateToProps), 199 | withHandlers({ 200 | onNodeClick, 201 | onStageClick, 202 | handleOnNodeDrag, 203 | handleOnDoubleClick, 204 | }), 205 | )(Editor) 206 | -------------------------------------------------------------------------------- /src/modules/editor/lib/__tests__/middlePositions.js: -------------------------------------------------------------------------------- 1 | import { middlePositions } from '../positions' 2 | 3 | it('should extract middle positions from x and y vectors', () => { 4 | const nodeA = { 5 | name: 'NodeA', 6 | pos: { 7 | x: 50, 8 | y: 40 9 | } 10 | } 11 | 12 | const nodeB = { 13 | name: 'NodeB', 14 | pos: { 15 | x: 10, 16 | y: 50 17 | } 18 | } 19 | 20 | const positions = middlePositions([nodeB, nodeA]) 21 | 22 | expect(positions).toMatchObject({ x: 30, y: 45 }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/modules/editor/lib/helpers.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda' 2 | import { toCamelCase, toStudlyCaps } from 'strman' 3 | 4 | export const isNodeModel = R.propSatisfies(R.equals('model'), 'type') 5 | 6 | export const normalizeNodeName = R.ifElse( 7 | isNodeModel, 8 | R.over(R.lensProp('name'), toStudlyCaps), 9 | R.over(R.lensProp('name'), toCamelCase) 10 | ) -------------------------------------------------------------------------------- /src/modules/editor/lib/positions.js: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda' 2 | 3 | const lowestPos = prop => R.pipe( 4 | R.sortWith([R.ascend(R.prop(prop))]), 5 | R.head, 6 | R.prop(prop) 7 | ) 8 | 9 | const biggestPos = prop => R.pipe( 10 | R.sortWith([R.descend(R.prop(prop))]), 11 | R.head, 12 | R.prop(prop) 13 | ) 14 | 15 | export const middlePositions = nodes => { 16 | const positions = R.reduce( 17 | (carry, item) => carry.concat(item.pos), 18 | [], 19 | nodes 20 | ) 21 | 22 | const yA = biggestPos("y")(positions); 23 | const yB = lowestPos("y")(positions); 24 | 25 | const xA = biggestPos("x")(positions); 26 | const xB = lowestPos("x")(positions); 27 | 28 | const x = ((xA - xB) / 2) + xB 29 | const y = ((yA - yB) / 2) + yB 30 | 31 | return { x, y } 32 | } 33 | 34 | const randomMargin = pos => { 35 | const range = R.range(120, 130) 36 | const margins = R.concat(R.map(R.negate, range), range) 37 | return pos + margins[Math.floor(Math.random() * margins.length)] 38 | } 39 | 40 | export const aroundPositions = R.pipe( 41 | R.head, 42 | R.prop('pos'), 43 | R.over(R.lensProp('y'), randomMargin), 44 | R.over(R.lensProp('x'), randomMargin), 45 | ) -------------------------------------------------------------------------------- /src/modules/editor/store.js: -------------------------------------------------------------------------------- 1 | import { BOOT } from 'redux-boot' 2 | import { createAction } from 'redux-actions' 3 | import lscache from 'lscache' 4 | import * as R from 'ramda' 5 | 6 | import initialStateMock from './__mock__/initialState' 7 | 8 | import { middlePositions, aroundPositions } from './lib/positions' 9 | 10 | /* 11 | * Actions. 12 | */ 13 | export const updateStage = createAction('editor/stage/UPDATE') 14 | export const updateNode = createAction('editor/node/UPDATE') 15 | export const updateNodeFields = createAction('editor/field/UPDATE') 16 | export const updateEdge = createAction('editor/edge/UPDATE') 17 | export const selectNode = createAction('editor/node/SELECT') 18 | export const deleteNode = createAction('editor/node/DELETE') 19 | export const resetSelectedNode = createAction('editor/node/SELECT_RESET') 20 | export const addNode = createAction('editor/node/ADD') 21 | export const addRelation = createAction('editor/relation/ADD') 22 | export const addEdge = createAction('editor/edge/ADD') 23 | export const addField = createAction('editor/field/ADD') 24 | export const deleteField = createAction('editor/field/DELETE') 25 | export const updateConnector = createAction('editor/connector/UPDATE') 26 | export const resetConnector = createAction('editor/connector/RESET') 27 | export const updateContextualDelete = createAction('editor/contextualDelete/UPDATE') 28 | export const resetContextualDelete = createAction('editor/contextualDelete/RESET') 29 | export const markNodeReadyToDelete = createAction('editor/contextualDelete/node/MARK_TO_DELETE') 30 | export const deleteTargetedNodes = createAction('editor/contextualDelete/node/DELETE_TARGETS') 31 | 32 | /* 33 | * Helper to normalize positions by stage position / offset. 34 | */ 35 | 36 | export const normalizePosWithStage = ({ stage, pos }) => ({ 37 | x: pos.x - stage.pos.x, 38 | y: pos.y - stage.pos.y, 39 | }) 40 | 41 | /* 42 | * Helpers to centralize line points and positions by node type. 43 | */ 44 | 45 | export const centralizeLinePoints = node => Object.values(node.pos) 46 | .map(pos => pos + centerPositionsByType(node.type)) 47 | 48 | export const centralizePositions = node => 49 | R.map(pos => pos + centerPositionsByType(node.type), node.pos) 50 | 51 | // @TODO this positions offsets should be centralized. 52 | const centerPositionsByType = type => { 53 | switch (type) { 54 | case 'model': 55 | return 61 56 | case 'relation': 57 | return 45 58 | default: 59 | } 60 | } 61 | 62 | /* 63 | * Selectors. 64 | */ 65 | 66 | export const getSelectedNode = (nodes = []) => nodes.find(node => node.selected) 67 | export const getConnectedNode = ({ nodes = [], connectedTo }) => nodes 68 | .find(({ name }) => name === connectedTo) 69 | export const getModelFromRelation = ({ edges, nodeB }) => { 70 | const edge = edges.find(({ nodes }) => nodes[0] === nodeB) 71 | if (!edge) return 72 | return edge.nodes[1] 73 | } 74 | export const typeToModel = type => type.replace(/[^A-Za-z_]*/g, '') 75 | 76 | /* 77 | * Initial State. 78 | */ 79 | const getInitialState = state => ({ 80 | ...state, 81 | nodes: lscache.get('nodes') || initialStateMock.nodes, 82 | edges: lscache.get('edges') || initialStateMock.edges, 83 | stage: { pos: { x: 0, y: 0 } }, 84 | connector: { 85 | isConnecting: false, 86 | connectedTo: null, 87 | }, 88 | contextualDelete: { 89 | isActive: false, 90 | targets: [], 91 | pos: { x: 0, y: 0 } 92 | } 93 | }) 94 | 95 | /* 96 | * Reducers. 97 | */ 98 | export const reducer = { 99 | [BOOT]: (state, action) => getInitialState(state), 100 | 101 | [addNode]: (state, { payload: newNode }) => { 102 | const defaultNode = { fields: [] } 103 | const normalizedNode = { 104 | ...defaultNode, 105 | ...newNode, 106 | pos: newNode.pos 107 | } 108 | return { ...state, nodes: state.nodes.concat(normalizedNode)} 109 | }, 110 | 111 | [addEdge]: (state, { payload: { nodeA, nodeB, type } }) => { 112 | const newEdge = { 113 | type, 114 | nodes: [nodeA, nodeB], 115 | points: [], 116 | } 117 | return { ...state, edges: state.edges.concat(newEdge)} 118 | }, 119 | 120 | [addField]: (state, { payload: { nodeName, name, type } }) => { 121 | // Find the source Node. 122 | const sourceNode = state.nodes.find(node => node.name === nodeName) 123 | 124 | // Avoid duplicate. 125 | if (sourceNode.fields.find(field => field.name === name)) return state 126 | 127 | // Add new field 128 | const updatedNodes = state.nodes.map(node => node.name === nodeName 129 | ? ({ 130 | ...node, 131 | fields: node.fields.concat({ name, type }), 132 | }) 133 | : node 134 | ) 135 | 136 | return { ...state, nodes: updatedNodes } 137 | }, 138 | 139 | [deleteField]: (state, { payload: fieldName }) => { 140 | const updatedNodes = state.nodes 141 | .map(node => { 142 | if (node.type !== 'model') return node 143 | return ({ 144 | ...node, 145 | fields: node.fields.filter( 146 | field => field.name !== fieldName 147 | ) 148 | }) 149 | }) 150 | 151 | return { ...state, nodes: updatedNodes } 152 | }, 153 | 154 | [updateNode]: (state, { payload }) => { 155 | const currentNode = state.nodes 156 | .find(type => type.name === payload.name) 157 | 158 | const updatedNodePos = payload.hasOwnProperty('pos') 159 | ? payload.pos 160 | : currentNode.pos 161 | 162 | const updatedNode = { 163 | ...currentNode, 164 | ...payload, 165 | pos: updatedNodePos 166 | } 167 | 168 | const updatedNodes = state.nodes 169 | .filter(node => node.name !== payload.name) 170 | .concat(updatedNode) 171 | 172 | return { ...state, nodes: updatedNodes } 173 | }, 174 | 175 | [deleteNode]: (state, { payload }) => ({ 176 | ...state, 177 | nodes: state.nodes.filter(({ name }) => name !== payload), 178 | edges: state.edges.filter( 179 | ({ nodes }) => nodes.every(name => name !== payload) 180 | ), 181 | }), 182 | 183 | [updateEdge]: (state, { payload: { node } }) => { 184 | const updatedEdges = state.edges 185 | .map(edge => { 186 | if (!edge.nodes.some(name => name === node.name)) return edge 187 | const points = edge.nodes 188 | .map(name => state.nodes.find(node => node.name === name)) 189 | .map(node => centralizeLinePoints(node)) 190 | .reduce((flat, pos) => flat.concat(pos), []) 191 | return { ...edge, points } 192 | }) 193 | 194 | return { ...state, edges: updatedEdges } 195 | }, 196 | 197 | [updateNodeFields]: (state, { payload: { node: editingNode, fields } }) => { 198 | const updatedNodes = state.nodes.map(node => { 199 | if (node.name !== editingNode.name) return node 200 | return { ...node, fields } 201 | }) 202 | return { ...state, nodes: updatedNodes } 203 | }, 204 | 205 | [selectNode]: (state, { payload }) => { 206 | const updatedNodes = state.nodes 207 | .map(node => ({ ...node, selected: (node.name === payload.name) })) 208 | return { ...state, nodes: updatedNodes } 209 | }, 210 | 211 | [resetSelectedNode]: (state, { payload }) => { 212 | const updatedNodes = state.nodes 213 | .map(node => ({ ...node, selected: false })) 214 | return { ...state, nodes: updatedNodes } 215 | }, 216 | 217 | [updateStage]: (state, { payload }) => { 218 | return { ...state, stage: payload } 219 | }, 220 | 221 | [updateConnector]: (state, { payload }) => { 222 | return { ...state, connector: { ...state.connector, ...payload } } 223 | }, 224 | 225 | [resetConnector]: (state, action) => { 226 | return { ...state, connector: getInitialState(state).connector } 227 | }, 228 | 229 | [resetContextualDelete]: (state, action) => { 230 | return { ...state, contextualDelete: getInitialState(state).contextualDelete } 231 | }, 232 | 233 | [updateContextualDelete]: (state, { payload }) => { 234 | return { 235 | ...state, 236 | contextualDelete: { ...state.contextualDelete, ...payload } 237 | } 238 | }, 239 | 240 | [markNodeReadyToDelete]: (state, { payload }) => ({ 241 | ...state, 242 | contextualDelete: { 243 | ...state.contextualDelete, 244 | targets: state.contextualDelete.targets.concat(payload.name) 245 | } 246 | }) 247 | } 248 | 249 | /* 250 | * Middlewares. 251 | */ 252 | export const middleware = { 253 | [updateNode]: ({ dispatch }) => next => action => { 254 | const result = next(action) 255 | 256 | // Update the edges. 257 | const { payload: { name, pos } } = action 258 | dispatch(updateEdge({ node: { name, pos } })) 259 | return result 260 | }, 261 | [addEdge]: ({ dispatch, getState }) => next => action => { 262 | const result = next(action) 263 | const { edges, nodes } = getState() 264 | const { nodeA, nodeB } = action.payload 265 | 266 | // Set pointers for the new edge. 267 | const newEdge = edges.find( 268 | edge => edge.nodes[0] === nodeA && edge.nodes[1] === nodeB 269 | ) 270 | 271 | const nodesToUpdate = nodes 272 | .filter(node => newEdge.nodes.some(name => name === node.name)) 273 | 274 | nodesToUpdate.forEach(node => { 275 | dispatch(updateEdge({ node })) 276 | }) 277 | 278 | return result 279 | }, 280 | [addRelation]: ({ dispatch, getState }) => next => action => { 281 | const result = next(action) 282 | const { nodes, edges } = getState() 283 | const { name, nodeA, nodeB, type, isModelToRelation } = action.payload 284 | 285 | // Translate type to schema lang type. 286 | const fieldType = ({ nodeB, type }) => { 287 | const node = isModelToRelation 288 | ? getModelFromRelation({ edges, nodeB }) 289 | : nodeB 290 | return type === 'hasMany' ? `[${node}]` : node 291 | } 292 | 293 | dispatch(addField({ 294 | name, 295 | nodeName: nodeA, 296 | type: fieldType({ nodeB, type }) 297 | })) 298 | 299 | // Connection already exist, so it just needs a new edge. 300 | const existentNode = nodes.find(node => node.name === name) 301 | if (existentNode) { 302 | dispatch(addEdge({ nodeA, nodeB, type })) 303 | 304 | return result 305 | } 306 | 307 | const selectedNodes = nodes.filter( 308 | node => [nodeA, nodeB].some(name => node.name === name) 309 | ) 310 | 311 | const positions = selectedNodes.length === 1 312 | ? aroundPositions(selectedNodes) 313 | : middlePositions(selectedNodes) 314 | 315 | // Create node for field. 316 | dispatch(addNode({ 317 | name, 318 | pos: positions, 319 | type: 'relation', 320 | selected: false, 321 | cardinality: type, 322 | })) 323 | 324 | // Create edge from nodeA to fieldNode. 325 | dispatch(addEdge({ nodeA, nodeB: name, type })) 326 | 327 | // Create edge from fieldNode to nodeB. 328 | dispatch(addEdge({ nodeA: name, nodeB, type })) 329 | 330 | return result 331 | }, 332 | 333 | [updateNodeFields]: store => next => action => { 334 | const { node, fields } = action.payload 335 | const result = next(action) 336 | const { nodes } = store.getState() 337 | 338 | // Get difference of fields. 339 | const diffFields = R.difference(node.fields, fields) 340 | 341 | // Get fields that are Model nodes. 342 | const modelFields = nodes.filter( 343 | ({ name }) => diffFields.some( 344 | field => typeToModel(field.type) === name 345 | ) 346 | ) 347 | if (!modelFields.length) return result 348 | 349 | // Remove relation or sync. 350 | console.log(modelFields, 'REMOVE RELATIONS') 351 | 352 | return result 353 | }, 354 | 355 | [deleteTargetedNodes]: ({ getState, dispatch }) => next => action => { 356 | const result = next(action) 357 | 358 | getState().contextualDelete.targets 359 | .forEach(name => { 360 | // Delete targeted nodes. 361 | next(deleteNode(name)) 362 | 363 | // Delete fields inside model nodes. 364 | next(deleteField(name)) 365 | }) 366 | 367 | // Reset contextual delete. 368 | next(resetContextualDelete()) 369 | 370 | return result 371 | }, 372 | 373 | [markNodeReadyToDelete]: ({ getState, dispatch }) => next => action => { 374 | const result = next(action) 375 | const { recursive = true, name: nodeName } = action.payload 376 | if (!recursive) return result 377 | 378 | const state = getState() 379 | 380 | const nodeToDelete = state.nodes.find(({ name }) => name === nodeName) 381 | // Nodes which are fields referencing 382 | // to the node being deleted. 383 | state.edges 384 | .filter(({ nodes }) => nodes[1] === nodeName) 385 | .map(({ nodes }) => nodes[0]) 386 | .forEach(name => dispatch(markNodeReadyToDelete({ name, recursive: false }))) 387 | 388 | // Nodes which are fields of the node being deleted. 389 | state.nodes 390 | .filter(({ name }) => nodeToDelete.fields.some( 391 | ({ name: fieldName }) => fieldName === name 392 | )) 393 | // Remove relation nodes (field) that is also used by another model node, 394 | // to avoid removing a field which is being used by other types/mode nodes. 395 | .filter(({ name: nodeToBeMarked }) => 396 | state.nodes 397 | .filter(({ name }) => name !== nodeName) 398 | .every(({ fields = [] }) => 399 | fields.every(({ name }) => name !== nodeToBeMarked)) 400 | ) 401 | .forEach( 402 | ({ name }) => dispatch(markNodeReadyToDelete({ name, recursive: false })) 403 | ) 404 | 405 | return result 406 | } 407 | } 408 | 409 | const enhancer = createStore => (reducer, initialState, enhancer) => { 410 | const store = createStore(reducer, initialState, enhancer) 411 | 412 | // Updates local storage. 413 | store.subscribe(() => { 414 | const state = store.getState() 415 | 416 | lscache.set('nodes', state.nodes) 417 | lscache.set('edges', state.edges) 418 | }) 419 | 420 | return store 421 | } 422 | 423 | 424 | export default { reducer, middleware, enhancer } 425 | -------------------------------------------------------------------------------- /src/modules/sidebar/components/Card.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | const Card = styled.section` 4 | border-radius: 4px; 5 | min-width: 19em; 6 | max-width: 60em; 7 | display: flex; 8 | margin-bottom: 0.7em; 9 | flex-direction: column; 10 | overflow: hidden; 11 | `; 12 | 13 | export default Card -------------------------------------------------------------------------------- /src/modules/sidebar/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const SideBar = styled.section` 4 | display: block; 5 | width: auto; 6 | height: 83%; 7 | border-radius: 0 4px 0; 8 | position: fixed; 9 | right: 0; 10 | top: 0; 11 | z-index: 1; 12 | background: rgba(38, 25, 58, 0.97); 13 | ${({ isOpen }) => `box-shadow: 0px 8px 14px 0px rgba(0, 0, 0, 0.42);`}; 14 | `; 15 | 16 | export default SideBar -------------------------------------------------------------------------------- /src/modules/sidebar/containers/CodeContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useRef } from 'react' 2 | import Card from '../components/Card' 3 | import styled from 'styled-components' 4 | import { useSelector } from 'react-redux' 5 | import { sort, ascend, prop } from 'ramda' 6 | import copy from 'copy-to-clipboard'; 7 | 8 | const Code = styled(Card)` 9 | padding: 1em 1em 20em; 10 | cursor: text; 11 | ` 12 | 13 | const FieldNode = styled.span` 14 | line-height: 1.8em; 15 | margin: 0; 16 | font-size: 1em; 17 | font-family: Fira Code, "Consolas", "Inconsolata", "Droid Sans Mono", "Monaco", monospace; 18 | color: #ddd; 19 | font-weight: 100; 20 | ` 21 | 22 | const FieldName = styled.span` 23 | color: #ec63c5; 24 | font-weight: 500; 25 | ` 26 | 27 | const TypeName = styled.span` 28 | font-style: italic; 29 | ` 30 | 31 | const CopyLabel = styled.p` 32 | color: white; 33 | padding: 1em; 34 | font-size: 12px; 35 | background-color: rgba(38,25,58,0.97); 36 | ` 37 | 38 | const CodeContainer = () => { 39 | const { nodes } = useSelector(mapStateToProps) 40 | const codeRef = useRef() 41 | 42 | const copyToClipboard = () => copy(codeRef.current.textContent) 43 | 44 | return ( 45 | 46 | Click in the code to copy to clipboard 47 | 48 | 49 | { nodes.map((node, key) => ( 50 | 51 | 52 | {'type '} 53 | {node.name} 54 | {' {'} 55 | 56 | { node.fields.map(({ name, type }, key) => ( 57 |   { name + ':'} {type} 58 | ))} 59 | {'}'} 60 |
61 |
62 | ))} 63 |
64 |
65 | ) 66 | } 67 | 68 | const mapStateToProps = ({ nodes }) => { 69 | const modelNodes = nodes.filter(({ type }) => type === 'model') 70 | const sortedNodes = sort(ascend(prop('name')), modelNodes) 71 | return { nodes: sortedNodes }; 72 | } 73 | 74 | export default CodeContainer -------------------------------------------------------------------------------- /src/modules/sidebar/containers/SidebarContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Sidebar from '../components/Sidebar' 3 | import CodeContainer from './CodeContainer' 4 | import TogglerContainer from './TogglerContainer' 5 | import styled from 'styled-components' 6 | import { compose } from 'recompose' 7 | import { connect } from 'react-redux' 8 | 9 | const Wrapper = styled.section` 10 | overflow: auto; 11 | padding: 0.9em 1.3em; 12 | height: 100%; 13 | box-sizing: border-box; 14 | transition: opacity 0.3s, padding-right 0.3s, padding-left 0.3s ease-out; 15 | ${({ isOpen }) => !isOpen && ` 16 | width: 0; 17 | padding-right: 0; 18 | padding-left: 0; 19 | opacity: 0; 20 | `}; 21 | `; 22 | 23 | const SidebarContainer = ({ isOpen }) => ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | 32 | const mapStateToProps = ({ sidebar: { isOpen } }) => ({ 33 | isOpen 34 | }) 35 | 36 | export default compose( 37 | connect(mapStateToProps), 38 | )(SidebarContainer) -------------------------------------------------------------------------------- /src/modules/sidebar/containers/TogglerContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { compose, withHandlers } from 'recompose' 4 | import { connect } from 'react-redux' 5 | 6 | import { toggleSidebar } from '../store' 7 | 8 | const Toggler = styled.button` 9 | position: absolute; 10 | width: 50px; 11 | height: 50px; 12 | display: block; 13 | background: white; 14 | top: 35px; 15 | left: -50px; 16 | color: #333; 17 | border-radius: 6px 0 0 6px; 18 | border: 4px solid rgba(38, 25, 58, 0.97); 19 | border-right: 0; 20 | font-weight: 700; 21 | font-size: 1em; 22 | cursor: pointer; 23 | opacity: 0.6; 24 | box-shadow: 0px 9px 10px 0px rgba(0, 0, 0, 0.42); 25 | &:focus { 26 | outline: 0; 27 | } 28 | &:hover { 29 | opacity: 1; 30 | 31 | ${({ isOpen }) => !isOpen && ` 32 | color: #ec63c5; 33 | border-color: #ec63c5; 34 | width: 55px; 35 | left: -55px; 36 | transition: opacity 0.2s, width 0.2s ease-in, left 0.2s ease-in; 37 | `}; 38 | } 39 | ${({ isOpen }) => isOpen && ` 40 | opacity: 0.6; 41 | `}; 42 | `; 43 | 44 | const TogglerContainer = props => ( 45 | { props.isOpen ? 'X' : '{ }' } 46 | ) 47 | 48 | const mapStateToProps = ({ sidebar: { isOpen } }) => ({ 49 | isOpen 50 | }) 51 | 52 | export default compose( 53 | connect(mapStateToProps), 54 | withHandlers({ 55 | onClick: ({ dispatch }) => () => { 56 | dispatch(toggleSidebar()) 57 | } 58 | }) 59 | )(TogglerContainer) -------------------------------------------------------------------------------- /src/modules/sidebar/store.js: -------------------------------------------------------------------------------- 1 | import { BOOT } from 'redux-boot' 2 | import { createAction } from 'redux-actions' 3 | import { over, lensPath } from 'ramda' 4 | 5 | export const toggleSidebar = createAction('sidebar/TOGGLE') 6 | export const resetSidebar = createAction('sidebar/RESET') 7 | 8 | const getInitialState = state => ({ 9 | ...state, 10 | sidebar: { 11 | isOpen: false 12 | } 13 | }) 14 | 15 | const reducer = { 16 | [BOOT]: (state, action) => getInitialState(state), 17 | [resetSidebar]: (state, action) => getInitialState(state), 18 | [toggleSidebar]: (state, action) => over( 19 | lensPath(['sidebar', 'isOpen']), 20 | value => !value 21 | )(state) 22 | } 23 | 24 | export default { reducer } -------------------------------------------------------------------------------- /src/withStore.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | 4 | const withStore = store => Component => props => ( 5 | 6 | 7 | 8 | ) 9 | export default withStore 10 | --------------------------------------------------------------------------------