├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------
/src/modules/editor/components/assets/contextual-delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/modules/editor/components/assets/node.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/modules/editor/components/assets/relation.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------