├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── build.yml ├── .prettierignore ├── src ├── state │ ├── graph │ │ ├── index.ts │ │ ├── graph-actions.ts │ │ ├── types.ts │ │ ├── hooks.ts │ │ └── reducer.ts │ ├── state-utils.ts │ ├── index.ts │ └── store.ts ├── icons │ ├── base.less │ ├── chevron-up.tsx │ ├── chevron-down.tsx │ ├── chevron-right.tsx │ ├── folder.tsx │ ├── x.tsx │ ├── eye.tsx │ ├── search.tsx │ ├── lock.tsx │ ├── chevrons-up.tsx │ ├── unlock.tsx │ ├── chevrons-down.tsx │ ├── corner-up-right.tsx │ ├── home.tsx │ ├── chevrons-right.tsx │ ├── info.tsx │ ├── trash.tsx │ ├── relay.tsx │ ├── maximize-2.tsx │ ├── minimize-2.tsx │ ├── alert-triangle.tsx │ ├── eye-off.tsx │ ├── upload-cloud.tsx │ ├── graph.tsx │ ├── index.tsx │ └── base.tsx ├── less.d.ts ├── persistence │ ├── index.ts │ ├── subscribed-state-repository.ts │ ├── types.ts │ └── local-storage-state-repository.ts ├── umd │ ├── umd.less │ ├── example.html │ └── umd.tsx ├── bootstrap │ ├── index.html │ ├── index.tsx │ ├── index.less │ └── shim.tsx ├── components │ ├── icon-button.less │ ├── button.less │ ├── button.tsx │ ├── pill-button.less │ ├── icon-button.tsx │ ├── pill-button.tsx │ └── browser-open-file-dialog.tsx ├── index.ts ├── simulation │ ├── index.ts │ ├── context.ts │ ├── edge-subscriber.ts │ ├── node-subscriber.ts │ └── simulation.tsx ├── tools │ ├── utils.ts │ ├── snapshot │ │ ├── create-snapshot.ts │ │ ├── factory-snapshot.tests.ts │ │ └── example.graphql │ ├── types.ts │ ├── document-cache.ts │ ├── plugins │ │ └── connections.ts │ └── factory.tests.ts ├── utils.ts ├── graph │ ├── index.less │ ├── toolbar.less │ ├── domain-object.less │ ├── domain-edge.less │ ├── node-picker.less │ ├── toolbar.tsx │ ├── index.tsx │ ├── node-picker.tsx │ ├── spotlight.less │ ├── domain-object.tsx │ ├── radial-menu.tsx │ ├── domain-edge.tsx │ └── spotlight.tsx ├── search │ ├── index.ts │ ├── use-debounced-callback.ts │ ├── types.ts │ ├── search-box.less │ ├── fast-fuzzy.ts │ └── search-box.tsx ├── registry.ts ├── svg-button │ ├── index.less │ └── index.tsx ├── data-provider.less ├── test-utils.ts ├── svg-canvas │ ├── use-resize.ts │ ├── use-zoom.ts │ ├── use-drag.ts │ └── index.tsx ├── domain-graph.tsx ├── colors.less └── data-provider.tsx ├── images └── hero.png ├── .prettierrc ├── jest.config.json ├── tsconfig.build.json ├── tsconfig.eslint.json ├── tsconfig.json ├── webpack.prod.js ├── webpack.common.js ├── webpack.dev.js ├── webpack.umd.js ├── LICENSE ├── .eslintrc.json ├── .gitignore ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [skonves] 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /coverage 3 | /umd 4 | -------------------------------------------------------------------------------- /src/state/graph/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | -------------------------------------------------------------------------------- /src/icons/base.less: -------------------------------------------------------------------------------- 1 | .c-icon { 2 | stroke: @color-icon-default; 3 | } 4 | -------------------------------------------------------------------------------- /images/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domain-graph/domain-graph/HEAD/images/hero.png -------------------------------------------------------------------------------- /src/less.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.less' { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './local-storage-state-repository'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /src/umd/umd.less: -------------------------------------------------------------------------------- 1 | .c-svg-canvas { 2 | background: @color-window-background; 3 | color: @color-window-text-default; 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "node", 4 | "collectCoverage": true, 5 | "testMatch": ["**/__tests__/**/*.ts?(x)", "**/?(*.)+(spec|test|tests).ts?(x)"] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "**/*.test?.*", 5 | "**/bootstrap/**/*", 6 | "**/snapshot/**/*", 7 | "**/umd/*" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/icons/chevron-up.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronUp = icon( 5 | 'ChevronUp', 6 | , 7 | ); 8 | -------------------------------------------------------------------------------- /src/icons/chevron-down.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronDown = icon( 5 | 'ChevronDown', 6 | , 7 | ); 8 | -------------------------------------------------------------------------------- /src/icons/chevron-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronRight = icon( 5 | 'ChevronRight', 6 | , 7 | ); 8 | -------------------------------------------------------------------------------- /src/bootstrap/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | }, 5 | "extends": "./tsconfig.json", 6 | "include": ["./*.js", "./**/*.ts", "./**/*.tsx"], 7 | "exclude": ["./node_modules", "./coverage", "./lib"] 8 | } 9 | -------------------------------------------------------------------------------- /src/icons/folder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Folder = icon( 5 | 'Folder', 6 | , 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/icon-button.less: -------------------------------------------------------------------------------- 1 | .c-icon-button { 2 | display: inline-flex; 3 | cursor: pointer; 4 | outline: none; 5 | border: none; 6 | background: none; 7 | padding: 8px 8px; 8 | 9 | &:focus { 10 | outline: none; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/icons/x.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const X = icon( 5 | 'X', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/eye.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Eye = icon( 5 | 'Eye', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Search = icon( 5 | 'Search', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain-graph'; 2 | export * from './data-provider'; 3 | export * from './components/browser-open-file-dialog'; 4 | export * from './persistence/local-storage-state-repository'; 5 | export * from './persistence/types'; 6 | export * as Icons from './icons'; 7 | -------------------------------------------------------------------------------- /src/icons/lock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Lock = icon( 5 | 'Lock', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/chevrons-up.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronsUp = icon( 5 | 'ChevronsUp', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/unlock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Unlock = icon( 5 | 'Unlock', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/simulation/index.ts: -------------------------------------------------------------------------------- 1 | export { Simulation } from './simulation'; 2 | export { 3 | EdgeEvent, 4 | EdgeSubscriber, 5 | useEdgeSubscriber, 6 | } from './edge-subscriber'; 7 | export { 8 | NodeEvent, 9 | NodeSubscriber, 10 | useNodeSubscriber, 11 | } from './node-subscriber'; 12 | -------------------------------------------------------------------------------- /src/icons/chevrons-down.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronsDown = icon( 5 | 'ChevronsDown', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/corner-up-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const CornerUpRight = icon( 5 | 'CornerUpRight', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Home = icon( 5 | 'Home', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/chevrons-right.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const ChevronsRight = icon( 5 | 'ChevronsRight', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/tools/utils.ts: -------------------------------------------------------------------------------- 1 | export function compact(obj: T): T { 2 | const compacted: T = {} as any; 3 | 4 | for (const key of Object.keys(obj)) { 5 | if (typeof obj[key] !== 'undefined') { 6 | compacted[key] = obj[key]; 7 | } 8 | } 9 | 10 | return compacted; 11 | } 12 | -------------------------------------------------------------------------------- /src/icons/info.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Info = icon( 5 | 'Info', 6 | <> 7 | 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/icons/trash.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Trash = icon( 5 | 'trash', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/relay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Relay = icon( 5 | 'Relay', 6 | <> 7 | 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/bootstrap/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { Shim } from './shim'; 6 | 7 | console.log('hello from react'); 8 | 9 | render(, document.getElementById('app-root')); 10 | 11 | // Hot Module Replacement API 12 | if (module['hot']) { 13 | module['hot'].accept(); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type KeysOfType = { 2 | [Key in keyof Base]: Base[Key] extends Condition ? Key : never; 3 | }[keyof Base]; 4 | 5 | export type OmitByType = Omit< 6 | Base, 7 | KeysOfType 8 | >; 9 | 10 | export type PickByType = Pick< 11 | Base, 12 | KeysOfType 13 | >; 14 | -------------------------------------------------------------------------------- /src/icons/maximize-2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Maximize2 = icon( 5 | 'Maximize2', 6 | <> 7 | 8 | 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/icons/minimize-2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Minimize2 = icon( 5 | 'Minimize2', 6 | <> 7 | 8 | 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/icons/alert-triangle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const AlertTriangle = icon( 5 | 'AlertTriangle', 6 | <> 7 | 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/icons/eye-off.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const EyeOff = icon( 5 | 'EyeOff', 6 | <> 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/icons/upload-cloud.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const UploadCloud = icon( 5 | 'UploadCloud', 6 | <> 7 | 8 | 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/icons/graph.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { icon } from './base'; 3 | 4 | export const Graph = icon( 5 | 'Graph', 6 | <> 7 | 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/simulation/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { EdgeSubscriber } from './edge-subscriber'; 3 | import { NodeSubscriber } from './node-subscriber'; 4 | 5 | const noop = () => { 6 | // NO-OP 7 | }; 8 | 9 | export const context = createContext<{ 10 | nodeSubscriber: NodeSubscriber; 11 | edgeSubscriber: EdgeSubscriber; 12 | }>({ 13 | nodeSubscriber: noop, 14 | edgeSubscriber: noop, 15 | }); 16 | -------------------------------------------------------------------------------- /src/umd/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/graph/index.less: -------------------------------------------------------------------------------- 1 | .c-svg-canvas { 2 | user-select: none; 3 | position: absolute; 4 | top: 0; 5 | bottom: 0; 6 | right: 0; 7 | left: 0; 8 | cursor: grab; 9 | &.dragging { 10 | cursor: grabbing; 11 | } 12 | svg { 13 | display: block !important; 14 | border: none !important; 15 | margin: 0 !important; 16 | } 17 | } 18 | 19 | ul.hidden-nodes { 20 | position: absolute; 21 | 22 | top: 0; 23 | right: 0; 24 | font-family: sans-serif; 25 | } 26 | -------------------------------------------------------------------------------- /src/bootstrap/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | background: @color-window-background; 3 | color: @color-window-text-default; 4 | margin: 0; 5 | } 6 | 7 | .c-uploader { 8 | font-family: sans-serif; 9 | text-align: center; 10 | 11 | width: 500px; 12 | 13 | margin: 150px auto; 14 | padding: 50px; 15 | 16 | border-radius: 100px; 17 | 18 | border: 8px dashed @color-drop-target-border; 19 | 20 | &.drop-ready { 21 | background-color: @color-drop-target-ready-background; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/state/state-utils.ts: -------------------------------------------------------------------------------- 1 | export type Reducer = ( 2 | state: State, 3 | action: Action, 4 | ) => State; 5 | 6 | export function chainReducers( 7 | ...reducers: Reducer[] 8 | ): Reducer { 9 | return (state, action) => { 10 | let nextState = state; 11 | for (const reducer of reducers) { 12 | nextState = reducer(nextState, action); 13 | } 14 | 15 | return nextState; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 18 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: npm ci 16 | - run: npm t 17 | - run: npm publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6", "dom"], 4 | "module": "commonjs", 5 | "noImplicitReturns": true, 6 | "outDir": "lib", 7 | "rootDir": "src", 8 | "sourceMap": true, 9 | "target": "es5", 10 | "declaration": true, 11 | "jsx": "react", 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "strictNullChecks": true, 15 | "downlevelIteration": true 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.tsx"], 18 | "exclude": [] 19 | } 20 | -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { ApplicationState } from '../state'; 3 | import { FastFuzzySearchEngine } from './fast-fuzzy'; 4 | 5 | const engine = new FastFuzzySearchEngine(); 6 | 7 | export function useIndexBuilder() { 8 | return useCallback((state: ApplicationState) => { 9 | engine.index(state.graph); 10 | }, []); 11 | } 12 | 13 | export function useSearch() { 14 | return useCallback( 15 | (query: string) => (query ? engine.search(query) : []), 16 | [], 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/button.less: -------------------------------------------------------------------------------- 1 | .c-button { 2 | cursor: pointer; 3 | border-radius: 3px; 4 | display: inline-flex; 5 | flex-direction: row; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 8px 16px; 9 | border: 2px solid @color-button-border; 10 | background-color: @color-button-background; 11 | 12 | font-size: 16px; 13 | 14 | &:focus, 15 | &:hover { 16 | outline: none; 17 | background-color: @color-button-hover-background; 18 | } 19 | 20 | .c-icon { 21 | margin-right: 8px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/registry.ts: -------------------------------------------------------------------------------- 1 | // import { Registry as GenericRegistry } from 'ts-registry'; 2 | // import { StateRepository } from './graph-state'; 3 | // import { StateService } from './graph-state/state-service'; 4 | 5 | // export type Registry = GenericRegistry<{ 6 | // 'state-repository': StateRepository; 7 | // 'state-service': StateService; 8 | // }>; 9 | 10 | // export function getRegistry(): Registry { 11 | // if (!global['window']['__REGISTRY__']) { 12 | // global['window']['__REGISTRY__'] = new GenericRegistry(); 13 | // } 14 | // return global['window']['__REGISTRY__']; 15 | // } 16 | -------------------------------------------------------------------------------- /src/graph/toolbar.less: -------------------------------------------------------------------------------- 1 | .c-toolbar { 2 | display: inline-block; 3 | position: relative; 4 | 5 | top: 16px; 6 | left: 395px; 7 | 8 | .c-pill-button { 9 | margin: 0 6px; 10 | background-color: @color-toolbar-background; 11 | color: @color-toolbar-foreground; 12 | svg { 13 | stroke: @color-toolbar-foreground; 14 | } 15 | 16 | &:active { 17 | background-color: @color-toolbar-active-background; 18 | color: @color-toolbar-active-foreground; 19 | svg { 20 | stroke: @color-toolbar-active-foreground; 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/svg-button/index.less: -------------------------------------------------------------------------------- 1 | .c-svg-button { 2 | circle.background { 3 | fill: @color-radial-menu-background; 4 | stroke: @color-radial-menu-border; 5 | stroke-width: 2; 6 | } 7 | .c-icon { 8 | stroke: @color-radial-menu-icon; 9 | } 10 | 11 | &:hover { 12 | circle.background { 13 | fill: @color-radial-menu-hover-background; 14 | stroke: @color-radial-menu-hover-border; 15 | } 16 | .c-icon { 17 | stroke: @color-radial-menu-hover-icon; 18 | } 19 | } 20 | 21 | .click-target { 22 | cursor: pointer; 23 | fill: transparent; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import './button.less'; 2 | 3 | import React, { forwardRef } from 'react'; 4 | 5 | export type ButtonProps = React.ComponentPropsWithoutRef<'button'>; 6 | 7 | export const Button = forwardRef( 8 | (props, ref) => { 9 | const { className: originalClassName, ...rest } = props; 10 | 11 | const className = originalClassName 12 | ? `c-button ${originalClassName}` 13 | : 'c-button'; 14 | 15 | return ( 16 | 19 | ); 20 | }, 21 | ); 22 | -------------------------------------------------------------------------------- /src/data-provider.less: -------------------------------------------------------------------------------- 1 | .c-uploader { 2 | font-family: sans-serif; 3 | text-align: center; 4 | 5 | width: 500px; 6 | 7 | margin: 150px auto; 8 | padding: 50px; 9 | 10 | border-radius: 100px; 11 | 12 | border: 8px dashed @color-drop-target-border; 13 | 14 | &.drop-ready { 15 | background-color: @color-drop-target-ready-background; 16 | } 17 | 18 | ul.errors { 19 | margin: 0; 20 | padding: 32px 0 0 0; 21 | li { 22 | list-style: none; 23 | display: inline-flex; 24 | align-items: center; 25 | 26 | .c-icon { 27 | margin-right: 8px; 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/search/use-debounced-callback.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback } from 'react'; 2 | 3 | export const useDebouncedCallback = ( 4 | fn: (...args: Args) => void, 5 | ms: number, 6 | ) => { 7 | const timeout = useRef>(); 8 | 9 | return useCallback( 10 | (...args: Args) => { 11 | const deferred = () => { 12 | timeout.current && clearTimeout(timeout.current); 13 | fn(...args); 14 | }; 15 | 16 | timeout.current && clearTimeout(timeout.current); 17 | timeout.current = setTimeout(deferred, ms); 18 | }, 19 | [fn, ms], 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/umd/umd.tsx: -------------------------------------------------------------------------------- 1 | import './umd.less'; 2 | 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { DomainGraph } from '../domain-graph'; 6 | import { LocalStorageStateRepository } from '../persistence'; 7 | import { parse } from 'graphql'; 8 | 9 | const repository = new LocalStorageStateRepository(); 10 | 11 | export function mount(rootElementId: string, graphId: string, source: string) { 12 | const documentNode = parse(source); 13 | 14 | render( 15 | , 20 | document.getElementById(rootElementId), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/tools/snapshot/create-snapshot.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { parse } from 'graphql'; 3 | import { join } from 'path'; 4 | import { factory } from '../factory'; 5 | import { connections } from '../plugins/connections'; 6 | import { format } from 'prettier'; 7 | 8 | const example = readFileSync( 9 | join('src', 'tools', 'snapshot', 'example.graphql'), 10 | ).toString(); 11 | 12 | const document = parse(example); 13 | 14 | const snapshot = JSON.stringify(factory(document, [connections])); 15 | 16 | const formatted = format(snapshot, { parser: 'json' }); 17 | 18 | writeFileSync(join('src', 'tools', 'snapshot', 'snapshot.json'), formatted); 19 | -------------------------------------------------------------------------------- /src/components/pill-button.less: -------------------------------------------------------------------------------- 1 | .c-pill-button { 2 | display: inline-flex; 3 | cursor: pointer; 4 | outline: none; 5 | border: none; 6 | background: none; 7 | padding: 8px 12px; 8 | 9 | user-select: none; 10 | 11 | border-radius: 40px; 12 | 13 | svg { 14 | margin-top: -2px; 15 | margin-right: 4px; 16 | } 17 | 18 | align-items: center; 19 | 20 | transition: box-shadow 100ms ease-in-out, background-color 100ms ease-in-out; 21 | 22 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 6px 0px; 23 | 24 | &:hover { 25 | box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 10px 0px; 26 | } 27 | 28 | &:active { 29 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 6px 0px; 30 | } 31 | 32 | &:focus { 33 | outline: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/persistence/subscribed-state-repository.ts: -------------------------------------------------------------------------------- 1 | import { SaveState, SaveStateRepository } from './types'; 2 | 3 | export class SubscribedStateRepository implements SaveStateRepository { 4 | constructor( 5 | private readonly repository: SaveStateRepository, 6 | private readonly onState: (id: string, state: SaveState) => void, 7 | ) {} 8 | 9 | has(id: string): Promise { 10 | return this.repository.has(id); 11 | } 12 | get(id: string): Promise { 13 | return this.repository.get(id); 14 | } 15 | set(id: string, state: SaveState): Promise { 16 | this.onState(id, state); 17 | return this.repository.set(id, state); 18 | } 19 | delete(id: string): Promise { 20 | return this.repository.delete(id); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.less$/, 12 | use: [ 13 | MiniCssExtractPlugin.loader, 14 | 'css-loader', 15 | { 16 | loader: 'less-loader', 17 | options: { additionalData: "@import '/src/colors.less';" }, 18 | }, 19 | ], 20 | exclude: /node_modules/, 21 | }, 22 | ], 23 | }, 24 | plugins: [new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' })], 25 | output: { 26 | filename: '[name].[contenthash].js', 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, windows-latest] 14 | node-version: [14, 16, 18, 19] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - name: Set git to use LF 20 | if: ${{ matrix.os == 'windows-latest' }} 21 | run: | 22 | git config --global core.autocrlf false 23 | git config --global core.eol lf 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm ci 30 | - run: npm run build 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /src/tools/snapshot/factory-snapshot.tests.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { parse } from 'graphql'; 3 | import { join } from 'path'; 4 | import { factory } from '../factory'; 5 | import { connections } from '../plugins/connections'; 6 | 7 | describe(factory, () => { 8 | it('recreates a valid snapshot', () => { 9 | // ARRANGE 10 | const example = readFileSync( 11 | join('src', 'tools', 'snapshot', 'example.graphql'), 12 | ).toString(); 13 | const snapshot = JSON.parse( 14 | readFileSync( 15 | join('src', 'tools', 'snapshot', 'snapshot.json'), 16 | ).toString(), 17 | ); 18 | 19 | const document = parse(example); 20 | 21 | // ACT 22 | const result = factory(document, [connections]); 23 | 24 | // ASSERT 25 | expect(result).toStrictEqual(snapshot); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/simulation/edge-subscriber.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useContext } from 'react'; 2 | import { context } from './context'; 3 | 4 | export const useEdgeSubscriber: EdgeSubscriber = ( 5 | edgeId: string, 6 | onChange: EdgeEvent, 7 | ) => { 8 | const onChangeRef = useRef(onChange); 9 | useEffect(() => { 10 | onChangeRef.current = onChange; 11 | }, [onChange]); 12 | 13 | const subscribe = useContext(context)?.edgeSubscriber; 14 | 15 | useEffect(() => { 16 | subscribe?.(edgeId, (change) => { 17 | requestAnimationFrame(() => { 18 | onChangeRef.current?.(change); 19 | }); 20 | }); 21 | }, [subscribe, edgeId]); 22 | }; 23 | 24 | export interface EdgeSubscriber { 25 | (edgeId: string, onChange: EdgeEvent): void; 26 | } 27 | export interface EdgeEvent { 28 | (location: { x1: number; y1: number; x2: number; y2: number }): void; 29 | } 30 | -------------------------------------------------------------------------------- /src/persistence/types.ts: -------------------------------------------------------------------------------- 1 | import { GraphState } from '../state/graph'; 2 | 3 | export interface SaveStateRepository { 4 | has(id: string): Promise; 5 | get(id: string): Promise; 6 | set(id: string, state: SaveState): Promise; 7 | delete(id: string): Promise; 8 | } 9 | 10 | export interface SaveState { 11 | graph: Omit< 12 | GraphState, 13 | | 'args' 14 | | 'edges' 15 | | 'fields' 16 | | 'nodes' 17 | | 'enums' 18 | | 'enumValues' 19 | | 'inputs' 20 | | 'inputFields' 21 | | 'visibleEdgeIds' 22 | | 'plugins' 23 | | 'activePlugins' 24 | >; 25 | canvas: CanvasState; 26 | } 27 | 28 | export interface NodeState { 29 | id: string; 30 | fixed: boolean; 31 | x: number; 32 | y: number; 33 | } 34 | 35 | export interface CanvasState { 36 | x: number; 37 | y: number; 38 | scale: number; 39 | } 40 | -------------------------------------------------------------------------------- /src/graph/domain-object.less: -------------------------------------------------------------------------------- 1 | .c-domain-object { 2 | .handle { 3 | cursor: grab; 4 | circle { 5 | fill: @color-node-background; 6 | stroke: @color-node-border; 7 | stroke-width: 2; 8 | } 9 | } 10 | 11 | &.selected .handle circle { 12 | fill: @color-selected-node-background; 13 | stroke: @color-selected-node-border; 14 | stroke-width: 4; 15 | } 16 | 17 | &.dragging { 18 | .handle { 19 | cursor: grabbing; 20 | } 21 | } 22 | 23 | text { 24 | font-family: sans-serif; 25 | stroke: none; 26 | text-anchor: middle; 27 | alignment-baseline: middle; 28 | fill: @color-node-text; 29 | } 30 | 31 | &.selected text { 32 | fill: @color-selected-node-text; 33 | } 34 | 35 | .controls { 36 | rect.click-target { 37 | fill: transparent; 38 | } 39 | 40 | .control-wheel { 41 | fill: transparent; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/simulation/node-subscriber.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from 'react'; 2 | import { context } from './context'; 3 | 4 | export const useNodeSubscriber: NodeSubscriber = ( 5 | nodeId: string, 6 | onChange: NodeEvent, 7 | ): void => { 8 | const onChangeRef = useRef(onChange); 9 | useEffect(() => { 10 | onChangeRef.current = onChange; 11 | }, [onChange]); 12 | 13 | const subscribe = useContext(context)?.nodeSubscriber; 14 | 15 | useEffect(() => { 16 | subscribe?.(nodeId, (event, location) => { 17 | requestAnimationFrame(() => { 18 | onChangeRef.current?.(event, location); 19 | }); 20 | }); 21 | }, [subscribe, nodeId]); 22 | }; 23 | 24 | export interface NodeSubscriber { 25 | (nodeId: string, onChange: NodeEvent): void; 26 | } 27 | 28 | export interface NodeEvent { 29 | ( 30 | kind: 'dragstart' | 'dragend' | 'drag' | 'tick', 31 | location: { x: number; y: number }, 32 | ): void; 33 | } 34 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | target: 'web', 6 | entry: './src/bootstrap/index.tsx', 7 | optimization: { 8 | splitChunks: { 9 | chunks: 'initial', 10 | cacheGroups: { 11 | vendor: { 12 | test: /[\\/]node_modules[\\/]/, 13 | name: 'vendor', 14 | chunks: 'all', 15 | }, 16 | }, 17 | }, 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | use: 'ts-loader', 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | output: { 29 | path: path.resolve(__dirname, 'dist'), 30 | }, 31 | plugins: [ 32 | new HtmlWebpackPlugin({ 33 | template: './src/bootstrap/index.html', 34 | }), 35 | ], 36 | resolve: { 37 | extensions: ['.js', '.ts', '.tsx'], 38 | fallback: { url: false }, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/graph/domain-edge.less: -------------------------------------------------------------------------------- 1 | .c-domain-edge { 2 | .handle { 3 | cursor: pointer; 4 | 5 | rect { 6 | rx: 3; 7 | fill: @color-edge-background; 8 | stroke: @color-edge-border; 9 | stroke-width: 2; 10 | stroke-linecap: round; 11 | stroke-linejoin: round; 12 | } 13 | 14 | .c-icon { 15 | stroke: @color-edge-chevron; 16 | } 17 | 18 | &.selected { 19 | rect { 20 | fill: @color-selected-edge-background; 21 | stroke: @color-selected-edge-border; 22 | stroke-width: 4; 23 | } 24 | 25 | .c-icon { 26 | stroke: @color-selected-edge-chevron; 27 | } 28 | } 29 | } 30 | 31 | path { 32 | stroke: @color-edge-border; 33 | stroke-width: 2; 34 | fill: none; 35 | 36 | &.optional { 37 | stroke-dasharray: 4 2; 38 | } 39 | 40 | &.selected { 41 | stroke: @color-selected-edge-border; 42 | stroke-width: 4; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { Action } from './state/graph/reducer'; 2 | 3 | export interface Test { 4 | ( 5 | type: ActionType, 6 | fn: (typeUnderTest: ActionType) => void, 7 | ): void; 8 | } 9 | export interface Describe extends Test { 10 | /** Only runs the tests inside this `describe` for the current file */ 11 | only: Describe; 12 | /** Skips running the tests inside this `describe` for the current file */ 13 | skip: Describe; 14 | each: jest.Each; 15 | } 16 | 17 | export function test( 18 | type: ActionType, 19 | fn: (typeUnderTest: ActionType) => void, 20 | ) { 21 | const jestFn = () => { 22 | fn(type); 23 | }; 24 | 25 | describe(`Action type: "${type}"`, jestFn); 26 | } 27 | 28 | export const describeAction: Describe = test as any; 29 | describeAction.only = describe.only as any; 30 | describeAction.skip = describe.skip as any; 31 | describeAction.each = describe.each as any; 32 | -------------------------------------------------------------------------------- /src/persistence/local-storage-state-repository.ts: -------------------------------------------------------------------------------- 1 | import { SaveState, SaveStateRepository } from './types'; 2 | 3 | export class LocalStorageStateRepository implements SaveStateRepository { 4 | private readonly prefix = 'domain-graph-state-object'; 5 | 6 | private key(id: string): string { 7 | return `${this.prefix}:${id}`; 8 | } 9 | 10 | has(id: string): Promise { 11 | return Promise.resolve(window.localStorage.getItem(this.key(id)) !== null); 12 | } 13 | get(id: string): Promise { 14 | const item = window.localStorage.getItem(this.key(id)); 15 | return Promise.resolve(item === null ? null : JSON.parse(item)); 16 | } 17 | set(id: string, state: SaveState): Promise { 18 | window.localStorage.setItem(this.key(id), JSON.stringify(state)); 19 | return Promise.resolve(); 20 | } 21 | delete(id: string): Promise { 22 | window.localStorage.removeItem(this.key(id)); 23 | return Promise.resolve(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { AlertTriangle } from './alert-triangle'; 2 | export { ChevronDown } from './chevron-down'; 3 | export { ChevronRight } from './chevron-right'; 4 | export { ChevronUp } from './chevron-up'; 5 | export { ChevronsDown } from './chevrons-down'; 6 | export { ChevronsRight } from './chevrons-right'; 7 | export { ChevronsUp } from './chevrons-up'; 8 | export { CornerUpRight } from './corner-up-right'; 9 | export { EyeOff } from './eye-off'; 10 | export { Eye } from './eye'; 11 | export { Folder } from './folder'; 12 | export { Graph } from './graph'; 13 | export { Home } from './home'; 14 | export { Info } from './info'; 15 | export { Lock } from './lock'; 16 | export { Maximize2 } from './maximize-2'; 17 | export { Minimize2 } from './minimize-2'; 18 | export { Relay } from './relay'; 19 | export { Search } from './search'; 20 | export { Trash } from './trash'; 21 | export { Unlock } from './unlock'; 22 | export { UploadCloud } from './upload-cloud'; 23 | export { X } from './x'; 24 | -------------------------------------------------------------------------------- /src/search/types.ts: -------------------------------------------------------------------------------- 1 | import { GraphState } from '../state/graph'; 2 | 3 | export type ResultKind = 'Arg' | 'Field' | 'Type'; 4 | 5 | export function isResultKind(str: string): str is ResultKind { 6 | return str === 'Arg' || str === 'Field' || str === 'Type'; 7 | } 8 | 9 | export type ResultField = 10 | | 'name' 11 | | 'description' 12 | | 'deprecationReason' 13 | | 'defaultValue'; 14 | 15 | export function isResultField(str: string): str is ResultField { 16 | return ( 17 | str === 'name' || 18 | str === 'description' || 19 | str === 'deprecationReason' || 20 | str === 'defaultValue' 21 | ); 22 | } 23 | 24 | export type Result = { 25 | kind: ResultKind; 26 | id: string; 27 | score: number; 28 | matchData?: MatchData[]; 29 | }; 30 | 31 | export type MatchData = { 32 | field: ResultField; 33 | locations: { offset: number; length: number }[]; 34 | }; 35 | 36 | export interface SearchEngine { 37 | index(graph: GraphState): void; 38 | search(query: string): Result[]; 39 | } 40 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const { merge } = require('webpack-merge'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | const common = require('./webpack.common.js'); 7 | 8 | module.exports = merge(common, { 9 | mode: 'development', 10 | devServer: { 11 | static: { 12 | directory: path.join(__dirname, 'dist'), 13 | }, 14 | port: 9999, 15 | hot: true, 16 | }, 17 | devtool: 'inline-source-map', 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.less$/, 22 | use: [ 23 | 'css-hot-loader', 24 | MiniCssExtractPlugin.loader, 25 | 'css-loader', 26 | { 27 | loader: 'less-loader', 28 | options: { additionalData: "@import '/src/colors.less';" }, 29 | }, 30 | ], 31 | exclude: /node_modules/, 32 | }, 33 | ], 34 | }, 35 | plugins: [new MiniCssExtractPlugin({ filename: '[name].css' })], 36 | output: { 37 | filename: '[name].js', 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /webpack.umd.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | module.exports = { 5 | target: 'web', 6 | mode: 'production', 7 | entry: './src/umd/umd.tsx', 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.tsx?$/, 12 | use: 'ts-loader', 13 | exclude: /node_modules/, 14 | }, 15 | { 16 | test: /\.less$/, 17 | use: [ 18 | MiniCssExtractPlugin.loader, 19 | 'css-loader', 20 | { 21 | loader: 'less-loader', 22 | options: { additionalData: "@import '/src/colors.less';" }, 23 | }, 24 | ], 25 | exclude: /node_modules/, 26 | }, 27 | ], 28 | }, 29 | output: { 30 | path: path.resolve(__dirname, 'umd'), 31 | filename: 'domain-graph.min.js', 32 | library: { name: 'domainGraph', type: 'umd' }, 33 | }, 34 | plugins: [new MiniCssExtractPlugin({ filename: 'domain-graph.min.css' })], 35 | resolve: { 36 | extensions: ['.js', '.ts', '.tsx'], 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import './icon-button.less'; 2 | 3 | import React, { forwardRef, useMemo } from 'react'; 4 | import { IconProps } from '../icons/base'; 5 | 6 | export interface ButtonProps 7 | extends React.ComponentPropsWithoutRef<'button'>, 8 | IconProps { 9 | Icon: React.VFC; 10 | } 11 | 12 | export const IconButton = forwardRef( 13 | (props, ref) => { 14 | const { 15 | className: originalClassName, 16 | size, 17 | color, 18 | strokeWidth, 19 | x, 20 | y, 21 | Icon, 22 | ...rest 23 | } = props; 24 | 25 | const iconProps = { 26 | size, 27 | color, 28 | strokeWidth, 29 | x, 30 | y, 31 | }; 32 | 33 | const className = useMemo( 34 | () => ['c-icon-button', originalClassName].filter((c) => c).join(' '), 35 | [originalClassName], 36 | ); 37 | 38 | return ( 39 | 42 | ); 43 | }, 44 | ); 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Steve Konves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/pill-button.tsx: -------------------------------------------------------------------------------- 1 | import './pill-button.less'; 2 | 3 | import React, { forwardRef, useMemo } from 'react'; 4 | import { IconProps } from '../icons/base'; 5 | 6 | export interface PillButtonProps 7 | extends React.ComponentPropsWithoutRef<'button'>, 8 | Omit { 9 | children: string | number; 10 | icon: React.VFC; 11 | } 12 | 13 | export const PillButton = forwardRef( 14 | (props, ref) => { 15 | const { 16 | children, 17 | className: originalClassName, 18 | color, 19 | strokeWidth, 20 | x, 21 | y, 22 | icon: Icon, 23 | ...buttonProps 24 | } = props; 25 | 26 | const iconProps: IconProps = { 27 | size: 14, 28 | color, 29 | strokeWidth, 30 | x, 31 | y, 32 | }; 33 | 34 | const className = useMemo( 35 | () => ['c-pill-button', originalClassName].filter((c) => c).join(' '), 36 | [originalClassName], 37 | ); 38 | 39 | return ( 40 | 44 | ); 45 | }, 46 | ); 47 | -------------------------------------------------------------------------------- /src/graph/node-picker.less: -------------------------------------------------------------------------------- 1 | .c-node-picker { 2 | overflow: hidden; 3 | display: inline-block; 4 | position: absolute; 5 | 6 | width: 300px; 7 | 8 | backdrop-filter: blur(20px); 9 | background: rgba(255, 255, 255, 0.3); 10 | 11 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 12 | 0 22px 70px 4px rgba(0, 0, 0, 0.56), 0 0 0 1px rgba(0, 0, 0, 0); 13 | 14 | border-radius: 20px; 15 | 16 | padding: 16px; 17 | 18 | top: 20px; 19 | left: 20px; 20 | font-family: sans-serif; 21 | 22 | ul, 23 | li { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | li { 29 | list-style: none; 30 | } 31 | 32 | ul { 33 | max-height: 600px; 34 | overflow-y: scroll; 35 | overflow-x: hidden; 36 | 37 | &::-webkit-scrollbar { 38 | display: none; /* Chrome, Safari and Opera */ 39 | } 40 | -ms-overflow-style: none; /* IE and Edge */ 41 | scrollbar-width: none; /* Firefox */ 42 | } 43 | 44 | button { 45 | cursor: pointer; 46 | background: none; 47 | border: none; 48 | padding: 4px 8px; 49 | outline: none; 50 | 51 | &:focus, 52 | &:hover { 53 | background-color: @color-button-hover-background; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/svg-canvas/use-resize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useResize( 4 | element: HTMLElement | null, 5 | onSize: (values: { width: number; height: number }) => void, 6 | ) { 7 | const callback = 8 | useRef<(values: { width: number; height: number }) => void>(onSize); 9 | useEffect(() => { 10 | callback.current = onSize; 11 | }, [onSize]); 12 | 13 | const observer = useRef(); 14 | useEffect(() => { 15 | if (element) { 16 | observer.current ||= new ResizeObserver(([{ target }]) => { 17 | callback.current({ 18 | width: target.clientWidth, 19 | height: target.clientHeight, 20 | }); 21 | }); 22 | 23 | callback.current({ 24 | width: element.clientWidth, 25 | height: element.clientHeight, 26 | }); 27 | 28 | observer.current.observe(element); 29 | return () => { 30 | observer.current?.unobserve(element); 31 | }; 32 | } else { 33 | return undefined; 34 | } 35 | }, [element]); 36 | 37 | useEffect(() => { 38 | return () => { 39 | if (observer.current) { 40 | observer.current.disconnect(); 41 | } 42 | }; 43 | }, []); 44 | } 45 | -------------------------------------------------------------------------------- /src/bootstrap/shim.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react'; 2 | import { OpenFilesResult, DataProvider, BrowserOpenFileDialog } from '..'; 3 | 4 | import { DomainGraph } from '../domain-graph'; 5 | import { LocalStorageStateRepository } from '../persistence'; 6 | 7 | const repository = new LocalStorageStateRepository(); 8 | 9 | export const Shim: React.VFC = () => { 10 | const handleDrop = useCallback(async () => { 11 | return true; 12 | }, []); 13 | 14 | const handleShowOpenDialog = useCallback(async () => { 15 | return ( 16 | openFileDialog.current?.open() || 17 | Promise.resolve({ canceled: true, files: [] }) 18 | ); 19 | }, []); 20 | 21 | const openFileDialog = useRef<{ open: () => Promise }>(null); 22 | 23 | return ( 24 | <> 25 | 26 | {(documentNode) => ( 27 | 32 | )} 33 | 34 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/graph/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import './toolbar.less'; 2 | 3 | import React, { useCallback } from 'react'; 4 | import * as Icons from '../icons'; 5 | import { useDispatch } from '../state'; 6 | import { hideAllNodes, hideUnpinnedNodes } from '../state/graph/graph-actions'; 7 | import { PillButton } from '../components/pill-button'; 8 | 9 | export interface ToolbarProps { 10 | onResetZoom(): void; 11 | onFitAll(): void; 12 | } 13 | 14 | export const Toolbar: React.VFC = ({ onResetZoom }) => { 15 | const dispatch = useDispatch(); 16 | const handleHideAll = useCallback(() => { 17 | dispatch(hideAllNodes() as any); // TODO: (issue: #40) 18 | onResetZoom(); 19 | }, [dispatch, onResetZoom]); 20 | 21 | const handleHideUnpinned = useCallback(() => { 22 | dispatch(hideUnpinnedNodes() as any); // TODO: (issue: #40) 23 | }, [dispatch]); 24 | return ( 25 |
26 | 27 | Hide all 28 | 29 | 30 | Hide unpinned 31 | 32 | {/* 33 | Fit all 34 | */} 35 | 36 | Reset view 37 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/tools/types.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | import { GraphState } from '../state/graph'; 3 | 4 | export type FactoryGraphState = Pick< 5 | GraphState, 6 | | 'nodes' 7 | | 'edges' 8 | | 'args' 9 | | 'enums' 10 | | 'enumValues' 11 | | 'fields' 12 | | 'inputs' 13 | | 'inputFields' 14 | >; 15 | 16 | export interface StateFactory { 17 | (document: DocumentNode, plugins?: StateFactoryPlugin[]): FactoryGraphState; 18 | } 19 | 20 | export interface StateFactoryPlugin { 21 | (state: FactoryGraphState): FactoryGraphState; 22 | } 23 | 24 | // TODO: replace usage of the following types with native graphql types instead 25 | export type SpecificFieldType = 26 | | ObjectFieldType 27 | | ScalarFieldType 28 | | EnumFieldType 29 | | UnionFieldType 30 | | InterfaceFieldType; 31 | 32 | export type SpecificInputFieldType = 33 | | ScalarFieldType 34 | | EnumFieldType 35 | | InputObjectFieldType; 36 | 37 | export interface ObjectFieldType { 38 | kind: 'OBJECT'; 39 | name: string; 40 | } 41 | 42 | export interface InputObjectFieldType { 43 | kind: 'INPUT_OBJECT'; 44 | name: string; 45 | } 46 | 47 | export interface ScalarFieldType { 48 | kind: 'SCALAR'; 49 | name: string; 50 | } 51 | 52 | export interface EnumFieldType { 53 | kind: 'ENUM'; 54 | name: string; 55 | } 56 | 57 | export interface UnionFieldType { 58 | kind: 'UNION'; 59 | name: string; 60 | } 61 | 62 | export interface InterfaceFieldType { 63 | kind: 'INTERFACE'; 64 | name: string; 65 | } 66 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | // import { Registry } from '../registry'; 3 | import { OmitByType } from '../utils'; 4 | import { 5 | useSelector as useOriginalSelector, 6 | useDispatch as useOriginalDispatch, 7 | } from 'react-redux'; 8 | import { reducer } from './graph/reducer'; 9 | 10 | export type FluxStandardAction< 11 | TType extends string = string, 12 | TPayload = void, 13 | TMeta = void, 14 | > = { 15 | type: TType; 16 | payload: TPayload; 17 | error?: boolean; 18 | meta?: TMeta; 19 | }; 20 | 21 | export function useSelector( 22 | selector: (state: ApplicationState) => T, 23 | equalityFn?: ((left: T, right: T) => boolean) | undefined, 24 | ): T { 25 | return useOriginalSelector(selector, equalityFn); 26 | } 27 | 28 | export function useDispatch(): Dispatch { 29 | return useOriginalDispatch(); 30 | } 31 | 32 | export type Dispatch = < 33 | Action extends FluxStandardAction | Thunk, 34 | >( 35 | action: Action, 36 | ) => Action extends FluxStandardAction 37 | ? void 38 | : Promise; 39 | 40 | export type Thunk = ( 41 | dispatch: Dispatch, 42 | getState: () => ApplicationState, 43 | // registry: Registry, 44 | ) => Promise; 45 | 46 | export const reducers = combineReducers({ 47 | graph: reducer, 48 | }); 49 | 50 | export type ApplicationState = OmitByType< 51 | ReturnType, 52 | undefined 53 | >; 54 | -------------------------------------------------------------------------------- /src/graph/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | 3 | import React, { useCallback, useRef } from 'react'; 4 | 5 | import { SvgCanvas, SvgCanvasMethods } from '../svg-canvas'; 6 | import { DomainObject } from './domain-object'; 7 | import { DomainEdge } from './domain-edge'; 8 | import { Simulation } from '../simulation'; 9 | import { Spotlight } from './spotlight'; 10 | import { useVisibleEdgeIds, useVisibleNodeIds } from '../state/graph/hooks'; 11 | import { Toolbar } from './toolbar'; 12 | import { SearchBox } from '../search/search-box'; 13 | 14 | export interface GraphProps { 15 | className?: string; 16 | } 17 | 18 | export const Graph: React.VFC = () => { 19 | const nodeIds = useVisibleNodeIds(); 20 | const edgeIds = useVisibleEdgeIds(); 21 | 22 | const canvas = useRef(null); 23 | 24 | const handleClickFitAll = useCallback(() => { 25 | canvas.current?.fitAll?.(); 26 | }, []); 27 | 28 | const handleClickResetZoom = useCallback(() => { 29 | canvas.current?.resetZoom?.(); 30 | }, []); 31 | 32 | return ( 33 | 34 | 35 | 36 | {edgeIds.map((edgeId) => ( 37 | 38 | ))} 39 | 40 | 41 | {nodeIds.map((nodeId) => ( 42 | 43 | ))} 44 | 45 | 46 | 50 | 51 | {/* */} 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/graph/node-picker.tsx: -------------------------------------------------------------------------------- 1 | import './node-picker.less'; 2 | 3 | import React, { useCallback, useMemo, useState } from 'react'; 4 | import { Eye, EyeOff } from '../icons'; 5 | 6 | import { useDispatch } from '../state'; 7 | import { hideNode, showNode } from '../state/graph/graph-actions'; 8 | import { useAllNodes, useIsVisible } from '../state/graph/hooks'; 9 | 10 | export const NodePicker: React.VFC = () => { 11 | const nodes = useAllNodes(); 12 | const [filter, setFilter] = useState(''); 13 | const sortedNodes = useMemo( 14 | () => 15 | [...nodes] 16 | .filter( 17 | (node) => 18 | !filter || 19 | node.id.toLocaleLowerCase().includes(filter.toLocaleLowerCase()), 20 | ) 21 | .sort((a, b) => a.id.localeCompare(b.id)), 22 | [nodes, filter], 23 | ); 24 | 25 | const handleFilter = useCallback( 26 | (event: React.ChangeEvent) => { 27 | setFilter(event.target.value); 28 | }, 29 | [], 30 | ); 31 | 32 | return ( 33 |
34 | 35 |
    36 | {sortedNodes.map((node) => ( 37 | 38 | ))} 39 |
40 |
41 | ); 42 | }; 43 | 44 | const Item: React.VFC<{ nodeId: string }> = ({ nodeId }) => { 45 | const dispatch = useDispatch(); 46 | const isVisible = useIsVisible(nodeId); 47 | 48 | const handleClick = useCallback(() => { 49 | dispatch(isVisible ? hideNode(nodeId) : showNode(nodeId)); 50 | }, [nodeId, isVisible, dispatch]); 51 | 52 | return ( 53 |
  • 54 | 57 | {nodeId} 58 |
  • 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/tools/snapshot/example.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | Required relay connection type 3 | """ 4 | type PageInfo { 5 | endCursor: String 6 | hasNextPage: Boolean! 7 | hasPreviousPage: Boolean! 8 | startCursor: String 9 | } 10 | 11 | type Widget { 12 | id: ID! 13 | gizmos( 14 | filter: String 15 | after: String 16 | before: String 17 | first: Int 18 | last: Int 19 | ): GizmoConnection! 20 | moreGizmos( 21 | after: String 22 | before: String 23 | first: Int 24 | last: Int 25 | ): GizmoConnection! # duplicated use of connection type 26 | stuff( 27 | after: String 28 | before: String 29 | first: Int 30 | last: Int 31 | ): MyInterfaceConnection! 32 | things(after: String, before: String, first: Int, last: Int): ThingConnection! # connection to union type 33 | somethingElse( 34 | after: String 35 | before: String 36 | first: Int 37 | last: Int 38 | ): SomethingElseConnection! 39 | } 40 | 41 | # Gizmo connection and edge use the {type}Connection and {type}Edge naming convention 42 | type Gizmo { 43 | id: ID! 44 | } 45 | type GizmoConnection { 46 | nodes: [Gizmo] 47 | edges: [GizmoEdge] 48 | pageInfo: PageInfo! 49 | } 50 | type GizmoEdge { 51 | node: Gizmo 52 | cursor: String! 53 | } 54 | 55 | # Connection to Gizmo type but using different base names 56 | type SomethingElseConnection { 57 | nodes: [Gizmo] 58 | edges: [SomethingOtherEdge] 59 | pageInfo: PageInfo! 60 | } 61 | type SomethingOtherEdge { 62 | node: Gizmo 63 | cursor: String! 64 | } 65 | 66 | # MyInterface connection and edge use the {interface}Connection and {interface}Edge naming convention 67 | type MyInterfaceConnection { 68 | nodes: [MyInterface] 69 | edges: [MyInterfaceEdge] 70 | pageInfo: PageInfo! 71 | } 72 | type MyInterfaceEdge { 73 | node: MyInterface 74 | cursor: String! 75 | } 76 | interface MyInterface { 77 | id: ID! 78 | name: String! 79 | } 80 | 81 | # Thing connection and edge use the {union}Connection and {union}Edge naming convention 82 | union Thing = Widget | Gizmo 83 | type ThingConnection { 84 | nodes: [Thing] 85 | edges: [ThingEdge] 86 | pageInfo: PageInfo! 87 | } 88 | type ThingEdge { 89 | node: Thing 90 | cursor: String! 91 | } 92 | -------------------------------------------------------------------------------- /src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | import { applyMiddleware, compose, createStore, Store } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | 5 | import { reducers } from '.'; 6 | 7 | import { importSaveState, importState } from './graph/graph-actions'; 8 | import { factory } from '../tools/factory'; 9 | import { defaultState } from './graph'; 10 | import { SaveState, SaveStateRepository } from '../persistence'; 11 | import { deindex } from 'flux-standard-functions'; 12 | import { connections, pluginName } from '../tools/plugins/connections'; 13 | 14 | const composeEnhancers = 15 | window['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'] || compose; 16 | 17 | export type ApplicationStore = Store>; 18 | 19 | export async function getStore( 20 | graphId: string, 21 | documentNode: DocumentNode, 22 | repository: SaveStateRepository, 23 | initialSaveState?: SaveState, 24 | ): Promise<{ store: ApplicationStore; unsubscribe: () => void }> { 25 | const store = createStore( 26 | reducers, 27 | { graph: defaultState }, 28 | composeEnhancers(applyMiddleware(thunk)), 29 | ); 30 | 31 | const { nodes, edges, fields, args, enums, enumValues, inputs, inputFields } = 32 | factory(documentNode, [connections]); 33 | 34 | store.dispatch( 35 | importState( 36 | deindex(nodes), 37 | deindex(edges), 38 | deindex(fields), 39 | deindex(args), 40 | deindex(enums), 41 | deindex(enumValues), 42 | deindex(inputs), 43 | deindex(inputFields), 44 | [], 45 | [pluginName], 46 | [pluginName], 47 | ), 48 | ); 49 | 50 | const saveState = initialSaveState || (await repository.get(graphId)); 51 | 52 | if (saveState) store.dispatch(importSaveState(saveState)); 53 | 54 | const unsubscribe = store.subscribe(() => { 55 | // TODO: debounce (issue #43) 56 | 57 | const { 58 | visibleNodes, 59 | selectedSourceNodeId, 60 | selectedFieldId, 61 | selectedTargetNodeId, 62 | } = store.getState().graph; 63 | 64 | repository.set(graphId, { 65 | graph: { 66 | visibleNodes, 67 | selectedSourceNodeId, 68 | selectedFieldId, 69 | selectedTargetNodeId, 70 | }, 71 | canvas: { scale: 1, x: 0, y: 0 }, 72 | }); 73 | }); 74 | 75 | return { store, unsubscribe }; 76 | } 77 | -------------------------------------------------------------------------------- /src/search/search-box.less: -------------------------------------------------------------------------------- 1 | .c-search-box { 2 | overflow: scroll; 3 | &::-webkit-scrollbar { 4 | width: 0 !important; 5 | } 6 | overflow: -moz-scrollbars-none; 7 | -ms-overflow-style: none; 8 | display: block; 9 | position: absolute; 10 | 11 | max-height: calc(100vh - 55px); 12 | width: 400px; 13 | 14 | top: 55px; 15 | left: 0; 16 | font-family: sans-serif; 17 | 18 | div.controls { 19 | position: fixed; 20 | top: 16px; 21 | left: 16px; 22 | 23 | input { 24 | width: 320px; 25 | padding: 8px 40px 8px 12px; 26 | margin: 0; 27 | 28 | border-radius: 40px; 29 | 30 | border: none; 31 | outline: none; 32 | 33 | transition: box-shadow 100ms ease-in-out; 34 | 35 | background-color: @color-search-input-background; 36 | color: @color-search-input-foreground; 37 | 38 | &::placeholder { 39 | color: @color-search-input-placeholder; 40 | } 41 | 42 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 6px 0px; 43 | 44 | &:focus { 45 | box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 10px 0px; 46 | } 47 | } 48 | 49 | &:focus, 50 | &:hover { 51 | input { 52 | box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 10px 0px; 53 | } 54 | } 55 | 56 | &:active { 57 | input { 58 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 6px 0px; 59 | } 60 | } 61 | 62 | .c-icon-button { 63 | position: absolute; 64 | right: 0; 65 | top: 0; 66 | 67 | svg { 68 | stroke: @color-search-input-foreground; 69 | } 70 | } 71 | } 72 | 73 | .result { 74 | padding: 12px 16px; 75 | 76 | cursor: pointer; 77 | 78 | .description { 79 | font-size: 90%; 80 | opacity: 90%; 81 | } 82 | 83 | &.node { 84 | background-color: @color-node-background; 85 | border: 2px solid @color-node-border; 86 | border-radius: 12px; 87 | margin: 8px 8px; 88 | } 89 | 90 | &.field, 91 | &.arg { 92 | background-color: @color-edge-background; 93 | border: 2px solid @color-edge-border; 94 | border-radius: 5px; 95 | margin: 8px 12px; 96 | } 97 | } 98 | 99 | ol { 100 | margin: 0; 101 | padding: 0; 102 | list-style: none; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "root": true, 7 | "extends": ["prettier", "plugin:react-hooks/recommended"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "project": "tsconfig.eslint.json", 11 | "sourceType": "module", 12 | "ecmaVersion": "latest", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "plugins": [ 18 | "@typescript-eslint", 19 | "eslint-plugin-import", 20 | "react-hooks", 21 | "unused-imports" 22 | ], 23 | "rules": { 24 | "@typescript-eslint/adjacent-overload-signatures": "error", 25 | "@typescript-eslint/no-empty-function": "error", 26 | "@typescript-eslint/no-empty-interface": "warn", 27 | "@typescript-eslint/no-namespace": "error", 28 | "@typescript-eslint/prefer-for-of": "warn", 29 | "@typescript-eslint/triple-slash-reference": "error", 30 | "@typescript-eslint/unified-signatures": "warn", 31 | "no-param-reassign": "error", 32 | "import/no-unassigned-import": [ 33 | "error", 34 | { 35 | "allow": ["**/*.less"] 36 | } 37 | ], 38 | "comma-dangle": ["error", "only-multiline"], 39 | "constructor-super": "error", 40 | "eqeqeq": ["warn", "always"], 41 | "no-cond-assign": "error", 42 | "no-duplicate-case": "error", 43 | "no-duplicate-imports": "error", 44 | "no-empty": [ 45 | "error", 46 | { 47 | "allowEmptyCatch": true 48 | } 49 | ], 50 | "spaced-comment": "error", 51 | "no-invalid-this": "error", 52 | "no-new-wrappers": "error", 53 | "no-redeclare": "error", 54 | "no-sequences": "error", 55 | "no-shadow": [ 56 | "error", 57 | { 58 | "hoist": "all" 59 | } 60 | ], 61 | "no-throw-literal": "error", 62 | "no-unsafe-finally": "error", 63 | "no-unused-labels": "error", 64 | "no-var": "warn", 65 | "prefer-const": "warn", 66 | "react-hooks/rules-of-hooks": "error", 67 | "react-hooks/exhaustive-deps": "warn", 68 | "no-unused-vars": "off", 69 | "unused-imports/no-unused-imports": "error", 70 | "unused-imports/no-unused-vars": [ 71 | "warn", 72 | { 73 | "vars": "all", 74 | "varsIgnorePattern": "^_", 75 | "args": "after-used", 76 | "argsIgnorePattern": "^_" 77 | } 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/domain-graph.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { DocumentNode } from 'graphql'; 3 | import { Provider as StoreProvider } from 'react-redux'; 4 | import { Graph } from './graph'; 5 | import { ApplicationStore, getStore } from './state/store'; 6 | import { SaveState, SaveStateRepository } from './persistence'; 7 | import { importSaveState } from './state/graph/graph-actions'; 8 | import { SubscribedStateRepository } from './persistence/subscribed-state-repository'; 9 | import { useIndexBuilder } from './search'; 10 | 11 | export interface DomainGraphProps { 12 | graphId: string; 13 | documentNode: DocumentNode; 14 | repository: SaveStateRepository; 15 | saveState?: SaveState; 16 | onSaveState?(graphId: string, saveState: SaveState): void; 17 | } 18 | 19 | export const DomainGraph: React.VFC = ({ 20 | graphId, 21 | documentNode, 22 | repository, 23 | saveState, 24 | onSaveState, 25 | }) => { 26 | const saveStateRef = useRef(saveState); 27 | useEffect(() => { 28 | saveStateRef.current = saveState; 29 | }, [saveState]); 30 | 31 | const [store, setStore] = useState(); 32 | const [subscribedRepository, setSubscribedRepository] = 33 | useState( 34 | onSaveState 35 | ? new SubscribedStateRepository(repository, onSaveState) 36 | : repository, 37 | ); 38 | useEffect(() => { 39 | setSubscribedRepository( 40 | onSaveState 41 | ? new SubscribedStateRepository(repository, onSaveState) 42 | : repository, 43 | ); 44 | }, [repository, onSaveState]); 45 | 46 | const buildIndex = useIndexBuilder(); 47 | 48 | useEffect(() => { 49 | let unsubscribe = () => { 50 | // noop 51 | }; 52 | getStore( 53 | graphId, 54 | documentNode, 55 | subscribedRepository, 56 | saveStateRef.current, 57 | ).then((result) => { 58 | setStore(result.store); 59 | unsubscribe = result.unsubscribe; 60 | buildIndex(result.store.getState()); 61 | }); 62 | 63 | return () => { 64 | unsubscribe(); 65 | }; 66 | }, [graphId, documentNode, subscribedRepository, buildIndex]); 67 | 68 | useEffect(() => { 69 | if (saveState) store?.dispatch(importSaveState(saveState)); 70 | }, [saveState, store]); 71 | 72 | if (!store) return null; 73 | 74 | return ( 75 | 76 | 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | data 119 | 120 | # build output 121 | /lib 122 | /umd 123 | -------------------------------------------------------------------------------- /src/svg-button/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | 3 | import React from 'react'; 4 | 5 | export interface ButtonProps { 6 | cx?: React.ReactText; 7 | cy?: React.ReactText; 8 | r: React.ReactText; 9 | onClick?(event: React.MouseEvent): void; 10 | className?: string; 11 | } 12 | 13 | export const CircleButton: React.FC = ({ 14 | cx, 15 | cy, 16 | r, 17 | className, 18 | children, 19 | onClick, 20 | }) => { 21 | return ( 22 | 26 | 27 | {children} 28 | 36 | 37 | ); 38 | }; 39 | 40 | export const RectButton: React.FC> = (props) => { 41 | const { 42 | className, 43 | children, 44 | x, 45 | x1, 46 | y, 47 | y1, 48 | x2: _x2, 49 | y2: _y2, 50 | ...rectProps 51 | } = props; 52 | 53 | const { width, height } = getSize(props); 54 | 55 | return ( 56 | 60 | {children} 61 | 67 | 68 | ); 69 | }; 70 | 71 | function getSize({ 72 | x1, 73 | x2, 74 | width, 75 | y1, 76 | y2, 77 | height, 78 | }: Pick< 79 | React.SVGProps, 80 | 'x1' | 'x2' | 'width' | 'y1' | 'y2' | 'height' 81 | >): { 82 | width: React.ReactText; 83 | height: React.ReactText; 84 | } { 85 | if (typeof width !== 'undefined' && typeof height !== 'undefined') { 86 | return { 87 | width, 88 | height, 89 | }; 90 | } else if ( 91 | typeof x1 !== 'undefined' && 92 | typeof x2 !== 'undefined' && 93 | typeof y1 !== 'undefined' && 94 | typeof y2 !== 'undefined' 95 | ) { 96 | return { 97 | width: Number(x2) - Number(x1), 98 | height: Number(y2) - Number(y1), 99 | }; 100 | } else { 101 | return { 102 | width: 0, 103 | height: 0, 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/svg-canvas/use-zoom.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | export interface ZoomOptions { 4 | max?: number; 5 | min?: number; 6 | speed?: number; 7 | // onZoom: (values: ZoomValues) => void; 8 | } 9 | 10 | export interface ZoomValues { 11 | delta: number; 12 | value: number; 13 | x: number; 14 | y: number; 15 | } 16 | 17 | interface State { 18 | element: HTMLElement | SVGElement | null; 19 | max: number; 20 | min: number; 21 | speed: number; 22 | value: number; 23 | onZoom: (values: ZoomValues) => void; 24 | } 25 | 26 | export function useZoom( 27 | element: HTMLElement | SVGElement | null, 28 | onZoom: (values: ZoomValues) => void, 29 | options?: ZoomOptions, 30 | ) { 31 | const initialMax = typeof options?.max === 'number' ? options.max : 4; 32 | const initialMin = typeof options?.min === 'number' ? options.min : 0.125; 33 | const initialSpeed = 34 | typeof options?.speed === 'number' ? options.speed : 0.005; 35 | 36 | const state = useRef({ 37 | element, 38 | max: initialMax, 39 | min: initialMin, 40 | speed: initialSpeed, 41 | value: 1, 42 | onZoom, 43 | }); 44 | 45 | const { min, max, speed } = options || {}; 46 | 47 | useEffect(() => { 48 | state.current.max = typeof max === 'number' ? max : 4; 49 | state.current.min = typeof min === 'number' ? min : 0.125; 50 | state.current.speed = typeof speed === 'number' ? speed : 0.005; 51 | state.current.onZoom = onZoom; 52 | 53 | // TODO check that current value is not out of bounds 54 | }, [min, max, speed, onZoom]); 55 | 56 | const handleWheel = useRef((e: WheelEvent) => { 57 | e.preventDefault(); 58 | const { value } = state.current; 59 | 60 | let newValue = value + e.deltaY * -0.005; 61 | 62 | // Restrict scale 63 | newValue = Math.min( 64 | Math.max(state.current.min, newValue), 65 | state.current.max, 66 | ); 67 | 68 | const delta = newValue - state.current.value; 69 | 70 | state.current.value = newValue; 71 | 72 | if (delta) { 73 | state.current.onZoom({ 74 | delta, 75 | value: newValue, 76 | x: e.offsetX, 77 | y: e.offsetY, 78 | }); 79 | } 80 | }); 81 | 82 | useEffect(() => { 83 | state.current.element = element; 84 | if (element) { 85 | const onWheel = handleWheel.current; 86 | element.addEventListener('wheel', onWheel); 87 | return () => { 88 | element.removeEventListener('wheel', onWheel); 89 | }; 90 | } else { 91 | return undefined; 92 | } 93 | }, [element]); 94 | 95 | return useCallback((value: number) => { 96 | state.current.value = value; 97 | }, []); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/browser-open-file-dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useCallback, 4 | useImperativeHandle, 5 | useRef, 6 | } from 'react'; 7 | import { OpenFilesResult } from '../data-provider'; 8 | 9 | export interface BrowserOpenFileDialogProps { 10 | accept?: string; 11 | multiple?: boolean; 12 | } 13 | 14 | export const BrowserOpenFileDialog = forwardRef( 15 | ({ accept, multiple }: BrowserOpenFileDialogProps, ref) => { 16 | const resolver = useRef<(results: OpenFilesResult) => void>(); 17 | const timer = useRef(); 18 | const inputRef = useRef(null); 19 | const isOpen = useRef(); 20 | 21 | useImperativeHandle(ref, () => ({ 22 | open: () => { 23 | inputRef.current?.focus(); 24 | inputRef.current?.click(); 25 | isOpen.current = true; 26 | return new Promise((resolve) => { 27 | resolver.current = resolve; 28 | }); 29 | }, 30 | })); 31 | 32 | const handleFocus = useCallback(() => { 33 | const delay = 100; 34 | 35 | if (isOpen.current) { 36 | timer.current = setTimeout(() => { 37 | resolver.current?.({ 38 | canceled: true, 39 | files: [], 40 | }); 41 | isOpen.current = false; 42 | resolver.current = undefined; 43 | }, delay) as unknown as NodeJS.Timeout; 44 | } 45 | }, []); 46 | 47 | const handleChange = useCallback( 48 | async (event: React.ChangeEvent) => { 49 | if (timer.current) clearTimeout(timer.current); 50 | 51 | const files: OpenFilesResult['files'] = []; 52 | 53 | if (event.target.files) { 54 | for (let i = 0; i < event.target.files.length; i++) { 55 | const item = event.target.files.item(i); 56 | 57 | if (item) { 58 | files.push({ 59 | filePath: item?.name, 60 | contents: await item.text(), 61 | }); 62 | } 63 | } 64 | } 65 | 66 | resolver.current?.({ 67 | canceled: false, 68 | files, 69 | }); 70 | 71 | isOpen.current = false; 72 | resolver.current = undefined; 73 | }, 74 | [], 75 | ); 76 | 77 | return ( 78 | 91 | ); 92 | }, 93 | ); 94 | -------------------------------------------------------------------------------- /src/icons/base.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Feather Icons originally from https://feathericons.com/ 3 | * 4 | * The MIT License (MIT) 5 | * 6 | * Copyright (c) 2013-2017 Cole Bemis 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy 9 | * of this software and associated documentation files (the "Software"), to deal 10 | * in the Software without restriction, including without limitation the rights 11 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | * copies of the Software, and to permit persons to whom the Software is 13 | * furnished to do so, subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in all 16 | * copies or substantial portions of the Software. 17 | * 18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | * SOFTWARE. 25 | */ 26 | 27 | import './base.less'; 28 | 29 | import React, { ReactNode } from 'react'; 30 | 31 | export interface IconProps { 32 | size?: number; 33 | color?: string; 34 | strokeWidth?: number; 35 | x?: React.ReactText; 36 | y?: React.ReactText; 37 | } 38 | 39 | export interface IconFactory { 40 | (displayName: string, children: ReactNode): React.VFC; 41 | } 42 | 43 | export const icon: IconFactory = (displayName: string, children: ReactNode) => { 44 | const component: React.VFC = ({ 45 | size = 24, 46 | strokeWidth = 2, 47 | x = 0, 48 | y = 0, 49 | }) => ( 50 | 51 | 62 | {children} 63 | 64 | 65 | ); 66 | 67 | component.displayName = displayName; 68 | 69 | return component; 70 | }; 71 | 72 | const Translate: React.FC> = ({ 73 | x, 74 | y, 75 | children, 76 | }) => { 77 | return x || y ? ( 78 | {children} 79 | ) : ( 80 | <>{children} 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/colors.less: -------------------------------------------------------------------------------- 1 | // See: https://coolors.co/f4f1de-e07a5f-3d405b-81b29a-f2cc8f 2 | @eggshell: #f4f1de; 3 | @terra-cotta: #e07a5f; 4 | @independence: #3d405b; 5 | @green-sheen: #81b29a; 6 | @deep-champagne: #f2cc8f; 7 | @dark-gray: #222222; 8 | @light-gray: #888888; 9 | 10 | ///// WINDOW ///// 11 | 12 | @color-window-background: @eggshell; 13 | @color-window-text-default: @dark-gray; 14 | 15 | ///// ICONS ///// 16 | 17 | @color-icon-default: @independence; 18 | 19 | ///// GENERIC BUTTON ///// 20 | 21 | @color-button-background: @eggshell; 22 | @color-button-border: @independence; 23 | @color-button-hover-background: @deep-champagne; 24 | 25 | ///// NODES ///// 26 | 27 | @color-node-background: @green-sheen; 28 | @color-node-border: @independence; 29 | @color-node-text: @dark-gray; 30 | 31 | @color-selected-node-background: @green-sheen; 32 | @color-selected-node-border: @terra-cotta; 33 | @color-selected-node-text: @dark-gray; 34 | 35 | ///// NODE PREVIEWS ///// 36 | 37 | @color-node-preview-background: @green-sheen; 38 | @color-node-preview-border: @independence; 39 | @color-node-preview-text: @dark-gray; 40 | 41 | @color-node-preview-button-background: @green-sheen; 42 | @color-node-preview-button-icon: @independence; 43 | @color-node-preview-button-hover-background: @deep-champagne; 44 | @color-node-preview-button-hover-icon: @independence; 45 | 46 | ///// EDGES ///// 47 | 48 | @color-edge-background: @eggshell; 49 | @color-edge-border: @independence; 50 | @color-edge-chevron: @independence; 51 | 52 | @color-selected-edge-background: @eggshell; 53 | @color-selected-edge-border: @terra-cotta; 54 | @color-selected-edge-chevron: @independence; 55 | 56 | ///// EDGE PREVIEWS ///// 57 | 58 | @color-edge-preview-background: @eggshell; 59 | @color-edge-preview-border: @independence; 60 | @color-edge-preview-text: @dark-gray; 61 | 62 | ///// RADIAL MENUS ///// 63 | 64 | @color-radial-menu-background: @deep-champagne; 65 | @color-radial-menu-border: @independence; 66 | @color-radial-menu-icon: @independence; 67 | 68 | @color-radial-menu-hover-background: @deep-champagne; 69 | @color-radial-menu-hover-border: @independence; 70 | @color-radial-menu-hover-icon: @independence; 71 | 72 | ///// SEARCH ///// 73 | 74 | @color-search-input-background: white; 75 | @color-search-input-foreground: @dark-gray; 76 | @color-search-input-placeholder: @light-gray; 77 | 78 | ///// TOOLBAR ///// 79 | @color-toolbar-background: @color-window-background; 80 | @color-toolbar-foreground: @color-window-text-default; 81 | @color-toolbar-active-background: @deep-champagne; 82 | @color-toolbar-active-foreground: @color-window-text-default; 83 | 84 | ///// DATA PROVIDER ///// 85 | 86 | @color-drop-target-border: @independence; 87 | @color-drop-target-ready-background: @deep-champagne; 88 | -------------------------------------------------------------------------------- /src/search/fast-fuzzy.ts: -------------------------------------------------------------------------------- 1 | import { GraphState } from '../state/graph'; 2 | import { Result, SearchEngine } from './types'; 3 | 4 | import { Searcher } from 'fast-fuzzy'; 5 | 6 | export class FastFuzzySearchEngine implements SearchEngine { 7 | private typeNames: Searcher | null; 8 | private fieldNames: Searcher | null; 9 | private argNames: Searcher | null; 10 | 11 | index(graph: GraphState): void { 12 | const typeNames = Object.keys(graph.nodes); 13 | const fieldNames = Object.keys(graph.fields); 14 | const argNames = Object.keys(graph.args); 15 | 16 | this.typeNames = new Searcher(typeNames); 17 | this.fieldNames = new Searcher(fieldNames); 18 | this.argNames = new Searcher(argNames); 19 | } 20 | search(query: string): Result[] { 21 | const typeMatches = 22 | this.typeNames?.search(query, { 23 | returnMatchData: true, 24 | threshold: 0.6, 25 | }) || []; 26 | 27 | const fieldMatches = 28 | this.fieldNames?.search(query, { 29 | returnMatchData: true, 30 | threshold: 0.6, 31 | }) || []; 32 | 33 | const argMatches = 34 | this.argNames?.search(query, { 35 | returnMatchData: true, 36 | threshold: 0.6, 37 | }) || []; 38 | 39 | const typeResults = typeMatches.map( 40 | (r) => 41 | ({ 42 | id: r.original, 43 | kind: 'Type', 44 | score: (3 * (r.score * query.length)) / r.original.length, 45 | matchData: [ 46 | { 47 | field: 'name', 48 | locations: [{ offset: r.match.index, length: r.match.length }], 49 | }, 50 | ], 51 | } as Result), 52 | ); 53 | 54 | const fieldResults = fieldMatches.map( 55 | (r) => 56 | ({ 57 | id: r.original, 58 | kind: 'Field', 59 | score: (2 * (r.score * query.length)) / r.original.length, 60 | matchData: [ 61 | { 62 | field: 'name', 63 | locations: [{ offset: r.match.index, length: r.match.length }], 64 | }, 65 | ], 66 | } as Result), 67 | ); 68 | 69 | const argResults = argMatches.map( 70 | (r) => 71 | ({ 72 | id: r.original, 73 | kind: 'Arg', 74 | score: (r.score * query.length) / r.original.length, 75 | matchData: [ 76 | { 77 | field: 'name', 78 | locations: [{ offset: r.match.index, length: r.match.length }], 79 | }, 80 | ], 81 | } as Result), 82 | ); 83 | 84 | return [...typeResults, ...fieldResults, ...argResults].sort( 85 | (a, b) => b.score - a.score, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "domain-graph", 3 | "version": "0.5.2", 4 | "description": "Beautiful interactive visualizations for GraphQL schemas", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "umd" 9 | ], 10 | "scripts": { 11 | "test": "jest", 12 | "start": "webpack serve --config webpack.dev.js", 13 | "prebuild": "npm run lint && rimraf lib/* umd", 14 | "build": "tsc -p tsconfig.build.json && webpack --config webpack.umd.js && copyfiles --up 1 \"./src/**/*.less\" lib", 15 | "postbuild": "rimraf ./lib/bootstrap ./lib/umd", 16 | "lint": "eslint ./src/**/*.ts ./src/**/*.tsx ./*.js && prettier -c .", 17 | "fix": "eslint ./src/**/*.ts ./src/**/*.tsx ./*.js --fix && prettier -w .", 18 | "pretest": "rimraf coverage/*", 19 | "less": "lessc ./src/**/*.less ./lib/main.css", 20 | "prepack": "npm run build", 21 | "create-snapshot": "ts-node ./src/tools/snapshot/create-snapshot.ts" 22 | }, 23 | "keywords": [], 24 | "author": "Steve Konves", 25 | "license": "MIT", 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/domain-graph/domain-graph.git" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/domain-graph/domain-graph/issues" 32 | }, 33 | "homepage": "https://github.com/domain-graph/domain-graph#readme", 34 | "devDependencies": { 35 | "@testing-library/react": "^11.2.2", 36 | "@testing-library/react-hooks": "^3.7.0", 37 | "@types/d3": "^6.2.0", 38 | "@types/d3-force": "^2.1.0", 39 | "@types/jest": "^26.0.19", 40 | "@types/node": "^14.14.14", 41 | "@types/react": "^17.0.0", 42 | "@types/react-dom": "^17.0.0", 43 | "@types/react-redux": "^7.1.14", 44 | "@typescript-eslint/eslint-plugin": "^5.4.0", 45 | "@typescript-eslint/parser": "^5.4.0", 46 | "copyfiles": "^2.4.1", 47 | "css-hot-loader": "^1.4.4", 48 | "css-loader": "^6.5.1", 49 | "eslint": "^8.3.0", 50 | "eslint-config-prettier": "^8.3.0", 51 | "eslint-plugin-import": "^2.25.3", 52 | "eslint-plugin-react-hooks": "^4.3.0", 53 | "eslint-plugin-unused-imports": "^2.0.0", 54 | "html-webpack-plugin": "^5.5.0", 55 | "jest": "^26.6.3", 56 | "less": "^4.1.2", 57 | "less-loader": "^10.2.0", 58 | "mini-css-extract-plugin": "^2.4.5", 59 | "prettier": "^2.2.1", 60 | "react": "^17.0.1", 61 | "react-dom": "^17.0.1", 62 | "react-test-renderer": "^17.0.0", 63 | "rimraf": "^3.0.2", 64 | "ts-jest": "^26.4.4", 65 | "ts-loader": "^9.2.6", 66 | "ts-node": "^10.4.0", 67 | "typescript": "^4.1.3", 68 | "webpack": "^5.64.4", 69 | "webpack-cli": "^4.9.1", 70 | "webpack-dev-server": "^4.6.0", 71 | "webpack-merge": "^5.8.0" 72 | }, 73 | "dependencies": { 74 | "d3": "^6.3.1", 75 | "d3-force": "^2.1.1", 76 | "fast-fuzzy": "^1.10.10", 77 | "flux-standard-functions": "^0.2.0", 78 | "graphql": "^15.4.0", 79 | "react-redux": "^7.2.2", 80 | "redux": "^4.0.5", 81 | "redux-thunk": "^2.3.0", 82 | "ts-registry": "^1.0.3" 83 | }, 84 | "peerDependencies": { 85 | "react": "^17.0.0", 86 | "react-dom": "^17.0.0" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/graph/spotlight.less: -------------------------------------------------------------------------------- 1 | .c-spotlight { 2 | overflow: scroll; 3 | &::-webkit-scrollbar { 4 | width: 0 !important; 5 | } 6 | overflow: -moz-scrollbars-none; 7 | -ms-overflow-style: none; 8 | display: block; 9 | position: absolute; 10 | 11 | max-height: 100vh; 12 | width: 400px; 13 | 14 | top: 0; 15 | right: 0; 16 | font-family: sans-serif; 17 | 18 | li { 19 | list-style-type: none; 20 | } 21 | 22 | ul { 23 | padding: 0; 24 | } 25 | 26 | .description, 27 | .notice { 28 | font-size: 90%; 29 | opacity: 90%; 30 | } 31 | 32 | .controls { 33 | position: absolute; 34 | 35 | .c-icon-button:hover { 36 | background-color: @color-node-preview-button-hover-background; 37 | stroke: @color-node-preview-button-hover-icon; 38 | } 39 | } 40 | 41 | .node-spotlight { 42 | position: relative; 43 | border: 2px solid @color-node-preview-border; 44 | background-color: @color-node-preview-background; 45 | color: @color-node-preview-text; 46 | margin: 10px; 47 | 48 | border-radius: 30px; 49 | padding: 16px 20px; 50 | 51 | li.edge.field { 52 | padding: 6px 8px 6px 8px; 53 | margin: -2px -8px -2px -8px; 54 | 55 | svg.c-icon { 56 | margin-right: 2px; 57 | margin-bottom: -6px; 58 | } 59 | 60 | transition: background-color 100ms ease-in-out; 61 | 62 | &.selected { 63 | border-left: 4px solid @color-selected-edge-border; 64 | } 65 | 66 | &:hover { 67 | cursor: pointer; 68 | background-color: @color-node-preview-button-hover-background; 69 | } 70 | 71 | .description { 72 | text-overflow: ellipsis; 73 | overflow: hidden; 74 | white-space: nowrap; 75 | } 76 | } 77 | 78 | .controls { 79 | top: 4px; 80 | right: 16px; 81 | 82 | .c-icon-button { 83 | background-color: @color-node-preview-button-background; 84 | stroke: @color-node-preview-button-icon; 85 | } 86 | } 87 | } 88 | 89 | .edge-spotlight { 90 | position: relative; 91 | border: 2px solid @color-edge-preview-border; 92 | background-color: @color-edge-preview-background; 93 | color: @color-edge-preview-text; 94 | 95 | border-radius: 5px; 96 | padding: 10px 20px; 97 | margin: 16px 30px; 98 | 99 | .controls { 100 | top: 1px; 101 | right: 1px; 102 | .c-icon-button { 103 | stroke: @color-node-preview-button-icon; 104 | } 105 | } 106 | } 107 | 108 | .enum-values, 109 | .input-fields { 110 | margin-top: 4px; 111 | margin-left: 8px; 112 | .deprecated { 113 | .name, 114 | .description { 115 | text-decoration: line-through; 116 | } 117 | 118 | .notice { 119 | padding: 0 8px 0 8px; 120 | font-style: italic; 121 | } 122 | } 123 | 124 | li.enum-value, 125 | li.input-field { 126 | margin-bottom: 4px; 127 | } 128 | } 129 | 130 | .field { 131 | padding-bottom: 8px; 132 | } 133 | 134 | h1 { 135 | text-align: center; 136 | font-size: 18px; 137 | display: flex; 138 | justify-content: center; 139 | 140 | svg.c-icon { 141 | margin-right: 4px; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/state/graph/graph-actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Arg, 3 | Edge, 4 | Enum, 5 | EnumValue, 6 | Field, 7 | Input, 8 | Node, 9 | InputField, 10 | VisibleNode, 11 | } from './types'; 12 | import { SaveState } from '../../persistence'; 13 | 14 | export const importState = ( 15 | nodes: Node[], 16 | edges: Edge[], 17 | fields: Field[], 18 | args: Arg[], 19 | enums: Enum[], 20 | enumValues: EnumValue[], 21 | inputs: Input[], 22 | inputFields: InputField[], 23 | visibleNodes: VisibleNode[], 24 | plugins: string[], 25 | activePlugins: string[], 26 | ) => ({ 27 | type: 'graph/import_state' as const, 28 | payload: { 29 | args, 30 | nodes, 31 | edges, 32 | fields, 33 | enums, 34 | enumValues, 35 | inputs, 36 | inputFields, 37 | visibleNodes, 38 | plugins, 39 | activePlugins, 40 | }, 41 | }); 42 | 43 | export const importSaveState = (state: SaveState) => ({ 44 | type: 'graph/import_save_state' as const, 45 | payload: state, 46 | }); 47 | 48 | export const hideAllNodes = () => ({ 49 | type: 'graph/hide_all_nodes' as const, 50 | }); 51 | 52 | export const hideUnpinnedNodes = () => ({ 53 | type: 'graph/hide_unpinned_nodes' as const, 54 | }); 55 | 56 | export const expandNode = (nodeId: string) => ({ 57 | type: 'graph/expand_node' as const, 58 | payload: nodeId, 59 | }); 60 | 61 | export const pinNode = (nodeId: string, x: number, y: number) => ({ 62 | type: 'graph/pin_node' as const, 63 | payload: { nodeId, x, y }, 64 | }); 65 | 66 | export const unpinNode = (nodeId: string) => ({ 67 | type: 'graph/unpin_node' as const, 68 | payload: nodeId, 69 | }); 70 | 71 | export const updateNodeLocation = (nodeId: string, x: number, y: number) => ({ 72 | type: 'graph/update_node_location' as const, 73 | payload: { nodeId, x, y }, 74 | }); 75 | 76 | export const updateNodeLocations = ( 77 | nodes: Record, 78 | ) => ({ 79 | type: 'graph/update_node_locations' as const, 80 | payload: nodes, 81 | }); 82 | 83 | export const hideNode = (nodeId: string) => ({ 84 | type: 'graph/hide_node' as const, 85 | payload: nodeId, 86 | }); 87 | 88 | export const showNode = (nodeId: string) => ({ 89 | type: 'graph/show_node' as const, 90 | payload: nodeId, 91 | }); 92 | 93 | export const selectNode = (nodeId: string) => ({ 94 | type: 'graph/select_node' as const, 95 | payload: nodeId, 96 | }); 97 | 98 | export const deselectNode = (nodeId: string) => ({ 99 | type: 'graph/deselect_node' as const, 100 | payload: nodeId, 101 | }); 102 | 103 | export const selectField = (fieldId: string) => ({ 104 | type: 'graph/select_field' as const, 105 | payload: fieldId, 106 | }); 107 | 108 | export const deselectField = (fieldId: string) => ({ 109 | type: 'graph/deselect_field' as const, 110 | payload: fieldId, 111 | }); 112 | 113 | export type GraphAction = 114 | | ReturnType 115 | | ReturnType 116 | | ReturnType 117 | | ReturnType 118 | | ReturnType 119 | | ReturnType 120 | | ReturnType 121 | | ReturnType 122 | | ReturnType 123 | | ReturnType 124 | | ReturnType 125 | | ReturnType 126 | | ReturnType 127 | | ReturnType 128 | | ReturnType; 129 | -------------------------------------------------------------------------------- /src/svg-canvas/use-drag.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | interface DragState { 4 | element: HTMLElement | SVGElement | null; 5 | beginX: number; 6 | beginY: number; 7 | currentX: number; 8 | currentY: number; 9 | } 10 | 11 | export interface DragOptions { 12 | onMove?: (values: MoveValues) => void; 13 | onBegin?: (values: BeginValues) => void; 14 | onEnd?: (values: EndValues) => void; 15 | } 16 | 17 | export interface MoveValues { 18 | beginX: number; 19 | beginY: number; 20 | currentX: number; 21 | currentY: number; 22 | dx: number; 23 | dy: number; 24 | } 25 | export interface BeginValues { 26 | beginX: number; 27 | beginY: number; 28 | } 29 | export interface EndValues { 30 | beginX: number; 31 | beginY: number; 32 | endX: number; 33 | endY: number; 34 | } 35 | 36 | export function useDrag( 37 | element: HTMLElement | SVGElement | null, 38 | options?: DragOptions, 39 | ) { 40 | const state = useRef({ 41 | element: null, 42 | beginX: 0, 43 | beginY: 0, 44 | currentX: 0, 45 | currentY: 0, 46 | }); 47 | 48 | const callbacks = useRef(options || {}); 49 | useEffect(() => { 50 | callbacks.current = options || {}; 51 | }, [options]); 52 | 53 | const handleMouseMove = useRef((e: MouseEvent) => { 54 | if (state.current) { 55 | const newX = e.offsetX; 56 | const newY = e.offsetY; 57 | 58 | const { beginX, beginY, currentX, currentY } = state.current; 59 | 60 | state.current.currentX = newX; 61 | state.current.currentY = newY; 62 | 63 | callbacks.current.onMove?.({ 64 | beginX: beginX, 65 | beginY: beginY, 66 | currentX: newX, 67 | currentY: newY, 68 | dx: newX - currentX, 69 | dy: newY - currentY, 70 | }); 71 | } 72 | }); 73 | 74 | const handleMouseUp = useRef((e: MouseEvent) => { 75 | const { beginX, beginY } = state.current; 76 | callbacks.current.onEnd?.({ 77 | beginX, 78 | beginY, 79 | endX: e.offsetX, 80 | endY: e.offsetY, 81 | }); 82 | 83 | document.removeEventListener('mouseup', handleMouseUp.current); 84 | state.current.element?.removeEventListener( 85 | 'mousemove', 86 | handleMouseMove.current, 87 | ); 88 | }); 89 | 90 | const handleMouseDown = useRef((e: MouseEvent) => { 91 | if (e.button === 0 && e.target === state.current.element) { 92 | state.current.beginX = e.offsetX; 93 | state.current.beginY = e.offsetY; 94 | state.current.currentX = e.offsetX; 95 | state.current.currentY = e.offsetY; 96 | 97 | callbacks.current.onBegin?.({ beginX: e.offsetX, beginY: e.offsetY }); 98 | 99 | document.addEventListener('mouseup', handleMouseUp.current); 100 | state.current.element?.addEventListener( 101 | 'mousemove', 102 | handleMouseMove.current, 103 | ); 104 | } 105 | }); 106 | 107 | useEffect(() => { 108 | if (element) { 109 | state.current.element = element; 110 | const onMouseDown = handleMouseDown.current; 111 | const onMouseUp = handleMouseUp.current; 112 | const mousemove = handleMouseMove.current; 113 | 114 | element.addEventListener('mousedown', onMouseDown); 115 | return () => { 116 | // This is fine 117 | // eslint-disable-next-line react-hooks/exhaustive-deps 118 | state.current.element = null; 119 | element.removeEventListener('mousedown', onMouseDown); 120 | element.removeEventListener('mouseup', onMouseUp); 121 | element.removeEventListener('mousemove', mousemove); 122 | }; 123 | } else { 124 | return undefined; 125 | } 126 | }, [element]); 127 | } 128 | -------------------------------------------------------------------------------- /src/svg-canvas/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useRef, 4 | useState, 5 | forwardRef, 6 | useImperativeHandle, 7 | } from 'react'; 8 | 9 | import { useDrag } from './use-drag'; 10 | import { useResize } from './use-resize'; 11 | import { useZoom } from './use-zoom'; 12 | 13 | export interface SvgCanvasProps { 14 | className?: string; 15 | style?: React.CSSProperties; 16 | } 17 | 18 | interface State { 19 | wrapper: HTMLDivElement | null; 20 | canvas: SVGSVGElement | null; 21 | transformGroup: SVGGElement | null; 22 | postX: number; 23 | postY: number; 24 | preX: number; 25 | preY: number; 26 | scale: number; 27 | width: number; 28 | height: number; 29 | } 30 | 31 | export interface SvgCanvasMethods { 32 | resetZoom(): void; 33 | fitAll(): void; 34 | } 35 | 36 | export const SvgCanvas = forwardRef< 37 | SvgCanvasMethods, 38 | React.PropsWithChildren 39 | >(({ className, style, children }, forwardedRef) => { 40 | const [isDragging, setIsDragging] = useState(false); 41 | const state = useRef({ 42 | wrapper: null, 43 | canvas: null, 44 | transformGroup: null, 45 | postX: 0, 46 | postY: 0, 47 | preX: 0, 48 | preY: 0, 49 | scale: 1, 50 | width: 300, 51 | height: 150, 52 | }); 53 | 54 | const divRef = useRef(null); 55 | 56 | useImperativeHandle(forwardedRef, () => ({ 57 | resetZoom: () => { 58 | setZoom(1); 59 | 60 | state.current.preX = 0; 61 | state.current.preY = 0; 62 | 63 | state.current.postX = 0; 64 | state.current.postY = 0; 65 | 66 | state.current.scale = 1; 67 | 68 | updateFn.current(); 69 | }, 70 | fitAll: () => { 71 | console.log('fitAll'); 72 | }, 73 | })); 74 | 75 | const [canvas, setCanvas] = useState(null); 76 | const canvasRef = useCallback((element: SVGSVGElement) => { 77 | setCanvas(element); 78 | state.current.canvas = element; 79 | }, []); 80 | 81 | const transformGroupRef = useCallback((element: SVGGElement) => { 82 | state.current.transformGroup = element; 83 | }, []); 84 | 85 | const updateFn = useRef(() => { 86 | requestAnimationFrame(() => { 87 | const { width, height, postX, postY, preX, preY, scale } = state.current; 88 | 89 | state.current.canvas?.setAttribute('viewBox', `0 0 ${width} ${height}`); 90 | state.current.canvas?.setAttribute('width', `${width}px`); 91 | state.current.canvas?.setAttribute('height', `${height}px`); 92 | 93 | const pre = `translate(${width / 2 + preX} ${height / 2 + preY})`; 94 | const zoom = `scale(${round1000(scale)})`; 95 | const post = `translate(${-round10(postX)} ${-round10(postY)})`; 96 | 97 | state.current.transformGroup?.setAttribute( 98 | 'transform', 99 | pre + zoom + post, 100 | ); 101 | }); 102 | }); 103 | 104 | useDrag(canvas, { 105 | onMove: ({ dx, dy }) => { 106 | state.current.preX += dx; 107 | state.current.preY += dy; 108 | 109 | updateFn.current(); 110 | }, 111 | onBegin: () => setIsDragging(true), 112 | onEnd: () => setIsDragging(false), 113 | }); 114 | 115 | const setZoom = useZoom(canvas, ({ value, x, y }) => { 116 | const centerX = state.current.width / 2; 117 | const centerY = state.current.height / 2; 118 | 119 | const newPreX = x - centerX; 120 | const newPreY = y - centerY; 121 | 122 | const dx = newPreX - state.current.preX; 123 | const dy = newPreY - state.current.preY; 124 | 125 | state.current.preX = newPreX; 126 | state.current.preY = newPreY; 127 | 128 | state.current.postX += dx / state.current.scale; 129 | state.current.postY += dy / state.current.scale; 130 | 131 | state.current.scale = value; 132 | 133 | updateFn.current(); 134 | }); 135 | 136 | useResize(divRef.current, ({ width, height }) => { 137 | state.current.width = width; 138 | state.current.height = height; 139 | 140 | updateFn.current(); 141 | }); 142 | 143 | return ( 144 |
    151 | 152 | {children} 153 | 154 |
    155 | ); 156 | }); 157 | 158 | function round10(value: number): number { 159 | return Math.round(value * 10.0) / 10.0; 160 | } 161 | 162 | function round1000(value: number): number { 163 | return Math.round(value * 1000.0) / 1000.0; 164 | } 165 | -------------------------------------------------------------------------------- /src/graph/domain-object.tsx: -------------------------------------------------------------------------------- 1 | import './domain-object.less'; 2 | 3 | import React, { useCallback, useRef, useState } from 'react'; 4 | 5 | import { useNodeSubscriber } from '../simulation'; 6 | import { EyeOff, Graph, Lock, Unlock } from '../icons'; 7 | import { CircleButton } from '../svg-button'; 8 | import { RadialMenu } from './radial-menu'; 9 | import { useDispatch } from '../state'; 10 | import { 11 | expandNode, 12 | hideNode, 13 | updateNodeLocation, 14 | pinNode, 15 | selectNode, 16 | unpinNode, 17 | } from '../state/graph/graph-actions'; 18 | import { 19 | useIsPinned, 20 | useSelectedSourceNodeId, 21 | useSelectedTargetNodeId, 22 | } from '../state/graph/hooks'; 23 | 24 | export const DomainObject: React.FC<{ nodeId: string }> = ({ nodeId }) => { 25 | const dispatch = useDispatch(); 26 | const isPinned = useIsPinned(nodeId); 27 | const sourceId = useSelectedSourceNodeId(); 28 | const targetId = useSelectedTargetNodeId(); 29 | 30 | const dragStart = useRef<{ x: number; y: number }>(); 31 | const location = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); 32 | 33 | const handleClickHide = useCallback( 34 | () => dispatch(hideNode(nodeId)), 35 | [nodeId, dispatch], 36 | ); 37 | 38 | const handleClickPin = useCallback(() => { 39 | const { x, y } = location.current; 40 | dispatch(isPinned ? unpinNode(nodeId) : pinNode(nodeId, x, y)); 41 | }, [nodeId, isPinned, dispatch]); 42 | 43 | const handleClickExpand = useCallback( 44 | () => dispatch(expandNode(nodeId)), 45 | [nodeId, dispatch], 46 | ); 47 | 48 | const handleClickSelect = useCallback(() => { 49 | if (targetId || nodeId !== sourceId) dispatch(selectNode(nodeId)); 50 | }, [nodeId, sourceId, targetId, dispatch]); 51 | 52 | const isSelected = nodeId === sourceId || nodeId === targetId; 53 | 54 | const handle = useRef(null); 55 | const controls = useRef(null); 56 | 57 | const [isDragging, setIsDragging] = useState(false); 58 | 59 | useNodeSubscriber(nodeId, (event, { x, y }) => { 60 | if (event === 'dragstart') { 61 | dragStart.current = { x, y }; 62 | setIsDragging(true); 63 | if (!isPinned) dispatch(pinNode(nodeId, x, y)); 64 | } else if (event === 'dragend') { 65 | setIsDragging(false); 66 | if ( 67 | dragStart.current && 68 | (x !== dragStart.current.x || y !== dragStart.current.y) 69 | ) { 70 | dispatch(updateNodeLocation(nodeId, x, y)); 71 | } 72 | dragStart.current = undefined; 73 | } 74 | 75 | location.current = { x, y }; 76 | 77 | if (handle.current && controls.current && event === 'tick') { 78 | handle.current.setAttribute('transform', `translate(${x} ${y})`); 79 | controls.current.setAttribute('transform', `translate(${x} ${y})`); 80 | } 81 | }); 82 | 83 | const [showControls, setShowControls] = useState(null); 84 | 85 | const handleMouseEnter = useCallback(() => { 86 | setShowControls(true); 87 | }, []); 88 | const handleMouseLeave = useCallback(() => { 89 | setShowControls(false); 90 | }, []); 91 | 92 | return ( 93 | 100 | 101 | 102 | 108 | 109 | 110 | 111 | 112 | {isPinned ? ( 113 | 114 | ) : ( 115 | 116 | )} 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 130 | 131 | {nodeId} 132 | 133 | 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /src/data-provider.tsx: -------------------------------------------------------------------------------- 1 | import './data-provider.less'; 2 | 3 | import { parse as parseDocument, DocumentNode } from 'graphql'; 4 | import React, { useCallback, useState } from 'react'; 5 | import { AlertTriangle, Folder, UploadCloud } from './icons'; 6 | import { Button } from './components/button'; 7 | 8 | export interface OpenFilesResult { 9 | canceled: boolean; 10 | files: { 11 | filePath: string; 12 | contents: string; 13 | }[]; 14 | } 15 | 16 | export interface DataProviderProps { 17 | onShowOpenDialog?: () => Promise; 18 | onDrop?: (filename: string, contents: string) => Promise; 19 | children: (data: DocumentNode) => React.ReactElement; 20 | } 21 | 22 | export const DataProvider: React.VFC = ({ 23 | onDrop, 24 | children, 25 | onShowOpenDialog, 26 | }) => { 27 | const [data, setData] = useState(null); 28 | 29 | const [dropReady, setDropReady] = useState(false); 30 | const [parseErrors, setParseErrors] = useState([]); 31 | 32 | const handleDragOver = useCallback( 33 | (event: React.DragEvent) => { 34 | event.stopPropagation(); 35 | event.preventDefault(); 36 | }, 37 | [], 38 | ); 39 | 40 | const handleDrop = useCallback( 41 | async (event: React.DragEvent) => { 42 | // Prevent default behavior (Prevent file from being opened) 43 | event.preventDefault(); 44 | 45 | const file = event.dataTransfer.files[0]; 46 | 47 | const arrayBuffer = await file.arrayBuffer(); 48 | 49 | const text = new TextDecoder().decode(arrayBuffer); 50 | 51 | if (onDrop && (await onDrop(file.name, text))) { 52 | const { documentNode, errors } = parse(text); 53 | 54 | setParseErrors(errors); 55 | setData(documentNode); 56 | } 57 | }, 58 | [onDrop], 59 | ); 60 | 61 | const handleClickOpen = useCallback(async () => { 62 | setParseErrors([]); 63 | const result = await onShowOpenDialog?.(); 64 | 65 | if (result && !result.canceled && result.files.length) { 66 | const text = result.files[0].contents; 67 | 68 | const { documentNode, errors } = parse(text); 69 | 70 | setParseErrors(errors); 71 | setData(documentNode); 72 | } 73 | }, [onShowOpenDialog]); 74 | 75 | if (data) return children(data); 76 | 77 | return ( 78 |
    setDropReady(true)} 82 | onDragLeave={() => setDropReady(false)} 83 | onDrop={handleDrop} 84 | > 85 | 86 |

    Drop a schema file here to get started!

    87 | 88 |

    89 | To get a schema file, run the Apollo introspection query. Save the 90 | results and drag the file into this box. 91 |

    92 | {!!onShowOpenDialog && ( 93 | 97 | )} 98 | {!!parseErrors.length && ( 99 |
      100 | {parseErrors.map((parseError) => ( 101 |
    • 102 | 103 | {parseError.message} 104 |
    • 105 | ))} 106 |
    107 | )} 108 |
    109 | ); 110 | }; 111 | 112 | type ParseError = { 113 | message: string; 114 | }; 115 | 116 | function parse(str: string): { 117 | documentNode: DocumentNode | null; 118 | errors: readonly ParseError[]; 119 | } { 120 | const errors: ParseError[] = []; 121 | 122 | let documentNode: DocumentNode | null = null; 123 | 124 | try { 125 | documentNode = parseDocument(str); 126 | } catch (firstEx) { 127 | try { 128 | documentNode = parseDocument(str + federationSchema); 129 | } catch (secondEx) { 130 | console.error(firstEx); 131 | console.error(secondEx); 132 | return { 133 | documentNode: null, 134 | errors: [ 135 | { 136 | message: 'Not a valid schema', 137 | }, 138 | ], 139 | }; 140 | } 141 | } 142 | 143 | return { 144 | documentNode, 145 | errors, 146 | }; 147 | } 148 | 149 | // see: https://www.apollographql.com/docs/federation/federation-spec/ 150 | const federationSchema = ` 151 | scalar _FieldSet 152 | 153 | directive @external on FIELD_DEFINITION 154 | directive @requires(fields: _FieldSet!) on FIELD_DEFINITION 155 | directive @provides(fields: _FieldSet!) on FIELD_DEFINITION 156 | directive @key(fields: _FieldSet!) on OBJECT | INTERFACE 157 | directive @extends on OBJECT | INTERFACE 158 | `; 159 | -------------------------------------------------------------------------------- /src/graph/radial-menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useRef } from 'react'; 2 | 3 | export interface RadialMenuProps { 4 | isVisible: boolean | null; 5 | radius: number; 6 | spread: number; 7 | margin: number; 8 | } 9 | 10 | export const RadialMenu: React.FC = (props) => { 11 | const { children, ...itemProps } = props; 12 | 13 | const count = React.Children.count(children); 14 | 15 | return ( 16 | 21 | {props.children && 22 | React.Children.map(props.children, (_, i) => ( 23 | 24 | ))} 25 | {props.children && 26 | React.Children.map(props.children, (child, i) => ( 27 | 28 | {child} 29 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | interface MenuItemProps extends RadialMenuProps { 36 | index: number; 37 | count: number; 38 | } 39 | 40 | const MenuItem: React.FC = ({ 41 | index, 42 | count, 43 | radius, 44 | spread, 45 | children, 46 | isVisible, 47 | }) => { 48 | const g = useRef(null); 49 | 50 | useLayoutEffect(() => { 51 | const angle = getAngle(count, index, spread); 52 | const tick = (v: number) => { 53 | g.current?.setAttribute( 54 | 'transform', 55 | `rotate(${angle}) translate(0 ${-v * radius}) rotate(${-angle})`, 56 | ); 57 | g.current?.setAttribute('opacity', `${v}`); 58 | }; 59 | let cancel: () => void; 60 | if (isVisible) { 61 | cancel = enter({ tick }); 62 | } else if (g.current?.transform?.baseVal?.numberOfItems) { 63 | cancel = exit({ tick }); 64 | } 65 | return () => { 66 | cancel?.(); 67 | }; 68 | }, [isVisible, count, index, radius, spread]); 69 | 70 | return {children}; 71 | }; 72 | 73 | const Margin: React.FC = ({ 74 | index, 75 | count, 76 | radius, 77 | spread, 78 | isVisible, 79 | margin, 80 | }) => { 81 | const line = useRef(null); 82 | 83 | useLayoutEffect(() => { 84 | const angle = getAngle(count, index, spread); 85 | const tick = (v: number) => { 86 | line.current?.setAttribute('y2', `${-v * radius}`); 87 | line.current?.setAttribute('transform', `rotate(${angle})`); 88 | }; 89 | let cancel: () => void; 90 | if (isVisible) { 91 | cancel = enter({ tick }); 92 | } else if (line.current?.transform?.baseVal?.numberOfItems) { 93 | cancel = exit({ tick }); 94 | } 95 | return () => { 96 | cancel?.(); 97 | }; 98 | }, [isVisible, count, index, radius, spread]); 99 | 100 | return ( 101 | 110 | ); 111 | }; 112 | 113 | const enter = (params: Pick) => 114 | tween({ duration: 75, easing: linear, ...params }); 115 | const exit = (params: Pick) => 116 | tween({ duration: 75, easing: linear, reverse: true, ...params }); 117 | 118 | interface TweenOptions { 119 | delay?: number; 120 | duration: number; 121 | easing: (t: number) => number; 122 | reverse?: boolean; 123 | start?: (value: number) => void; 124 | tick?: (value: number) => void; 125 | done?: (value: number) => void; 126 | } 127 | 128 | function tween({ 129 | delay = 0, 130 | duration, 131 | easing, 132 | reverse, 133 | start, 134 | tick, 135 | done, 136 | }: TweenOptions): () => void { 137 | let isCanceled = false; 138 | setTimeout(() => { 139 | const s = performance.now(); 140 | 141 | start?.(reverse ? 1 : 0); 142 | 143 | const doit = () => { 144 | if (!isCanceled) { 145 | requestAnimationFrame(() => { 146 | const now = performance.now(); 147 | 148 | if (now - s > duration) { 149 | tick?.(reverse ? 0 : 1); 150 | done?.(reverse ? 0 : 1); 151 | } else { 152 | const t = (now - s) / duration; 153 | tick?.(easing(reverse ? 1 - t : t)); 154 | doit(); 155 | } 156 | }); 157 | } 158 | }; 159 | doit(); 160 | }, delay); 161 | 162 | return () => { 163 | isCanceled = true; 164 | tick?.(reverse ? 0 : 1); 165 | done?.(reverse ? 0 : 1); 166 | }; 167 | } 168 | 169 | // TODO: create non-linear functions (issue: #41) 170 | function linear(t: number) { 171 | return t; 172 | } 173 | 174 | function getAngle(count: number, index: number, spread: number) { 175 | return ((count - 1) / 2 - index) * spread; 176 | } 177 | -------------------------------------------------------------------------------- /src/graph/domain-edge.tsx: -------------------------------------------------------------------------------- 1 | import './domain-edge.less'; 2 | 3 | import React, { useLayoutEffect, useRef } from 'react'; 4 | 5 | import { useEdgeSubscriber } from '../simulation'; 6 | 7 | import { ChevronDown, ChevronsDown, ChevronsUp, ChevronUp } from '../icons'; 8 | import { useDispatch } from '../state'; 9 | import { selectField } from '../state/graph/graph-actions'; 10 | import { 11 | useEdge, 12 | useFieldsByEdge, 13 | useSelectedFieldId, 14 | } from '../state/graph/hooks'; 15 | 16 | const handleSize = 20; 17 | 18 | export const DomainEdge: React.VFC<{ edgeId: string }> = ({ edgeId }) => { 19 | const dispatch = useDispatch(); 20 | const edge = useEdge(edgeId); 21 | const selectedFieldId = useSelectedFieldId(); 22 | 23 | const fields = useFieldsByEdge(edgeId); 24 | 25 | const g = useRef(null); 26 | const paths = useRef([]); 27 | const handles = useRef([]); 28 | 29 | useLayoutEffect(() => { 30 | paths.current = []; 31 | handles.current = []; 32 | 33 | if (g.current) { 34 | for (let i = 0; i < g.current.children.length; i++) { 35 | const item = g.current.children.item(i); 36 | 37 | if (item?.tagName === 'path') { 38 | paths.current.push(g.current.children.item(i) as SVGPathElement); 39 | } else if (item?.tagName === 'g' && item.classList.contains('handle')) { 40 | handles.current.push(g.current.children.item(i) as SVGGElement); 41 | } 42 | } 43 | } 44 | }, [fields.length]); 45 | 46 | useEdgeSubscriber(edgeId, ({ x1, y1, x2, y2 }) => { 47 | if (g.current && paths.current?.length) { 48 | const count = paths.current.length; 49 | 50 | if (x1 === x2 && y1 === y2) { 51 | // "circular" edge 52 | const midpoints = Array.from(Array(count)).map((_, i) => [ 53 | x1, 54 | y1 + 60 + i * (handleSize + 5), 55 | ]); 56 | 57 | let w = 30; 58 | const widthMultiplier = 1.2; 59 | 60 | midpoints.forEach(([xa, ya], i) => { 61 | paths.current[i].setAttribute( 62 | 'd', 63 | `M${x1} ${y1} C${x1 + w} ${y1} ${x1 + w} ${ya} ${xa} ${ya} S${ 64 | x1 - w 65 | } ${y1} ${x1} ${y1}`, 66 | ); 67 | 68 | handles.current[i].setAttribute( 69 | 'transform', 70 | `translate(${xa} ${ya})rotate(90)translate(${-handleSize / 2 - 2} ${ 71 | -handleSize / 2 72 | })`, 73 | ); 74 | 75 | w *= widthMultiplier; 76 | }); 77 | } else { 78 | // "normal" edge 79 | const midpoints = getMidPoints(count, x1, y1, x2, y2); 80 | const angle = (Math.atan2(x2 - x1, y1 - y2) * 180) / Math.PI; 81 | 82 | midpoints.forEach(([xa, ya, x, y], i) => { 83 | paths.current[i].setAttribute( 84 | 'd', 85 | `M${x1} ${y1} Q${xa} ${ya} ${x2} ${y2}`, 86 | ); 87 | 88 | handles.current[i].setAttribute( 89 | 'transform', 90 | `translate(${x} ${y})rotate(${angle})translate(${-handleSize / 2} ${ 91 | -handleSize / 2 92 | })`, 93 | ); 94 | }); 95 | } 96 | } 97 | }); 98 | 99 | // TODO: verify this won't break the simulation 100 | if (!edge) return null; 101 | 102 | return ( 103 | 104 | {fields.map((field, i) => ( 105 | 106 | 111 | { 116 | if (selectedFieldId !== field.id) { 117 | dispatch(selectField(field.id)); 118 | } 119 | }} 120 | > 121 | 122 | {field.isList ? ( 123 | field.isReverse && edge.sourceNodeId !== edge.targetNodeId ? ( 124 | 125 | ) : ( 126 | 127 | ) 128 | ) : field.isReverse && edge.sourceNodeId !== edge.targetNodeId ? ( 129 | 130 | ) : ( 131 | 132 | )} 133 | 134 | 135 | ))} 136 | 137 | ); 138 | }; 139 | 140 | function getMidPoints( 141 | count: number, 142 | x1: number, 143 | y1: number, 144 | x2: number, 145 | y2: number, 146 | ): [number, number, number, number][] { 147 | const spread = handleSize * 2.45; 148 | 149 | const dx = x2 - x1; 150 | const dy = y2 - y1; 151 | 152 | const xm = (x1 + x2) / 2; 153 | const ym = (y1 + y2) / 2; 154 | 155 | const l = Math.sqrt(dx * dx + dy * dy); 156 | 157 | const midpoints: [number, number, number, number][] = []; 158 | 159 | for (let i = 0; i < count; i++) { 160 | const r = (((count - 1) / 2 - i) * spread) / l; 161 | 162 | const xa = xm - dy * r; 163 | const ya = ym + dx * r; 164 | 165 | const x = (x1 + x2 + 2 * xa) / 4; 166 | const y = (y1 + y2 + 2 * ya) / 4; 167 | 168 | midpoints[i] = [xa, ya, x, y]; 169 | } 170 | 171 | return midpoints; 172 | } 173 | -------------------------------------------------------------------------------- /src/search/search-box.tsx: -------------------------------------------------------------------------------- 1 | import './search-box.less'; 2 | 3 | import React, { useCallback, useRef, useState } from 'react'; 4 | import { useSearch } from '.'; 5 | import { useDebouncedCallback } from './use-debounced-callback'; 6 | import { 7 | useArg, 8 | useEnum, 9 | useField, 10 | useInput, 11 | useNode, 12 | } from '../state/graph/hooks'; 13 | import { TypeDisplayName } from '../graph/spotlight'; 14 | import { useDispatch } from '../state'; 15 | import { selectField, selectNode } from '../state/graph/graph-actions'; 16 | import { IconButton } from '../components/icon-button'; 17 | import { Icons } from '..'; 18 | import { Result } from './types'; 19 | 20 | export const SearchBox: React.VFC = () => { 21 | const search = useSearch(); 22 | const [results, setResults] = useState | null>( 23 | null, 24 | ); 25 | 26 | const [query, setQuery] = useState(null); 27 | 28 | const inputRef = useRef(null); 29 | 30 | const handleSearch = useCallback( 31 | (event: React.ChangeEvent) => { 32 | console.log('Search', event.target.value); 33 | if (event.target.value) { 34 | setResults(search(event.target.value)); 35 | } else { 36 | setResults(null); 37 | } 38 | }, 39 | [search], 40 | ); 41 | 42 | const handleClear = useCallback(() => { 43 | if (inputRef.current) { 44 | inputRef.current.value = ''; 45 | } 46 | 47 | setResults(null); 48 | setQuery(null); 49 | }, []); 50 | 51 | const debouncedSearch = useDebouncedCallback(handleSearch, 500); 52 | 53 | const handleChange = useCallback( 54 | (event: React.ChangeEvent) => { 55 | setQuery(event.target.value); 56 | debouncedSearch(event); 57 | }, 58 | [debouncedSearch], 59 | ); 60 | 61 | return ( 62 |
    63 |
    64 | 65 | 70 |
    71 | {results !== null && !results.length && 'No results found'} 72 | {!!results?.length && ( 73 |
      74 | {results?.map((result) => ( 75 | 76 | ))} 77 |
    78 | )} 79 |
    80 | ); 81 | }; 82 | 83 | const SearchResult: React.VFC = (props) => { 84 | const { kind, ...rest } = props; 85 | switch (kind) { 86 | case 'Type': 87 | return ; 88 | case 'Field': 89 | return ; 90 | case 'Arg': 91 | return ; 92 | default: 93 | return null; 94 | } 95 | }; 96 | 97 | const NodeResult: React.VFC> = ({ id }) => { 98 | const dispatch = useDispatch(); 99 | 100 | const node = useNode(id); 101 | 102 | const handleClick = useCallback(() => { 103 | dispatch(selectNode(node?.id || '')); 104 | }, [node, dispatch]); 105 | 106 | if (!node) return null; 107 | return ( 108 |
  • 109 |
    {node.id}
    110 |
    {node.description}
    111 |
  • 112 | ); 113 | }; 114 | 115 | const FieldResult: React.VFC<{ id: string }> = ({ id }) => { 116 | const dispatch = useDispatch(); 117 | 118 | const field = useField(id); 119 | const node = useNode(field?.nodeId || ''); 120 | const e = useEnum(field?.typeName || ''); 121 | const input = useInput(field?.typeName || ''); 122 | 123 | const resultKind = 124 | field?.typeKind === 'ENUM' || field?.typeKind === 'SCALAR' 125 | ? 'node' 126 | : 'field'; 127 | 128 | const handleClick = useCallback(() => { 129 | if (resultKind === 'node') { 130 | dispatch(selectNode(node?.id || '')); 131 | } else { 132 | dispatch(selectField(field?.id || '')); 133 | } 134 | }, [field, node, resultKind, dispatch]); 135 | 136 | if (!field || !node) return null; 137 | return ( 138 |
  • 139 |
    {node.id}
    140 |
    141 | {field.name} 142 | {': '} 143 | 150 |
    151 | 152 |
    {field.description}
    153 |
  • 154 | ); 155 | }; 156 | 157 | const ArgResult: React.VFC<{ id: string }> = ({ id }) => { 158 | const dispatch = useDispatch(); 159 | 160 | const arg = useArg(id); 161 | const field = useField(arg?.fieldId || ''); 162 | const node = useNode(field?.nodeId || ''); 163 | const e = useEnum(arg?.typeName || ''); 164 | const input = useInput(arg?.typeName || ''); 165 | 166 | const handleClick = useCallback(() => { 167 | dispatch(selectField(field?.id || '')); 168 | }, [field, dispatch]); 169 | 170 | if (!field || !node || !arg) return null; 171 | 172 | return ( 173 |
  • 174 |
    {node.id}
    175 |
    176 | {field.name}({arg.name}:{' '} 177 | 184 | ) 185 |
    186 | 187 |
    {arg.description}
    188 |
  • 189 | ); 190 | }; 191 | -------------------------------------------------------------------------------- /src/tools/document-cache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefinitionNode, 3 | DocumentNode, 4 | FieldDefinitionNode, 5 | InputObjectTypeDefinitionNode, 6 | InputObjectTypeExtensionNode, 7 | InputValueDefinitionNode, 8 | InterfaceTypeDefinitionNode, 9 | InterfaceTypeExtensionNode, 10 | NamedTypeNode, 11 | ObjectTypeDefinitionNode, 12 | ObjectTypeExtensionNode, 13 | SchemaDefinitionNode, 14 | SchemaExtensionNode, 15 | TypeNode, 16 | } from 'graphql'; 17 | 18 | export type NormalizedTypeNode = { 19 | namedType: NamedTypeNode; 20 | isNotNull: boolean; 21 | isList: boolean; 22 | isListElementNotNull?: boolean; 23 | }; 24 | 25 | export class DocumentCache { 26 | constructor(readonly document: DocumentNode) { 27 | for (const definition of document.definitions) { 28 | if (hasName(definition) && definition.name?.value) { 29 | this.namedDefinitionByName.set(definition.name.value, definition); 30 | } 31 | 32 | if (hasFields(definition)) { 33 | for (const field of definition.fields || []) { 34 | switch (field.kind) { 35 | case 'FieldDefinition': 36 | this.definitionsByField.set(field, definition); 37 | 38 | // TODO index args 39 | for (const arg of field.arguments || []) { 40 | this.definitionsByInputValue.set(arg, field); 41 | } 42 | break; 43 | 44 | case 'InputValueDefinition': 45 | this.definitionsByInputValue.set(field, definition); 46 | break; 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | private readonly definitionsByInputValue = new Map< 54 | InputValueDefinitionNode, 55 | | FieldDefinitionNode 56 | | InputValueDefinitionNode 57 | | InputObjectTypeDefinitionNode 58 | | InputObjectTypeExtensionNode 59 | | InterfaceTypeDefinitionNode 60 | | InterfaceTypeExtensionNode 61 | | ObjectTypeDefinitionNode 62 | | ObjectTypeExtensionNode 63 | >(); 64 | 65 | private readonly definitionsByField = new Map< 66 | FieldDefinitionNode, 67 | | InputObjectTypeDefinitionNode 68 | | InputObjectTypeExtensionNode 69 | | InterfaceTypeDefinitionNode 70 | | InterfaceTypeExtensionNode 71 | | ObjectTypeDefinitionNode 72 | | ObjectTypeExtensionNode 73 | >(); 74 | 75 | private readonly namedDefinitionByName = new Map< 76 | string, 77 | Exclude 78 | >(); 79 | 80 | private readonly normalizeFieldTypesByTypeNode = new WeakMap< 81 | TypeNode, 82 | NormalizedTypeNode 83 | >(); 84 | 85 | normalizeTypeNode(typeNode: TypeNode): NormalizedTypeNode { 86 | if (!this.normalizeFieldTypesByTypeNode.has(typeNode)) { 87 | let type: TypeNode = typeNode; 88 | const isNotNull = type.kind === 'NonNullType'; 89 | let isList = false; 90 | let isListElementNotNull: boolean | undefined = undefined; 91 | if (type.kind === 'NonNullType') { 92 | type = type.type; 93 | } 94 | if (type.kind === 'ListType') { 95 | isList = true; 96 | type = type.type; 97 | isListElementNotNull = type.kind === 'NonNullType'; 98 | if (type.kind === 'NonNullType') { 99 | type = type.type; 100 | } 101 | } 102 | 103 | let normalizeTypeNode: NormalizedTypeNode; 104 | 105 | if (type.kind === 'ListType') { 106 | // TODO: Support nested lists #84 107 | normalizeTypeNode = { 108 | isList: true, 109 | isNotNull, 110 | namedType: { 111 | kind: 'NamedType', 112 | name: { kind: 'Name', value: 'UnsupportedNestedList' }, 113 | }, 114 | isListElementNotNull, 115 | }; 116 | } else if (typeof isListElementNotNull === 'boolean') { 117 | normalizeTypeNode = { 118 | namedType: type, 119 | isNotNull, 120 | isList, 121 | isListElementNotNull, 122 | }; 123 | } else { 124 | normalizeTypeNode = { 125 | namedType: type, 126 | isNotNull, 127 | isList, 128 | }; 129 | } 130 | 131 | this.normalizeFieldTypesByTypeNode.set(typeNode, normalizeTypeNode); 132 | } 133 | return this.normalizeFieldTypesByTypeNode.get(typeNode)!; 134 | } 135 | 136 | getTypeDefinition( 137 | name: string, 138 | ): 139 | | Exclude 140 | | undefined { 141 | return this.namedDefinitionByName.get(name); 142 | } 143 | 144 | getDefinitionByField(node: FieldDefinitionNode) { 145 | return this.definitionsByField.get(node); 146 | } 147 | 148 | getDefinitionByInputValue(node: InputValueDefinitionNode) { 149 | return this.definitionsByInputValue.get(node); 150 | } 151 | 152 | *nodes() { 153 | for (const definition of this.document.definitions) { 154 | if ( 155 | definition.kind === 'ObjectTypeDefinition' || 156 | definition.kind === 'InterfaceTypeDefinition' 157 | ) { 158 | yield definition; 159 | } 160 | } 161 | } 162 | 163 | *fields() { 164 | for (const node of this.nodes()) { 165 | for (const field of node.fields || []) { 166 | yield field; 167 | } 168 | } 169 | } 170 | 171 | *args() { 172 | for (const field of this.fields()) { 173 | for (const arg of field.arguments || []) { 174 | yield arg; 175 | } 176 | } 177 | } 178 | 179 | *inputs() { 180 | for (const definition of this.document.definitions) { 181 | if (definition.kind === 'InputObjectTypeDefinition') { 182 | yield definition; 183 | } 184 | } 185 | } 186 | } 187 | 188 | function hasName( 189 | node: DefinitionNode, 190 | ): node is Exclude { 191 | switch (node.kind) { 192 | case 'SchemaDefinition': 193 | case 'SchemaExtension': 194 | return false; 195 | default: 196 | return true; 197 | } 198 | } 199 | 200 | function hasFields( 201 | node: DefinitionNode, 202 | ): node is 203 | | InputObjectTypeDefinitionNode 204 | | InputObjectTypeExtensionNode 205 | | InterfaceTypeDefinitionNode 206 | | InterfaceTypeExtensionNode 207 | | ObjectTypeDefinitionNode 208 | | ObjectTypeExtensionNode { 209 | switch (node.kind) { 210 | case 'InputObjectTypeDefinition': 211 | case 'InputObjectTypeExtension': 212 | case 'InterfaceTypeDefinition': 213 | case 'InterfaceTypeExtension': 214 | case 'ObjectTypeDefinition': 215 | case 'ObjectTypeExtension': 216 | return true; 217 | default: 218 | return false; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/state/graph/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | array, 3 | define, 4 | key, 5 | required, 6 | optional, 7 | indexOf, 8 | } from 'flux-standard-functions'; 9 | import { SpecificFieldType, SpecificInputFieldType } from '../../tools/types'; 10 | 11 | export type Entity = { id: string }; 12 | 13 | export type Arg = { 14 | id: string; 15 | fieldId: string; 16 | name: string; 17 | description?: string; 18 | defaultValue?: string; 19 | typeKind: SpecificInputFieldType['kind']; 20 | typeName: SpecificInputFieldType['name']; 21 | isNotNull: boolean; 22 | isList: boolean; 23 | isListElementNotNull?: boolean; 24 | hideWith?: string[]; 25 | showWith?: string[]; 26 | }; 27 | 28 | export const argDef = define({ 29 | id: key(), 30 | fieldId: required(), 31 | name: required(), 32 | description: optional(), 33 | defaultValue: optional(), 34 | typeKind: required(), 35 | typeName: required(), 36 | isNotNull: required(), 37 | isList: required(), 38 | isListElementNotNull: optional(), 39 | hideWith: optional(array()), 40 | showWith: optional(array()), 41 | }); 42 | 43 | export type Edge = { 44 | id: string; 45 | sourceNodeId: string; 46 | targetNodeId: string; 47 | fieldIds: string[]; 48 | hideWith?: string[]; 49 | showWith?: string[]; 50 | }; 51 | 52 | export const edgeDef = define({ 53 | id: key(), 54 | sourceNodeId: required(), 55 | targetNodeId: required(), 56 | fieldIds: required(array()), 57 | hideWith: optional(array()), 58 | showWith: optional(array()), 59 | }); 60 | 61 | export type Enum = { 62 | id: string; 63 | description?: string; 64 | valueIds: string[]; 65 | hideWith?: string[]; 66 | showWith?: string[]; 67 | }; 68 | 69 | export const enumDef = define({ 70 | id: key(), 71 | description: optional(), 72 | valueIds: required(array()), 73 | hideWith: optional(array()), 74 | showWith: optional(array()), 75 | }); 76 | 77 | export type EnumValue = { 78 | id: string; 79 | enumId: string; 80 | name: string; 81 | description?: string; 82 | isDeprecated: boolean; 83 | deprecationReason?: string; 84 | hideWith?: string[]; 85 | showWith?: string[]; 86 | }; 87 | 88 | export const enumValueDef = define({ 89 | id: key(), 90 | enumId: required(), 91 | name: required(), 92 | description: optional(), 93 | isDeprecated: required(), 94 | deprecationReason: optional(), 95 | hideWith: optional(array()), 96 | showWith: optional(array()), 97 | }); 98 | 99 | export type Field = { 100 | id: string; 101 | nodeId: string; 102 | edgeId?: string; 103 | argIds: string[]; 104 | isReverse?: boolean; 105 | name: string; 106 | description?: string; 107 | typeKind: SpecificFieldType['kind']; 108 | typeName: SpecificFieldType['name']; 109 | isNotNull: boolean; 110 | isList: boolean; 111 | isListElementNotNull?: boolean; 112 | hideWith?: string[]; 113 | showWith?: string[]; 114 | }; 115 | 116 | export const fieldDef = define({ 117 | id: key(), 118 | nodeId: required(), 119 | edgeId: optional(), 120 | argIds: required(array()), 121 | isReverse: optional(), 122 | name: required(), 123 | description: optional(), 124 | typeKind: required(), 125 | typeName: required(), 126 | isNotNull: required(), 127 | isList: required(), 128 | isListElementNotNull: optional(), 129 | hideWith: optional(array()), 130 | showWith: optional(array()), 131 | }); 132 | 133 | export type InputField = { 134 | id: string; 135 | inputId: string; 136 | name: string; 137 | description?: string; 138 | defaultValue?: string; 139 | typeKind: SpecificInputFieldType['kind']; 140 | typeName: SpecificInputFieldType['name']; 141 | isNotNull: boolean; 142 | isList: boolean; 143 | isListElementNotNull?: boolean; 144 | hideWith?: string[]; 145 | showWith?: string[]; 146 | }; 147 | 148 | export const inputFieldDef = define({ 149 | id: key(), 150 | inputId: required(), 151 | name: required(), 152 | description: optional(), 153 | defaultValue: optional(), 154 | typeKind: required(), 155 | typeName: required(), 156 | isNotNull: required(), 157 | isList: required(), 158 | isListElementNotNull: optional(), 159 | hideWith: optional(array()), 160 | showWith: optional(array()), 161 | }); 162 | 163 | export type Input = { 164 | id: string; 165 | description?: string; 166 | inputFieldIds: string[]; 167 | hideWith?: string[]; 168 | showWith?: string[]; 169 | }; 170 | 171 | export const inputDef = define({ 172 | id: key(), 173 | description: optional(), 174 | inputFieldIds: required(array()), 175 | hideWith: optional(array()), 176 | showWith: optional(array()), 177 | }); 178 | 179 | export type Node = { 180 | id: string; 181 | description?: string; 182 | edgeIds: string[]; 183 | fieldIds: string[]; 184 | hideWith?: string[]; 185 | showWith?: string[]; 186 | }; 187 | 188 | export const nodeDef = define({ 189 | id: key(), 190 | description: optional(), 191 | edgeIds: required(array()), 192 | fieldIds: required(array()), 193 | hideWith: optional(array()), 194 | showWith: optional(array()), 195 | }); 196 | 197 | export type VisibleNode = { 198 | id: string; 199 | isPinned: boolean; 200 | x?: number; 201 | y?: number; 202 | }; 203 | 204 | export const visibleNodeDef = define({ 205 | id: key(), 206 | isPinned: required(), 207 | x: optional(), 208 | y: optional(), 209 | }); 210 | 211 | export type GraphState = { 212 | args: Record; 213 | edges: Record; 214 | fields: Record; 215 | nodes: Record; 216 | enums: Record; 217 | enumValues: Record; 218 | inputs: Record; 219 | inputFields: Record; 220 | visibleNodes: Record; 221 | visibleEdgeIds: string[]; 222 | selectedSourceNodeId?: string; 223 | selectedFieldId?: string; 224 | selectedTargetNodeId?: string; 225 | plugins: string[]; 226 | activePlugins: string[]; 227 | }; 228 | 229 | export const stateDef = define({ 230 | args: required(indexOf(argDef)), 231 | edges: required(indexOf(edgeDef)), 232 | fields: required(indexOf(fieldDef)), 233 | nodes: required(indexOf(nodeDef)), 234 | enums: required(indexOf(enumDef)), 235 | enumValues: required(indexOf(enumValueDef)), 236 | inputs: required(indexOf(inputDef)), 237 | inputFields: required(indexOf(inputFieldDef)), 238 | visibleNodes: required(indexOf(visibleNodeDef)), 239 | visibleEdgeIds: required(array()), 240 | selectedSourceNodeId: optional(), 241 | selectedFieldId: optional(), 242 | selectedTargetNodeId: optional(), 243 | plugins: required(array()), 244 | activePlugins: required(array()), 245 | }); 246 | 247 | export const defaultState: GraphState = { 248 | args: {}, 249 | edges: {}, 250 | fields: {}, 251 | nodes: {}, 252 | enums: {}, 253 | enumValues: {}, 254 | inputs: {}, 255 | inputFields: {}, 256 | visibleNodes: {}, 257 | visibleEdgeIds: [], 258 | plugins: [], 259 | activePlugins: [], 260 | }; 261 | -------------------------------------------------------------------------------- /src/tools/plugins/connections.ts: -------------------------------------------------------------------------------- 1 | import { Edge, Field } from '../../state/graph'; 2 | import { buildEdgeId } from '../factory'; 3 | import { StateFactoryPlugin } from '../types'; 4 | import { compact } from '../utils'; 5 | 6 | export const pluginName = 'simple-connections'; 7 | 8 | export const connections: StateFactoryPlugin = (state) => { 9 | for (const nodeId in state.nodes) { 10 | if (nodeId.endsWith('Connection')) { 11 | const name = nodeId.substr(0, nodeId.length - 'Connection'.length); 12 | 13 | const connectionNode = state.nodes[`${name}Connection`]; 14 | const pageInfoNode = state.nodes['PageInfo']; 15 | 16 | if (!connectionNode || !pageInfoNode) continue; 17 | 18 | const connectionFields = connectionNode.fieldIds 19 | .map((fieldId) => state.fields[fieldId]) 20 | .filter((x) => x); 21 | 22 | const connectionEdgesField = connectionFields.find( 23 | (f) => f.name === 'edges' && f.typeKind !== 'SCALAR', 24 | ); 25 | if (!connectionEdgesField) continue; 26 | 27 | const edgeNode = state.nodes[connectionEdgesField.typeName]; 28 | if (!edgeNode) continue; 29 | 30 | const connectionNodesField = connectionFields.find( 31 | (f) => 32 | f.name === 'nodes' && f.typeKind !== 'SCALAR' && f.isList === true, 33 | ); 34 | if (!connectionNodesField) continue; 35 | 36 | const connectionPageInfoField = connectionFields.find( 37 | (f) => 38 | f.name === 'pageInfo' && 39 | f.typeName === pageInfoNode.id && 40 | f.isList === false, 41 | ); 42 | if (!connectionPageInfoField) continue; 43 | 44 | const targetNode = state.nodes[connectionNodesField.typeName]; 45 | if (!targetNode) continue; 46 | 47 | const virtualSourceIds = new Set(); 48 | 49 | hide(connectionNode); 50 | for (const edgeId of connectionNode.edgeIds) { 51 | const edge = state.edges[edgeId]; 52 | hide(edge); 53 | for (const fieldId of edge.fieldIds) { 54 | hide(state.fields[fieldId]); 55 | } 56 | 57 | virtualSourceIds.add(edge.sourceNodeId); 58 | virtualSourceIds.add(edge.targetNodeId); 59 | } 60 | for (const fieldId of connectionNode.fieldIds) { 61 | hide(state.fields[fieldId]); 62 | } 63 | 64 | hide(edgeNode); 65 | for (const edgeId of edgeNode.edgeIds) { 66 | hide(state.edges[edgeId]); 67 | for (const fieldId of state.edges[edgeId].fieldIds) { 68 | hide(state.fields[fieldId]); 69 | } 70 | } 71 | for (const fieldId of edgeNode.fieldIds) { 72 | hide(state.fields[fieldId]); 73 | } 74 | 75 | hide(pageInfoNode); 76 | 77 | virtualSourceIds.delete(connectionNode.id); 78 | virtualSourceIds.delete(edgeNode.id); 79 | virtualSourceIds.delete(pageInfoNode.id); 80 | 81 | for (const sourceId of virtualSourceIds) { 82 | const sourceNode = state.nodes[sourceId]; 83 | 84 | // TODO: don't match nodes using typeName 85 | const fieldsToRewrite = sourceNode.fieldIds 86 | .map((fieldId) => state.fields[fieldId]) 87 | .filter((field) => field?.typeName === connectionNode.id); 88 | 89 | for (const fieldToRewrite of fieldsToRewrite) { 90 | if (!fieldToRewrite) continue; 91 | 92 | const newFieldId = `${fieldToRewrite.id}~${pluginName}`; 93 | const { 94 | edgeId, 95 | isReverse, 96 | sourceId: edgeSourceId, 97 | targetId: edgeTargetId, 98 | } = buildEdgeId(sourceId, targetNode.id); 99 | 100 | const isNewEdge = !state.edges[edgeId]; 101 | 102 | const edge: Edge = 103 | state.edges[edgeId] || 104 | compact({ 105 | id: edgeId, 106 | fieldIds: [], 107 | sourceNodeId: edgeSourceId, 108 | targetNodeId: edgeTargetId, 109 | }); 110 | edge.fieldIds.push(newFieldId); 111 | 112 | if (isNewEdge) { 113 | show(edge); 114 | } else { 115 | edge.hideWith = remove(edge.hideWith, pluginName); 116 | edge.showWith = remove(edge.showWith, pluginName); 117 | if (typeof edge.hideWith === 'undefined') delete edge.hideWith; 118 | if (typeof edge.showWith === 'undefined') delete edge.showWith; 119 | } 120 | 121 | const newArgs = fieldToRewrite.argIds 122 | .map((argId) => 123 | compact({ 124 | ...state.args[argId], 125 | id: `${argId}~${pluginName}`, 126 | fieldId: newFieldId, 127 | hideWith: undefined, 128 | showWith: [pluginName], 129 | }), 130 | ) 131 | .filter((arg) => !pagingArgs.has(arg.name)); 132 | 133 | const newField: Field = compact({ 134 | ...connectionNodesField, 135 | isNotNull: fieldToRewrite.isNotNull, 136 | id: newFieldId, 137 | edgeId, 138 | name: fieldToRewrite.name, 139 | description: fieldToRewrite.description, 140 | argIds: newArgs.map((arg) => arg.id), 141 | nodeId: sourceId, 142 | isReverse, 143 | hideWith: undefined, 144 | showWith: [pluginName], 145 | }); 146 | 147 | sourceNode.fieldIds.push(newFieldId); 148 | if (isNewEdge) sourceNode.edgeIds.push(edgeId); 149 | 150 | state.fields[newField.id] = newField; 151 | state.edges[edge.id] = edge; 152 | for (const arg of newArgs) { 153 | state.args[arg.id] = arg; 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | return state; 161 | }; 162 | 163 | const pagingArgs = new Set(['first', 'after', 'last', 'before']); 164 | 165 | function show( 166 | item: T, 167 | ): T { 168 | item.hideWith = remove(item.hideWith, pluginName); 169 | item.showWith = add(item.showWith, pluginName); 170 | if (typeof item.hideWith === 'undefined') delete item.hideWith; 171 | if (typeof item.showWith === 'undefined') delete item.showWith; 172 | return item; 173 | } 174 | 175 | function hide( 176 | item: T, 177 | ): T { 178 | item.hideWith = add(item.hideWith, pluginName); 179 | item.showWith = remove(item.showWith, pluginName); 180 | if (typeof item.hideWith === 'undefined') delete item.hideWith; 181 | if (typeof item.showWith === 'undefined') delete item.showWith; 182 | return item; 183 | } 184 | 185 | function remove( 186 | values: string[] | undefined, 187 | value: string, 188 | ): string[] | undefined { 189 | if (!values?.length) return undefined; 190 | 191 | if (!values.includes(value)) return values; 192 | 193 | if (values.length === 1) return undefined; 194 | 195 | return values.filter((v) => v !== value); 196 | } 197 | 198 | function add(values: string[] | undefined, value: string): string[] { 199 | if (!values) return [value]; 200 | 201 | if (values.includes(value)) return values; 202 | 203 | return [...values, value]; 204 | } 205 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![master](https://github.com/domain-graph/domain-graph/workflows/build/badge.svg?branch=master&event=push)](https://github.com/domain-graph/domain-graph/actions?query=workflow%3Abuild+branch%3Amaster+event%3Apush) 2 | [![npm](https://img.shields.io/npm/v/domain-graph.svg)](https://www.npmjs.com/package/domain-graph) 3 | 4 | # DomainGraph 5 | 6 | Beautiful, interactive visualizations for GraphQL schemas 7 | 8 | ![](./images/hero.png) 9 | 10 | ## Quick Start 11 | 12 | Import the script and styles from [unpkg](https://unpkg.com/) and mount your schema: 13 | 14 | ```html 15 | 16 | 17 | 18 | 22 | 23 | 24 |
    25 | 29 | 30 | 31 | ``` 32 | 33 | Alternatively, you can build DomainGraph into a React web application. 34 | 35 | This library exposes two main components. The `` component displays the interactive graph. The `` component provides an opinionated, cross-platform UI for opening or dropping files. 36 | 37 | ### DomainGraph 38 | 39 | This component renders a GraphQL `IntrospectionQuery` object as an interactive graph. Learn more about introspection queries from [the GraphQL docs](https://graphql.org/learn/introspection/). 40 | 41 | ```tsx 42 | import React from 'react'; 43 | import { DomainGraph } from 'domain-graph'; 44 | import { IntrospectionQuery } from 'graphql'; 45 | 46 | export const App: React.FC = () => { 47 | const introspection: IntrospectionQuery = useIntrospection(); // Some data source 48 | 49 | return ; 50 | }; 51 | ``` 52 | 53 | ### DataProvider 54 | 55 | This component provides an opinionated, cross-platform UI for opening or dropping files. The result is an `DocumentNode` object that is passed via a render prop. The resulting object can then be passed to a `` component. If the GraphQL SDL file (`*.gql` or `*.graphql`) is not valid, parse errors will be displayed in the UI. 56 | 57 | ```tsx 58 | import React, { useCallback } from 'react'; 59 | import { DataProvider, DomainGraph } from 'domain-graph'; 60 | 61 | export const App: React.FC = () => { 62 | const handleDrop = useCallback(() => { 63 | // TODO: Implement platform-specific confirmation before opening the dropped file 64 | return Promise.resolve(true); 65 | }); 66 | 67 | const handleShowFileDialog = useCallback(() => { 68 | // TODO: Implement platform-specific "open file dialog" here. 69 | return Promise.resolve({ canceled: true, files: [] }); 70 | }); 71 | 72 | return ( 73 | 74 | {(documentNode) => } 75 | 76 | ); 77 | }; 78 | ``` 79 | 80 | This component renders all of the of UI for opening or dropping files; however, the callbacks must be implemented in a platform-specific way. If a callback is _not_ implemented, then that behavior will not be supported by the resulting application. 81 | 82 | #### Examples: 83 | 84 | - VSCode Extension: [github.com/domain-graph/vscode](https://github.com/domain-graph/vscode/blob/master/src/app.tsx) 85 | - Web implementation: [github.com/domain-graph/website](https://github.com/domain-graph/website/blob/master/src/app.tsx) 86 | - Desktop (Electron) implementation: [github.com/domain-graph/desktop](https://github.com/domain-graph/desktop/blob/master/src/app.tsx) 87 | 88 | ### Styles and Themes 89 | 90 | The components are styled with LESS and the raw .less files are included in the package. You will need to use a transpiler/bundler such as webpack to generate CSS to include in your project. You will also need to include a theme file. This package provides an example theme in `/lib/colors.less` or you may include your own custom theme. Custom themes must export _at least_ the same LESS variables as the included theme. 91 | 92 | Include the theme in your build using the `additionalData` less-loader option in your webpack config: 93 | 94 | ```js 95 | config = { 96 | // ... 97 | module: { 98 | rules: [ 99 | { 100 | test: /\.less$/, 101 | use: [ 102 | 'css-loader', 103 | { 104 | loader: 'less-loader', 105 | options: { 106 | additionalData: 107 | "@import '/node_modules/domain-graph/lib/colors.less';", // Or the path to your theme file 108 | }, 109 | }, 110 | ], 111 | }, 112 | ], 113 | }, 114 | // ... 115 | }; 116 | ``` 117 | 118 | Note that if you _don't_ include a theme file, you'll see an error message such as: 119 | 120 | > Variable @color-some-color-description is undefined 121 | 122 | ## How To: 123 | 124 | ### Run the Dev Server with Hot Module Reloading (HMR) 125 | 126 | This project contains a development server than can be started by running `npm start`. This will load a bootstrap web application that contains a `` and a ``. 127 | 128 | To run the server: 129 | 130 | 1. `npm start` 131 | 1. Open `localhost:9999` in your browser 132 | 133 | Any changes to `index.html`, `*.ts`, or `*.less` files will be immediately reflected in the browser without required a page refresh. 134 | 135 | ### Run unit tests 136 | 137 | The `test` script will run any file ending with `.tests.ts`: 138 | 139 | 1. `npm test` 140 | 141 | Code coverage may be viewed in `./coverage/lcov-report/index.html`. 142 | 143 | ### Publish a new version to NPM 144 | 145 | Publishing is automated via a [workflow](https://github.com/domain-graph/domain-graph/actions?query=workflow%3Apublish). To run this workflow: 146 | 147 | 1. Checkout `master` and pull latest changes. 148 | 1. Run `npm version [major|minor|patch]` to create a new version commit and tag 149 | 1. Run `git push origin master --follow-tags` to push the tag (and version commit) and start the workflow 150 | 1. Wait for [the workflow](https://github.com/domain-graph/domain-graph/actions?query=workflow%3Apublish) to detect the tag and publish the package. 151 | 152 | ### Add code or style files 153 | 154 | #### Code 155 | 156 | The entry point of the Typescript files is `./src/index.ts`; therefore, any file that will be included in the `.js` bundle must be ultimately imported from `index.ts`. 157 | 158 | #### Styles 159 | 160 | `*.less` files must be imported from Typescript in order to be included in the `.css` bundle. Note that even though the styles are "imported" into a code file, they are NOT inlined into the `.js` bundle. The `MiniCssExtractPlugin` ensures that any LESS styles imported into code are moved from the code into the style bundle. (The `less.d.ts` file prevents compile-time errors when importing non-Typescript content.) 161 | 162 | Example: 163 | 164 | ```ts 165 | import './index.less'; 166 | 167 | const code = 'goes here'; 168 | ``` 169 | 170 | #### Markup 171 | 172 | Add your markup to `./src/index.html`. This file is used as the "template" when running Webpack. The resulting file will include script and link tags to the `.js` and `.css` bundles. 173 | 174 | --- 175 | 176 | Generated with [generator-ts-website](https://www.npmjs.com/package/generator-ts-website) 177 | -------------------------------------------------------------------------------- /src/state/graph/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { shallowEqual } from 'react-redux'; 3 | import * as fsf from 'flux-standard-functions'; 4 | 5 | import { 6 | Node, 7 | Edge, 8 | Field, 9 | VisibleNode, 10 | Arg, 11 | Enum, 12 | EnumValue, 13 | Input, 14 | InputField, 15 | } from './types'; 16 | import { useSelector } from '..'; 17 | 18 | function respectPlugins( 19 | entity: T | undefined, 20 | activePlugins: Set, 21 | ): T | undefined { 22 | if (!entity) { 23 | return undefined; 24 | } else if (!entity.hideWith?.length && !entity.showWith?.length) { 25 | return entity; 26 | } else if ( 27 | entity.hideWith && 28 | entity.hideWith.some((plugin) => activePlugins.has(plugin)) 29 | ) { 30 | return undefined; 31 | } else if ( 32 | entity.showWith && 33 | !entity.showWith.some((plugin) => activePlugins.has(plugin)) 34 | ) { 35 | return undefined; 36 | } else { 37 | return entity; 38 | } 39 | } 40 | 41 | function useActivePlugins(): Set { 42 | const activePlugins = useSelector((state) => state.graph.activePlugins); 43 | return useMemo(() => new Set(activePlugins), [activePlugins]); 44 | } 45 | 46 | const isTruthy = (x: T | undefined): x is T => typeof x !== 'undefined'; 47 | 48 | export function useArg(argId: string | undefined): Arg | undefined { 49 | const arg = useSelector((state) => state.graph.args[argId || '']); 50 | const activePlugins = useActivePlugins(); 51 | return respectPlugins(arg, activePlugins); 52 | } 53 | 54 | export function useEdge(edgeId: string | undefined): Edge | undefined { 55 | const edge = useSelector((state) => state.graph.edges[edgeId || '']); 56 | const activePlugins = useActivePlugins(); 57 | return respectPlugins(edge, activePlugins); 58 | } 59 | 60 | export function useField(fieldId: string): Field | undefined { 61 | const field = useSelector((state) => state.graph.fields[fieldId]); 62 | const activePlugins = useActivePlugins(); 63 | return respectPlugins(field, activePlugins); 64 | } 65 | 66 | export function useFieldIds(nodeId: string): string[] { 67 | // TODO: respect plugins 68 | return useSelector( 69 | (state) => state.graph.nodes[nodeId]?.fieldIds || [], 70 | shallowEqual, 71 | ); 72 | } 73 | 74 | export function useFieldIdsByEdge(edgeId: string): string[] { 75 | // TODO: respect plugins 76 | return useSelector( 77 | (state) => state.graph.edges[edgeId]?.fieldIds || [], 78 | shallowEqual, 79 | ); 80 | } 81 | 82 | export function useFields(nodeId: string): Field[] { 83 | const fieldIds = useFieldIds(nodeId); 84 | const allFields = useSelector((state) => state.graph.fields); 85 | const activePlugins = useActivePlugins(); 86 | 87 | return useMemo( 88 | () => 89 | fieldIds 90 | .map((fieldId) => { 91 | const field = allFields[fieldId]; 92 | 93 | if (!field) console.error('Cannot find field by ID:', fieldId); 94 | 95 | return respectPlugins(field, activePlugins); 96 | }) 97 | .filter(isTruthy), 98 | [allFields, fieldIds, activePlugins], 99 | ); 100 | } 101 | 102 | export function useFieldsByEdge(edgeId: string): Field[] { 103 | const fieldIds = useFieldIdsByEdge(edgeId); 104 | const allFields = useSelector((state) => state.graph.fields); 105 | const activePlugins = useActivePlugins(); 106 | 107 | return useMemo( 108 | () => 109 | fieldIds 110 | .map((fieldId) => { 111 | const field = allFields[fieldId]; 112 | 113 | if (!field) console.error('Cannot find field by ID:', fieldId); 114 | 115 | return respectPlugins(field, activePlugins); 116 | }) 117 | .filter(isTruthy), 118 | [allFields, fieldIds, activePlugins], 119 | ); 120 | } 121 | 122 | export function useNode(nodeId: string | undefined): Node | undefined { 123 | const node = useSelector((state) => state.graph.nodes[nodeId || '']); 124 | const activePlugins = useActivePlugins(); 125 | return respectPlugins(node, activePlugins); 126 | } 127 | 128 | export function useAllNodes(): Node[] { 129 | const allNodes = useSelector((state) => fsf.deindex(state.graph.nodes)); 130 | const activePlugins = useActivePlugins(); 131 | 132 | return useMemo( 133 | () => 134 | allNodes 135 | .map((node) => respectPlugins(node, activePlugins)) 136 | .filter(isTruthy), 137 | [allNodes, activePlugins], 138 | ); 139 | } 140 | 141 | export function useEnum(enumId: string | undefined): Enum | undefined { 142 | const e = useSelector((state) => state.graph.enums[enumId || '']); 143 | const activePlugins = useActivePlugins(); 144 | return respectPlugins(e, activePlugins); 145 | } 146 | 147 | export function useEnumValue(enumValueId: string): EnumValue | undefined { 148 | const enumValue = useSelector((state) => state.graph.enumValues[enumValueId]); 149 | const activePlugins = useActivePlugins(); 150 | return respectPlugins(enumValue, activePlugins); 151 | } 152 | 153 | export function useEnumValues(enumId: string): EnumValue[] { 154 | const enumValueIds = useEnum(enumId)?.valueIds; 155 | const enumValues = useSelector((state) => state.graph.enumValues); 156 | const activePlugins = useActivePlugins(); 157 | 158 | return useMemo(() => { 159 | return (enumValueIds || []) 160 | .map((id) => respectPlugins(enumValues[id], activePlugins)) 161 | .filter(isTruthy); 162 | }, [enumValueIds, enumValues, activePlugins]); 163 | } 164 | 165 | export function useInput(inputId: string | undefined): Input | undefined { 166 | const input = useSelector((state) => state.graph.inputs[inputId || '']); 167 | const activePlugins = useActivePlugins(); 168 | return respectPlugins(input, activePlugins); 169 | } 170 | 171 | export function useInputField(inputFieldId: string): InputField | undefined { 172 | const inputField = useSelector( 173 | (state) => state.graph.inputFields[inputFieldId], 174 | ); 175 | const activePlugins = useActivePlugins(); 176 | return respectPlugins(inputField, activePlugins); 177 | } 178 | 179 | export function useInputFields(inputId: string): InputField[] { 180 | const inputFieldIds = useInput(inputId)?.inputFieldIds; 181 | const inputFields = useSelector((state) => state.graph.inputFields); 182 | const activePlugins = useActivePlugins(); 183 | 184 | return useMemo(() => { 185 | return (inputFieldIds || []) 186 | .map((id) => respectPlugins(inputFields[id], activePlugins)) 187 | .filter(isTruthy); 188 | }, [inputFieldIds, inputFields, activePlugins]); 189 | } 190 | 191 | export function useVisibleEdgeIds(): string[] { 192 | // TODO: respect plugins 193 | return useSelector((state) => state.graph.visibleEdgeIds); 194 | } 195 | export function useVisibleEdges(): Edge[] { 196 | const { visibleEdgeIds, edges } = useSelector((state) => state.graph); 197 | 198 | // TODO: respect plugins 199 | return useMemo( 200 | () => visibleEdgeIds.map((id) => edges[id]), 201 | [visibleEdgeIds, edges], 202 | ); 203 | } 204 | 205 | export function useVisibleNodeIds(): string[] { 206 | // TODO: respect plugins 207 | return useSelector( 208 | (state) => fsf.deindex(state.graph.visibleNodes).map((x) => x.id), 209 | shallowEqual, 210 | ); 211 | } 212 | 213 | export function useAllVisibleNodes(): VisibleNode[] { 214 | const { visibleNodes } = useSelector((state) => state.graph); 215 | // TODO: respect plugins 216 | return useMemo(() => fsf.deindex(visibleNodes), [visibleNodes]); 217 | } 218 | 219 | export function useVisibleNodes(): Record { 220 | // TODO: respect plugins 221 | return useSelector((state) => state.graph.visibleNodes); 222 | } 223 | 224 | export function useIsPinned(nodeId: string): boolean { 225 | // TODO: respect plugins 226 | return useSelector( 227 | (state) => state.graph.visibleNodes[nodeId]?.isPinned === true, 228 | ); 229 | } 230 | 231 | export function useIsVisible(nodeId: string): boolean { 232 | // TODO: respect plugins 233 | return useSelector((state) => !!state.graph.visibleNodes[nodeId]); 234 | } 235 | 236 | export function useSelectedFieldId(): string | undefined { 237 | return useSelector((state) => state.graph.selectedFieldId); 238 | } 239 | 240 | export function useSelectedSourceNodeId(): string | undefined { 241 | return useSelector((state) => state.graph.selectedSourceNodeId); 242 | } 243 | 244 | export function useSelectedTargetNodeId(): string | undefined { 245 | return useSelector((state) => state.graph.selectedTargetNodeId); 246 | } 247 | 248 | export function useVisibleNodeBounds() { 249 | const visibleNodes = useAllVisibleNodes(); 250 | 251 | let minX = 0; 252 | let maxX = 0; 253 | let minY = 0; 254 | let maxY = 0; 255 | 256 | for (const node of visibleNodes) { 257 | if (typeof node.x === 'number' && node.x < minX) minX = node.x; 258 | if (typeof node.x === 'number' && node.x > maxX) maxX = node.x; 259 | if (typeof node.y === 'number' && node.y < minY) minY = node.y; 260 | if (typeof node.y === 'number' && node.y > maxY) maxY = node.y; 261 | } 262 | 263 | return useMemo(() => ({ minX, maxX, minY, maxY }), [minX, maxX, minY, maxY]); 264 | } 265 | -------------------------------------------------------------------------------- /src/simulation/simulation.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useLayoutEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import * as d3 from 'd3'; 10 | 11 | import { NodeEvent, NodeSubscriber } from './node-subscriber'; 12 | import { context } from './context'; 13 | import { EdgeEvent, EdgeSubscriber } from './edge-subscriber'; 14 | import { Edge, VisibleNode } from '../state/graph'; 15 | import { useVisibleEdges, useVisibleNodes } from '../state/graph/hooks'; 16 | import { useDispatch } from '../state'; 17 | import { updateNodeLocations } from '../state/graph/graph-actions'; 18 | import { shallowEqual } from 'react-redux'; 19 | 20 | /** 21 | * Maps items using the provided mapping function. The resulting 22 | * mapped items are "stable" meaning that the same (albeit mutated) 23 | * instance of the object will be returned after each iteration. 24 | * Not that this not idomatic React; however, D3 needs it to work. 25 | * @param items This source items to map 26 | * @param keyProp The prop on the incomming and mapped items used to establish equality 27 | * @param map The mapping function 28 | */ 29 | function useStableMap( 30 | items: TIn[], 31 | keyFn: (item: TIn) => TKey, 32 | map: (item: TIn, existing: TOut | undefined) => TOut, 33 | ): TOut[] { 34 | const existingMap = useRef(new Map()); 35 | 36 | return useMemo( 37 | () => 38 | items.map((item) => { 39 | const key = keyFn(item); 40 | const existing = existingMap.current.get(key); 41 | const clonedExisting = existing ? ({ ...existing } as TOut) : undefined; 42 | const mappedExisting = map(item, clonedExisting); 43 | 44 | if (existing) { 45 | for (const prop of Object.keys(mappedExisting)) { 46 | existing[prop] = mappedExisting[prop]; 47 | } 48 | existingMap.current.set(key, existing); 49 | return existing; 50 | } else { 51 | existingMap.current.set(key, mappedExisting); 52 | return mappedExisting; 53 | } 54 | }), 55 | [items, keyFn, map], 56 | ); 57 | } 58 | type PartialNode = Pick; 59 | type SimulationNode = PartialNode & d3.SimulationNodeDatum; 60 | type SimulationEdge = Pick & d3.SimulationLinkDatum; 61 | 62 | /** 63 | * Returns an array of pinned/unpinned nodes. 64 | * The array reference is stable even if the (x,y) coords change. 65 | * Note that useEffect cannot be used here because this selector 66 | * must be synchronous or the actual D3 simulation throws. 67 | */ 68 | function useVisiblePartialNodes(): PartialNode[] { 69 | const selector = useCallback( 70 | (nodes: typeof visibleNodes): Record => 71 | Object.keys(nodes).reduce((acc, nodeId) => { 72 | acc[nodeId] = nodes[nodeId].isPinned; 73 | return acc; 74 | }, {}), 75 | [], 76 | ); 77 | const mapper = useCallback( 78 | (m: Record): PartialNode[] => 79 | Object.keys(m).map((id) => ({ 80 | id, 81 | isPinned: m[id], 82 | })), 83 | [], 84 | ); 85 | const visibleNodes = useVisibleNodes(); 86 | 87 | const pinMap = selector(visibleNodes); 88 | const pinMapRef = useRef(pinMap); 89 | const resultRef = useRef(mapper(pinMap)); 90 | 91 | if (!shallowEqual(pinMap, pinMapRef.current)) { 92 | pinMapRef.current = pinMap; 93 | resultRef.current = mapper(pinMap); 94 | } 95 | 96 | return resultRef.current; 97 | } 98 | 99 | export const Simulation: React.FC = ({ children }) => { 100 | const dispatch = useDispatch(); 101 | const [svg, setSvg] = useState(d3.select('svg')); 102 | useEffect(() => { 103 | setSvg(d3.select('svg')); 104 | }, []); 105 | 106 | const visibleNodes = useVisiblePartialNodes(); 107 | const visibleEdges = useVisibleEdges(); 108 | 109 | const circularEdgeIdsByNode: Record = useMemo( 110 | () => 111 | visibleEdges 112 | .filter((edge) => edge.sourceNodeId === edge.targetNodeId) 113 | .reduce>((acc, edge) => { 114 | acc[edge.sourceNodeId] ||= []; 115 | 116 | acc[edge.sourceNodeId].push(edge.id); 117 | 118 | return acc; 119 | }, {}), 120 | [visibleEdges], 121 | ); 122 | 123 | const fullVisibleNodes = useVisibleNodes(); 124 | const fullVisibleNodesRef = useRef(fullVisibleNodes); 125 | fullVisibleNodesRef.current = fullVisibleNodes; 126 | 127 | const nodeMapper = useCallback( 128 | (node: PartialNode, simNode: SimulationNode | undefined) => { 129 | const fullNode = fullVisibleNodesRef.current[node.id]; 130 | 131 | // Initialize with stored coords; otherwise, fall back to sim coords 132 | const x = simNode ? simNode.x : fullNode.x; 133 | const y = simNode ? simNode.y : fullNode.y; 134 | return { 135 | ...simNode, 136 | ...node, 137 | x, 138 | y, 139 | fx: node.isPinned ? x : undefined, 140 | fy: node.isPinned ? y : undefined, 141 | vx: node.isPinned ? 0 : simNode?.vx, 142 | vy: node.isPinned ? 0 : simNode?.vy, 143 | }; 144 | }, 145 | [], 146 | ); 147 | 148 | const getVisibleNodeId = useCallback((item: VisibleNode) => item.id, []); 149 | 150 | const clonedNodes: SimulationNode[] = useStableMap( 151 | visibleNodes, 152 | getVisibleNodeId, 153 | nodeMapper, 154 | ); 155 | 156 | const edgeMapper = useCallback( 157 | (edge: Edge, simEdge: SimulationEdge | undefined) => { 158 | return { 159 | ...edge, 160 | source: simEdge?.source || edge.sourceNodeId, 161 | target: simEdge?.target || edge.targetNodeId, 162 | }; 163 | }, 164 | [], 165 | ); 166 | 167 | const getCurrentEdgeId = useCallback((item: Edge) => item.id, []); 168 | 169 | const clonedEdges: SimulationEdge[] = useStableMap( 170 | visibleEdges, 171 | getCurrentEdgeId, 172 | edgeMapper, 173 | ); 174 | 175 | const nodeEventsByNodeId = useRef>({}); 176 | const nodeSubscriber: NodeSubscriber = useCallback( 177 | (id: string, onNodeChange: NodeEvent) => { 178 | nodeEventsByNodeId.current[id] = onNodeChange; 179 | }, 180 | [], 181 | ); 182 | const edgeEventsByEdgeId = useRef>({}); 183 | const edgeSubscriber: EdgeSubscriber = useCallback( 184 | (id: string, dataFn: EdgeEvent) => { 185 | edgeEventsByEdgeId.current[id] = dataFn; 186 | }, 187 | [], 188 | ); 189 | 190 | // Must be a layout effect because we attach the sim to existing DOM nodes 191 | useLayoutEffect(() => { 192 | if (svg && clonedNodes) { 193 | const simulation = d3 194 | .forceSimulation(clonedNodes) 195 | .force( 196 | 'link', 197 | d3 198 | .forceLink(clonedEdges) 199 | .id((d) => d.id) 200 | .distance(120), 201 | ) 202 | .force('charge', d3.forceManyBody().strength(-500).distanceMax(150)); 203 | 204 | // TODO: consider this when we can plumn tick XOR drag event data (issue #42) 205 | // if (!clonedNodes.some((n) => !n.fixed)) simulation.stop(); 206 | 207 | const link = svg.selectAll('g.edge').data(clonedEdges); 208 | 209 | const node = svg 210 | .selectAll('g.simulation-node .handle') 211 | .data(clonedNodes, function (this: Element, d: any) { 212 | // eslint-disable-next-line no-invalid-this 213 | return d ? d.id : this.id; 214 | }); 215 | 216 | node.on('mouseover', function () { 217 | // eslint-disable-next-line no-invalid-this 218 | d3.select((this as any).parentNode).raise(); 219 | }); 220 | 221 | node.call(drag(simulation, nodeEventsByNodeId.current)); 222 | 223 | simulation.on('end', () => { 224 | const payload: Record = 225 | clonedNodes.reduce((acc, item) => { 226 | acc[item.id] = { x: r10(item.x || 0), y: r10(item.y || 0) }; 227 | 228 | return acc; 229 | }, {}); 230 | 231 | dispatch(updateNodeLocations(payload)); 232 | }); 233 | 234 | simulation.on('tick', () => { 235 | link.each((d: any) => { 236 | edgeEventsByEdgeId.current[d.id]?.({ 237 | x1: r10(d.source.x), 238 | y1: r10(d.source.y), 239 | x2: r10(d.target.x), 240 | y2: r10(d.target.y), 241 | }); 242 | }); 243 | 244 | node.each((d) => { 245 | if (typeof d.x === 'number' && typeof d.y === 'number') { 246 | nodeEventsByNodeId.current[d.id]?.('tick', { 247 | x: r10(d.x), 248 | y: r10(d.y), 249 | }); 250 | 251 | const edgeIds = circularEdgeIdsByNode[d.id]; 252 | 253 | if (edgeIds?.length) { 254 | for (const edgeId of edgeIds) { 255 | edgeEventsByEdgeId.current[edgeId]?.({ 256 | x1: r10(d.x), 257 | y1: r10(d.y), 258 | x2: r10(d.x), 259 | y2: r10(d.y), 260 | }); 261 | } 262 | } 263 | } 264 | }); 265 | }); 266 | 267 | return () => { 268 | simulation.stop(); 269 | }; 270 | } else { 271 | return undefined; 272 | } 273 | }, [clonedNodes, clonedEdges, circularEdgeIdsByNode, svg, dispatch]); 274 | 275 | return ( 276 | 277 | {children} 278 | 279 | ); 280 | }; 281 | 282 | function drag( 283 | simulation: d3.Simulation, 284 | subscribers: { [id: string]: NodeEvent }, 285 | ): any { 286 | function dragstarted(event) { 287 | if (!event.active) simulation.alphaTarget(0.3).restart(); 288 | subscribers[event.subject.id]?.('dragstart', { 289 | x: r10(event.subject.x), 290 | y: r10(event.subject.y), 291 | }); 292 | event.subject.fx = event.subject.x; 293 | event.subject.fy = event.subject.y; 294 | } 295 | 296 | function dragged(event) { 297 | subscribers[event.subject.id]?.('drag', { 298 | x: r10(event.x), 299 | y: r10(event.y), 300 | }); 301 | event.subject.fx = event.x; 302 | event.subject.fy = event.y; 303 | } 304 | 305 | function dragended(event) { 306 | if (!event.active) simulation.alphaTarget(0); 307 | subscribers[event.subject.id]?.('dragend', { 308 | x: r10(event.subject.x), 309 | y: r10(event.subject.y), 310 | }); 311 | if (!event.subject.isPinned) { 312 | event.subject.fx = null; 313 | event.subject.fy = null; 314 | event.subject.vx = null; 315 | event.subject.vy = null; 316 | } 317 | } 318 | 319 | return d3 320 | .drag() 321 | .on('start', dragstarted) 322 | .on('drag', dragged) 323 | .on('end', dragended); 324 | } 325 | 326 | function r10(n: number): number { 327 | return Math.round(n * 10) / 10.0; 328 | } 329 | -------------------------------------------------------------------------------- /src/graph/spotlight.tsx: -------------------------------------------------------------------------------- 1 | import './spotlight.less'; 2 | 3 | import React, { useCallback, useEffect, useState } from 'react'; 4 | 5 | import { IconButton } from '../components/icon-button'; 6 | import { Maximize2, Minimize2, Relay, X } from '../icons'; 7 | import { useDispatch } from '../state'; 8 | import { 9 | deselectNode, 10 | hideNode, 11 | selectField, 12 | } from '../state/graph/graph-actions'; 13 | import { 14 | useArg, 15 | useEnum, 16 | useEnumValues, 17 | useField, 18 | useFields, 19 | useInput, 20 | useInputFields, 21 | useNode, 22 | useSelectedFieldId, 23 | useSelectedSourceNodeId, 24 | useSelectedTargetNodeId, 25 | } from '../state/graph/hooks'; 26 | import { InputField } from '../state/graph'; 27 | import { pluginName } from '../tools/plugins/connections'; 28 | 29 | export const Spotlight: React.VFC = () => { 30 | const sourceId = useSelectedSourceNodeId(); 31 | const fieldId = useSelectedFieldId(); 32 | const targetId = useSelectedTargetNodeId(); 33 | 34 | if (!sourceId) return null; 35 | 36 | return ( 37 |
    38 | 39 | {fieldId && } 40 | {targetId && } 41 |
    42 | ); 43 | }; 44 | 45 | const EdgeSpotlight: React.FC<{ fieldId: string }> = ({ fieldId }) => { 46 | const field = useField(fieldId); 47 | const { name, description, argIds, showWith } = field || {}; 48 | const [isExpanded, setIsExpanded] = useState(true); 49 | useEffect(() => { 50 | setIsExpanded(true); 51 | }, [fieldId]); 52 | return ( 53 |
    54 | {!!argIds?.length && ( 55 |
    56 | setIsExpanded(!isExpanded)} 60 | /> 61 |
    62 | )} 63 |

    64 | {!!showWith && !!showWith.includes(pluginName) && ( 65 | 66 | 67 | 68 | )} 69 | {name} 70 |

    71 | 72 | {description &&
    {description}
    } 73 | {!!argIds?.length && isExpanded && ( 74 |
      75 | {argIds.map((argId) => ( 76 | 77 | ))} 78 |
    79 | )} 80 |
    81 | ); 82 | }; 83 | 84 | const ResolverArg: React.VFC<{ argId: string }> = ({ argId }) => { 85 | const arg = useArg(argId); 86 | const e = useEnum(arg?.typeName); 87 | const input = useInput(arg?.typeName); // TODO: don't match on typename 88 | 89 | if (!arg) return null; 90 | 91 | const { 92 | name, 93 | description, 94 | isList, 95 | typeName, 96 | isListElementNotNull, 97 | isNotNull, 98 | } = arg; 99 | return ( 100 |
  • 101 | {name} 102 | {': '} 103 | 110 | {!!description &&
    {description}
    } 111 | 112 | 113 |
  • 114 | ); 115 | }; 116 | 117 | const Controls: React.VFC<{ 118 | nodeId: string; 119 | isExpanded: boolean; 120 | size: number; 121 | onExpand: () => void; 122 | onCollapse: () => void; 123 | }> = ({ nodeId, isExpanded, size, onExpand, onCollapse }) => { 124 | const dispatch = useDispatch(); 125 | 126 | const handleExpandClick = useCallback(() => { 127 | if (isExpanded) { 128 | onCollapse(); 129 | } else { 130 | onExpand(); 131 | } 132 | }, [isExpanded, onCollapse, onExpand]); 133 | 134 | const handleDeselectClick = useCallback(() => { 135 | dispatch(deselectNode(nodeId)); 136 | }, [dispatch, nodeId]); 137 | 138 | const _handleHideClick = useCallback(() => { 139 | dispatch(hideNode(nodeId)); 140 | }, [dispatch, nodeId]); 141 | 142 | return ( 143 |
    144 | {/* */} 149 | {/* */} 150 | 155 | 156 |
    157 | ); 158 | }; 159 | 160 | const NodeSpotlight: React.VFC<{ nodeId: string }> = ({ nodeId }) => { 161 | const node = useNode(nodeId); 162 | 163 | const fields = [...useFields(nodeId)].sort((a, b) => 164 | a.name.localeCompare(b.name), 165 | ); 166 | const ids = fields.filter((f) => f.typeName === 'ID'); 167 | const scalars = fields.filter((f) => f.typeName !== 'ID' && !f.edgeId); 168 | const edges = fields.filter((f) => f.edgeId); 169 | 170 | const selectedFieldId = useSelectedFieldId(); 171 | 172 | const [isExpanded, setIsExpanded] = useState(!selectedFieldId); 173 | 174 | useEffect(() => { 175 | setIsExpanded(!selectedFieldId); 176 | }, [nodeId, selectedFieldId]); 177 | 178 | return ( 179 |
    180 | setIsExpanded(true)} 185 | onCollapse={() => setIsExpanded(false)} 186 | /> 187 |

    {nodeId}

    188 | {!!node?.description &&
    {node.description}
    } 189 | {isExpanded && ( 190 | <> 191 |
      192 | {ids.map((field) => ( 193 | 194 | ))} 195 |
    196 |
      197 | {edges.map((field) => ( 198 | 199 | ))} 200 |
    201 |
      202 | {scalars.map((field) => ( 203 | 204 | ))} 205 |
    206 | 207 | )} 208 |
    209 | ); 210 | }; 211 | 212 | const IdField: React.VFC<{ fieldId: string }> = ({ fieldId }) => { 213 | const field = useField(fieldId); 214 | if (!field) return null; 215 | const { name, description } = field; 216 | return ( 217 |
  • 218 |
    {name}
    219 | {!!description &&
    {description}
    } 220 |
  • 221 | ); 222 | }; 223 | 224 | const EdgeField: React.VFC<{ fieldId: string }> = ({ fieldId }) => { 225 | const dispatch = useDispatch(); 226 | const field = useField(fieldId); 227 | 228 | const handleClick = useCallback(() => { 229 | dispatch(selectField(fieldId)); 230 | }, [fieldId, dispatch]); 231 | 232 | const selectedFieldId = useSelectedFieldId(); 233 | 234 | const isSelected = selectedFieldId === fieldId; 235 | 236 | if (!field) return null; 237 | const { 238 | name, 239 | description, 240 | isList, 241 | typeName, 242 | isListElementNotNull, 243 | isNotNull, 244 | showWith, 245 | } = field; 246 | 247 | const typeDescription = undefined; // TODO: 248 | 249 | return ( 250 |
  • 255 | {name} 256 | {': '} 257 | {showWith?.includes(pluginName) && ( 258 | 259 | 260 | 261 | )} 262 | 269 |
    {!!description ? description : null}
    270 |
  • 271 | ); 272 | }; 273 | 274 | const ScalarField: React.VFC<{ fieldId: string }> = ({ fieldId }) => { 275 | const field = useField(fieldId); 276 | const e = useEnum(field?.typeName); 277 | if (!field) return null; 278 | 279 | const { 280 | name, 281 | description, 282 | typeName, 283 | isNotNull, 284 | isList, 285 | isListElementNotNull, 286 | } = field; 287 | 288 | return ( 289 |
  • 290 |
    291 | {name} 292 | {': '} 293 | 300 |
    301 |
    {!!description ? description : null}
    302 | 303 |
  • 304 | ); 305 | }; 306 | 307 | const EnumValuesInfo: React.VFC<{ enumId: string }> = ({ enumId }) => { 308 | const e = useEnum(enumId); 309 | const values = useEnumValues(enumId); 310 | 311 | if (!e) return null; 312 | 313 | return ( 314 |
      315 | {values.map((value) => ( 316 |
    • 322 | {value.name} 323 | {!!value.description && ( 324 | : {value.description} 325 | )} 326 | {!!value.isDeprecated && !!value.deprecationReason && ( 327 |
      ⚠️ {value.deprecationReason}
      328 | )} 329 |
    • 330 | ))} 331 |
    332 | ); 333 | }; 334 | 335 | const InputFieldsInfo: React.VFC<{ inputId: string }> = ({ inputId }) => { 336 | const input = useInput(inputId); 337 | const fields = useInputFields(inputId); 338 | 339 | if (!input) return null; 340 | 341 | return ( 342 |
      343 | {fields.map((field) => ( 344 | 345 | ))} 346 |
    347 | ); 348 | }; 349 | 350 | const InputFieldInfo: React.VFC<{ field: InputField }> = ({ field }) => { 351 | const e = useEnum(field.typeName); 352 | const input = useInput(field.typeName); 353 | return ( 354 |
  • 355 |
    356 | {field.name} 357 | {': '} 358 | 365 |
    366 | {!!field.description && ( 367 |
    {field.description}
    368 | )} 369 | 370 | 371 |
  • 372 | ); 373 | }; 374 | 375 | export const TypeDisplayName: React.VFC<{ 376 | typeName: string; 377 | typeDescription?: string; 378 | isList?: boolean; 379 | isNotNull?: boolean; 380 | isListElementNotNull?: boolean; 381 | }> = ({ 382 | typeName, 383 | typeDescription, 384 | isListElementNotNull, 385 | isList, 386 | isNotNull, 387 | }) => ( 388 | 389 | {isList && '['} 390 | {typeName} 391 | {isListElementNotNull && '!'} 392 | {isList && ']'} 393 | {isNotNull && '!'} 394 | 395 | ); 396 | -------------------------------------------------------------------------------- /src/tools/factory.tests.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'graphql'; 2 | import { 3 | buildArgs, 4 | buildEdges, 5 | buildEnums, 6 | buildEnumValues, 7 | buildFields, 8 | buildInputFields, 9 | buildInputs, 10 | buildNodes, 11 | } from './factory'; 12 | 13 | describe('factory', () => { 14 | describe(buildNodes, () => { 15 | it('builds nodes', () => { 16 | // ARRANGE 17 | const document = parse(` 18 | """ 19 | The query node 20 | """ 21 | type Query { 22 | x: ComplexType 23 | } 24 | 25 | type ComplexType { 26 | id: ID! 27 | value: String 28 | } 29 | 30 | enum EnumA { 31 | FOO 32 | BAR 33 | } 34 | 35 | input ReviewInput { 36 | stars: Int! 37 | commentary: String 38 | }`); 39 | 40 | // ACT 41 | const result = buildNodes(document); 42 | 43 | // ASSERT 44 | expect(result).toStrictEqual([ 45 | { 46 | id: 'Query', 47 | edgeIds: ['ComplexType>Query'], 48 | fieldIds: ['Query.x'], 49 | description: 'The query node', 50 | }, 51 | { 52 | id: 'ComplexType', 53 | edgeIds: ['ComplexType>Query'], 54 | fieldIds: ['ComplexType.id', 'ComplexType.value'], 55 | }, 56 | ]); 57 | }); 58 | }); 59 | 60 | describe(buildFields, () => { 61 | it('builds fields with scalar types', () => { 62 | const document = parse(` 63 | type TypeA { 64 | foo: String 65 | }`); 66 | 67 | // ACT 68 | const result = buildFields(document); 69 | 70 | // ASSERT 71 | expect(result).toStrictEqual([ 72 | { 73 | id: 'TypeA.foo', 74 | name: 'foo', 75 | isList: false, 76 | isNotNull: false, 77 | nodeId: 'TypeA', 78 | typeKind: 'SCALAR', 79 | typeName: 'String', 80 | isReverse: false, 81 | argIds: [], 82 | }, 83 | ]); 84 | }); 85 | 86 | it('builds fields with non-scalar types', () => { 87 | // ARRANGE 88 | const document = parse(` 89 | type TypeA { 90 | nullArray: [TypeB] 91 | nullCompactArray: [TypeB!] 92 | compactArray: [TypeB]! 93 | array: [TypeB!]! 94 | } 95 | 96 | type TypeB { 97 | nullComplex: TypeA 98 | complex: TypeA! 99 | }`); 100 | 101 | // ACT 102 | const result = buildFields(document); 103 | 104 | // ASSERT 105 | expect(result).toStrictEqual([ 106 | { 107 | id: 'TypeA.nullArray', 108 | name: 'nullArray', 109 | edgeId: 'TypeA>TypeB', 110 | isList: true, 111 | isNotNull: false, 112 | isListElementNotNull: false, 113 | nodeId: 'TypeA', 114 | typeKind: 'OBJECT', 115 | typeName: 'TypeB', 116 | isReverse: false, 117 | argIds: [], 118 | }, 119 | { 120 | id: 'TypeA.nullCompactArray', 121 | name: 'nullCompactArray', 122 | edgeId: 'TypeA>TypeB', 123 | isList: true, 124 | isNotNull: false, 125 | isListElementNotNull: true, 126 | nodeId: 'TypeA', 127 | typeKind: 'OBJECT', 128 | typeName: 'TypeB', 129 | isReverse: false, 130 | argIds: [], 131 | }, 132 | { 133 | id: 'TypeA.compactArray', 134 | name: 'compactArray', 135 | edgeId: 'TypeA>TypeB', 136 | isList: true, 137 | isNotNull: true, 138 | isListElementNotNull: false, 139 | nodeId: 'TypeA', 140 | typeKind: 'OBJECT', 141 | typeName: 'TypeB', 142 | isReverse: false, 143 | argIds: [], 144 | }, 145 | { 146 | id: 'TypeA.array', 147 | name: 'array', 148 | edgeId: 'TypeA>TypeB', 149 | isList: true, 150 | isNotNull: true, 151 | isListElementNotNull: true, 152 | nodeId: 'TypeA', 153 | typeKind: 'OBJECT', 154 | typeName: 'TypeB', 155 | isReverse: false, 156 | argIds: [], 157 | }, 158 | { 159 | id: 'TypeB.nullComplex', 160 | name: 'nullComplex', 161 | edgeId: 'TypeA>TypeB', 162 | isList: false, 163 | isNotNull: false, 164 | nodeId: 'TypeB', 165 | typeKind: 'OBJECT', 166 | typeName: 'TypeA', 167 | isReverse: true, 168 | argIds: [], 169 | }, 170 | { 171 | id: 'TypeB.complex', 172 | name: 'complex', 173 | edgeId: 'TypeA>TypeB', 174 | isList: false, 175 | isNotNull: true, 176 | nodeId: 'TypeB', 177 | typeKind: 'OBJECT', 178 | typeName: 'TypeA', 179 | isReverse: true, 180 | argIds: [], 181 | }, 182 | ]); 183 | }); 184 | }); 185 | 186 | describe(buildEdges, () => { 187 | it('builds edges', () => { 188 | // ARRANGE 189 | const document = parse(` 190 | type TypeA { 191 | id: ID! 192 | nullArray: [TypeB] 193 | nullCompactArray: [TypeB!] 194 | compactArray: [TypeB]! 195 | array: [TypeB!]! 196 | } 197 | 198 | type TypeB { 199 | id: ID! 200 | nullComplex: TypeA 201 | complex: TypeA! 202 | }`); 203 | 204 | // ACT 205 | const result = buildEdges(document); 206 | 207 | // ASSERT 208 | expect(result).toStrictEqual([ 209 | { 210 | id: 'TypeA>TypeB', 211 | sourceNodeId: 'TypeA', 212 | targetNodeId: 'TypeB', 213 | fieldIds: [ 214 | 'TypeA.nullArray', 215 | 'TypeA.nullCompactArray', 216 | 'TypeA.compactArray', 217 | 'TypeA.array', 218 | 'TypeB.nullComplex', 219 | 'TypeB.complex', 220 | ], 221 | }, 222 | ]); 223 | }); 224 | }); 225 | 226 | describe(buildArgs, () => { 227 | it('builds args', () => { 228 | // ARRANGE 229 | const document = parse(` 230 | type TypeA { 231 | id: ID! 232 | child( 233 | nullArray: [String] 234 | nullCompactArray: [String!] 235 | compactArray: [String]! 236 | array: [String!]! 237 | ): TypeB 238 | } 239 | 240 | type TypeB { 241 | id: ID! 242 | nullComplex: TypeA 243 | complex: TypeA! 244 | }`); 245 | 246 | // ACT 247 | const result = buildArgs(document); 248 | 249 | // ASSERT 250 | expect(result).toStrictEqual([ 251 | { 252 | id: 'TypeA.child(nullArray)', 253 | fieldId: 'TypeA.child', 254 | name: 'nullArray', 255 | typeKind: 'SCALAR', 256 | typeName: 'String', 257 | isList: true, 258 | isNotNull: false, 259 | isListElementNotNull: false, 260 | }, 261 | { 262 | id: 'TypeA.child(nullCompactArray)', 263 | fieldId: 'TypeA.child', 264 | name: 'nullCompactArray', 265 | typeKind: 'SCALAR', 266 | typeName: 'String', 267 | isList: true, 268 | isNotNull: false, 269 | isListElementNotNull: true, 270 | }, 271 | { 272 | id: 'TypeA.child(compactArray)', 273 | fieldId: 'TypeA.child', 274 | name: 'compactArray', 275 | typeKind: 'SCALAR', 276 | typeName: 'String', 277 | isList: true, 278 | isNotNull: true, 279 | isListElementNotNull: false, 280 | }, 281 | { 282 | id: 'TypeA.child(array)', 283 | fieldId: 'TypeA.child', 284 | name: 'array', 285 | typeKind: 'SCALAR', 286 | typeName: 'String', 287 | isList: true, 288 | isNotNull: true, 289 | isListElementNotNull: true, 290 | }, 291 | ]); 292 | }); 293 | }); 294 | 295 | describe(buildEnums, () => { 296 | it('builds enums', () => { 297 | // ARRANGE 298 | const document = parse(` 299 | enum EnumA { 300 | FOO 301 | """ 302 | The value of bar 303 | """ 304 | BAR 305 | } 306 | 307 | """ 308 | The second enum 309 | """ 310 | enum EnumB { 311 | FIZZ 312 | BUZZ 313 | }`); 314 | 315 | // ACT 316 | const result = buildEnums(document); 317 | 318 | // ASSERT 319 | expect(result).toStrictEqual([ 320 | { id: 'EnumA', valueIds: ['EnumA.FOO', 'EnumA.BAR'] }, 321 | { 322 | id: 'EnumB', 323 | description: 'The second enum', 324 | valueIds: ['EnumB.FIZZ', 'EnumB.BUZZ'], 325 | }, 326 | ]); 327 | }); 328 | }); 329 | 330 | describe(buildEnumValues, () => { 331 | it('builds enums', () => { 332 | // ARRANGE 333 | const document = parse(` 334 | enum EnumA { 335 | FOO 336 | """ 337 | The value of bar 338 | """ 339 | BAR 340 | } 341 | 342 | """ 343 | The second enum 344 | """ 345 | enum EnumB { 346 | FIZZ 347 | BUZZ 348 | }`); 349 | 350 | // ACT 351 | const result = buildEnumValues(document); 352 | 353 | // ASSERT 354 | expect(result).toStrictEqual([ 355 | { 356 | id: 'EnumA.FOO', 357 | enumId: 'EnumA', 358 | name: 'FOO', 359 | isDeprecated: false, 360 | }, 361 | { 362 | id: 'EnumA.BAR', 363 | enumId: 'EnumA', 364 | description: 'The value of bar', 365 | name: 'BAR', 366 | isDeprecated: false, 367 | }, 368 | { 369 | id: 'EnumB.FIZZ', 370 | enumId: 'EnumB', 371 | name: 'FIZZ', 372 | isDeprecated: false, 373 | }, 374 | { 375 | id: 'EnumB.BUZZ', 376 | enumId: 'EnumB', 377 | name: 'BUZZ', 378 | isDeprecated: false, 379 | }, 380 | ]); 381 | }); 382 | }); 383 | 384 | describe(buildInputs, () => { 385 | it('builds inputs', () => { 386 | // ARRANGE 387 | const document = parse(` 388 | """ 389 | The query node 390 | """ 391 | type Query { 392 | x: ComplexType 393 | } 394 | 395 | type ComplexType { 396 | id: ID! 397 | value: String 398 | } 399 | 400 | enum EnumA { 401 | FOO 402 | BAR 403 | } 404 | 405 | input InputA { 406 | foo: Int! 407 | bar: String 408 | child: InputB! 409 | } 410 | 411 | """ 412 | Description for InputB 413 | """ 414 | input InputB { 415 | foo: Int! 416 | """ 417 | Description for bar 418 | """ 419 | bar: String 420 | }`); 421 | 422 | // ACT 423 | const result = buildInputs(document); 424 | 425 | // ASSERT 426 | expect(result).toStrictEqual([ 427 | { 428 | id: 'InputA', 429 | inputFieldIds: ['InputA.foo', 'InputA.bar', 'InputA.child'], 430 | }, 431 | { 432 | id: 'InputB', 433 | inputFieldIds: ['InputB.foo', 'InputB.bar'], 434 | description: 'Description for InputB', 435 | }, 436 | ]); 437 | }); 438 | }); 439 | 440 | describe(buildInputFields, () => { 441 | it('builds input fields', () => { 442 | // ARRANGE 443 | const document = parse(` 444 | input InputA { 445 | foo: Int! 446 | bar: String 447 | child: InputB! 448 | } 449 | 450 | """ 451 | Description for InputB 452 | """ 453 | input InputB { 454 | foo: Int! 455 | """ 456 | Description for bar 457 | """ 458 | bar: String 459 | }`); 460 | 461 | // ACT 462 | const result = buildInputFields(document); 463 | 464 | // ASSERT 465 | expect(result).toStrictEqual([ 466 | { 467 | id: 'InputA.foo', 468 | name: 'foo', 469 | isList: false, 470 | isNotNull: true, 471 | inputId: 'InputA', 472 | typeKind: 'SCALAR', 473 | typeName: 'Int', 474 | }, 475 | { 476 | id: 'InputA.bar', 477 | name: 'bar', 478 | isList: false, 479 | isNotNull: false, 480 | inputId: 'InputA', 481 | typeKind: 'SCALAR', 482 | typeName: 'String', 483 | }, 484 | { 485 | id: 'InputA.child', 486 | name: 'child', 487 | isList: false, 488 | isNotNull: true, 489 | inputId: 'InputA', 490 | typeKind: 'INPUT_OBJECT', 491 | typeName: 'InputB', 492 | }, 493 | { 494 | id: 'InputB.foo', 495 | name: 'foo', 496 | isList: false, 497 | isNotNull: true, 498 | inputId: 'InputB', 499 | typeKind: 'SCALAR', 500 | typeName: 'Int', 501 | }, 502 | { 503 | id: 'InputB.bar', 504 | name: 'bar', 505 | isList: false, 506 | isNotNull: false, 507 | inputId: 'InputB', 508 | typeKind: 'SCALAR', 509 | typeName: 'String', 510 | description: 'Description for bar', 511 | }, 512 | ]); 513 | }); 514 | }); 515 | }); 516 | -------------------------------------------------------------------------------- /src/state/graph/reducer.ts: -------------------------------------------------------------------------------- 1 | import * as fsf from 'flux-standard-functions'; 2 | 3 | import { 4 | GraphState, 5 | defaultState, 6 | edgeDef, 7 | stateDef, 8 | VisibleNode, 9 | visibleNodeDef, 10 | argDef, 11 | fieldDef, 12 | nodeDef, 13 | } from '.'; 14 | import { GraphAction } from './graph-actions'; 15 | import { enumDef, enumValueDef, inputDef, inputFieldDef } from './types'; 16 | 17 | export type Action = GraphAction; 18 | 19 | export function reducer( 20 | state: GraphState = defaultState, 21 | action: Action, 22 | ): GraphState { 23 | switch (action.type) { 24 | case 'graph/import_state': { 25 | const { 26 | payload: { 27 | args, 28 | nodes, 29 | edges, 30 | fields, 31 | enums, 32 | enumValues, 33 | inputs, 34 | inputFields, 35 | visibleNodes, 36 | plugins, 37 | activePlugins, 38 | }, 39 | } = action; 40 | 41 | const fieldIdsByNodeId = new Map>(); 42 | const fieldIdsByEdgeId = new Map>(); 43 | const argIdsByFieldId = new Map>(); 44 | for (const arg of args) { 45 | const byFieldId = argIdsByFieldId.get(arg.fieldId); 46 | if (byFieldId) { 47 | byFieldId.add(arg.id); 48 | } else { 49 | argIdsByFieldId.set(arg.fieldId, new Set([arg.id])); 50 | } 51 | } 52 | for (const field of fields) { 53 | const byNodeId = fieldIdsByNodeId.get(field.nodeId); 54 | if (byNodeId) { 55 | byNodeId.add(field.id); 56 | } else { 57 | fieldIdsByNodeId.set(field.nodeId, new Set([field.id])); 58 | } 59 | 60 | if (field.edgeId) { 61 | const byEdgeId = fieldIdsByEdgeId.get(field.edgeId); 62 | if (byEdgeId) { 63 | byEdgeId.add(field.id); 64 | } else { 65 | fieldIdsByEdgeId.set(field.edgeId, new Set([field.id])); 66 | } 67 | } 68 | field.argIds = Array.from(argIdsByFieldId.get(field.id) || []); 69 | } 70 | for (const node of nodes) { 71 | node.fieldIds = Array.from(fieldIdsByNodeId.get(node.id) || []); 72 | } 73 | for (const edge of edges) { 74 | edge.fieldIds = Array.from(fieldIdsByEdgeId.get(edge.id) || []); 75 | } 76 | 77 | const visibleNodeIds = new Set(visibleNodes.map((n) => n.id)); 78 | 79 | const visibleEdgeIds = edges 80 | .filter( 81 | (edge) => visibleNodeIds.has(edge.id) && visibleNodeIds.has(edge.id), 82 | ) 83 | .map((edge) => edge.id); 84 | 85 | return { 86 | args: fsf.index(args, argDef), 87 | nodes: fsf.index(nodes, nodeDef), 88 | edges: fsf.index(edges, edgeDef), 89 | fields: fsf.index(fields, fieldDef), 90 | enums: fsf.index(enums, enumDef), 91 | enumValues: fsf.index(enumValues, enumValueDef), 92 | inputs: fsf.index(inputs, inputDef), 93 | inputFields: fsf.index(inputFields, inputFieldDef), 94 | visibleNodes: fsf.index(visibleNodes, visibleNodeDef), 95 | visibleEdgeIds, 96 | plugins, 97 | activePlugins, 98 | }; 99 | } 100 | // TODO: deprecate in favor of passing visible nodes in graph/import_state 101 | case 'graph/import_save_state': { 102 | const { payload } = action; 103 | if (!payload?.graph?.visibleNodes) return state; 104 | 105 | const { 106 | visibleNodes, 107 | selectedSourceNodeId: s, 108 | selectedFieldId: f, 109 | selectedTargetNodeId: t, 110 | } = payload.graph; 111 | 112 | let nextState = state; 113 | nextState = fsf.set(nextState, 'visibleNodes', {}, stateDef); 114 | nextState = fsf.set(nextState, 'visibleEdgeIds', [], stateDef); 115 | nextState = fsf.unset(nextState, 'selectedSourceNodeId', stateDef); 116 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 117 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 118 | 119 | const nodeIds = new Set(Object.keys(visibleNodes)); 120 | 121 | nextState = showNodes(nextState, nodeIds, visibleNodes); 122 | 123 | if (s && nextState.nodes[s]) { 124 | nextState = fsf.set(nextState, 'selectedSourceNodeId', s, stateDef); 125 | } 126 | if ( 127 | f && 128 | s && 129 | t && 130 | nextState.nodes[s] && 131 | nextState.fields[f] && 132 | nextState.nodes[t] 133 | ) { 134 | nextState = fsf.set(nextState, 'selectedFieldId', f, stateDef); 135 | nextState = fsf.set(nextState, 'selectedTargetNodeId', t, stateDef); 136 | } 137 | 138 | return nextState; 139 | } 140 | case 'graph/hide_all_nodes': { 141 | let nextState = state; 142 | nextState = fsf.set(nextState, 'visibleNodes', {}, stateDef); 143 | nextState = fsf.set(nextState, 'visibleEdgeIds', [], stateDef); 144 | nextState = fsf.unset(nextState, 'selectedSourceNodeId', stateDef); 145 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 146 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 147 | return nextState; 148 | } 149 | case 'graph/hide_unpinned_nodes': { 150 | const unpinnedNodeIds = new Set( 151 | fsf 152 | .deindex(state.visibleNodes) 153 | .filter((visibleNode) => !visibleNode.isPinned) 154 | .map((visibleNode) => visibleNode.id), 155 | ); 156 | 157 | return hideNodes(state, unpinnedNodeIds); 158 | } 159 | case 'graph/expand_node': { 160 | const { payload: nodeId } = action; 161 | 162 | const visibleEdgeIds = new Set(state.visibleEdgeIds); 163 | 164 | const nodeIds = new Set( 165 | fsf 166 | .deindex(state.edges) 167 | .filter( 168 | (edge) => 169 | !visibleEdgeIds.has(edge.id) && 170 | (edge.sourceNodeId === nodeId || edge.targetNodeId === nodeId), 171 | ) 172 | .map((edge) => 173 | edge.sourceNodeId === nodeId 174 | ? edge.targetNodeId 175 | : edge.sourceNodeId, 176 | ), 177 | ); 178 | 179 | return showNodes(state, nodeIds); 180 | } 181 | case 'graph/pin_node': { 182 | const { 183 | payload: { nodeId, x, y }, 184 | } = action; 185 | 186 | if (!state.visibleNodes[nodeId]?.isPinned) { 187 | return fsf.patch( 188 | state, 189 | { 190 | visibleNodes: { 191 | [nodeId]: { id: nodeId, isPinned: true, x, y }, 192 | }, 193 | }, 194 | stateDef, 195 | ); 196 | } else { 197 | return state; 198 | } 199 | } 200 | case 'graph/unpin_node': { 201 | const { payload: nodeId } = action; 202 | 203 | if (state.visibleNodes[nodeId]?.isPinned) { 204 | return fsf.patch( 205 | state, 206 | { 207 | visibleNodes: { 208 | [nodeId]: { isPinned: false }, 209 | }, 210 | }, 211 | stateDef, 212 | ); 213 | } else { 214 | return state; 215 | } 216 | } 217 | case 'graph/update_node_location': { 218 | const { 219 | payload: { nodeId, x, y }, 220 | } = action; 221 | 222 | if (state.visibleNodes[nodeId]?.isPinned) { 223 | return fsf.patch( 224 | state, 225 | { 226 | visibleNodes: { 227 | [nodeId]: { x, y }, 228 | }, 229 | }, 230 | stateDef, 231 | ); 232 | } else { 233 | return state; 234 | } 235 | } 236 | case 'graph/update_node_locations': { 237 | const { payload } = action; 238 | 239 | return fsf.patch(state, { visibleNodes: payload }, stateDef); 240 | } 241 | case 'graph/hide_node': { 242 | const { payload: nodeId } = action; 243 | return hideNodes(state, new Set([nodeId])); 244 | } 245 | case 'graph/show_node': { 246 | const { payload: nodeId } = action; 247 | return showNodes(state, new Set([nodeId])); 248 | } 249 | case 'graph/select_node': { 250 | const { payload: nodeId } = action; 251 | const node = state.nodes[nodeId]; 252 | if (!node) return state; 253 | 254 | let nextState = state; 255 | nextState = fsf.set(nextState, 'selectedSourceNodeId', node.id, stateDef); 256 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 257 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 258 | 259 | const nodesToShow = new Set(); 260 | if (!nextState.visibleNodes[node.id]) nodesToShow.add(node.id); 261 | 262 | if (nodesToShow.size) nextState = showNodes(nextState, nodesToShow); 263 | 264 | return nextState; 265 | } 266 | case 'graph/deselect_node': { 267 | const { payload: nodeId } = action; 268 | 269 | let nextState = state; 270 | 271 | if (nodeId === nextState.selectedSourceNodeId) { 272 | nextState = fsf.unset(nextState, 'selectedSourceNodeId', stateDef); 273 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 274 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 275 | } else if (nodeId === nextState.selectedTargetNodeId) { 276 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 277 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 278 | } 279 | 280 | return nextState; 281 | } 282 | case 'graph/select_field': { 283 | const { payload: fieldId } = action; 284 | const field = state.fields[fieldId]; 285 | if (!field?.edgeId) return state; 286 | const edge = state.edges[field.edgeId]; 287 | if (!edge) return state; 288 | 289 | let nextState = state; 290 | nextState = fsf.set(nextState, 'selectedFieldId', field.id, stateDef); 291 | 292 | const { sourceNodeId: s, targetNodeId: t } = edge; 293 | 294 | if (field.isReverse) { 295 | nextState = fsf.set(nextState, 'selectedSourceNodeId', t, stateDef); 296 | nextState = fsf.set(nextState, 'selectedTargetNodeId', s, stateDef); 297 | } else { 298 | nextState = fsf.set(nextState, 'selectedSourceNodeId', s, stateDef); 299 | nextState = fsf.set(nextState, 'selectedTargetNodeId', t, stateDef); 300 | } 301 | 302 | const nodesToShow = new Set(); 303 | if (!nextState.visibleNodes[s]) nodesToShow.add(s); 304 | if (!nextState.visibleNodes[t]) nodesToShow.add(t); 305 | 306 | if (nodesToShow.size) nextState = showNodes(nextState, nodesToShow); 307 | 308 | return nextState; 309 | } 310 | case 'graph/deselect_field': { 311 | const { payload: fieldId } = action; 312 | 313 | let nextState = state; 314 | 315 | if (fieldId === nextState.selectedFieldId) { 316 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 317 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 318 | } 319 | 320 | return nextState; 321 | } 322 | default: 323 | return state; 324 | } 325 | } 326 | 327 | function hideNodes(state: GraphState, nodeIds: Set): GraphState { 328 | let nextState = state; 329 | const edgeIdsToHide = state.visibleEdgeIds 330 | .map((edgeId) => state.edges[edgeId]) 331 | .filter( 332 | (edge) => 333 | nodeIds.has(edge.sourceNodeId) || nodeIds.has(edge.targetNodeId), 334 | ) 335 | .map((edge) => edge.id); 336 | 337 | // TODO: avoid unnecessary spread (issue: #45) 338 | nextState = { 339 | ...nextState, 340 | visibleEdgeIds: fsf.unsetEach( 341 | nextState.visibleEdgeIds, 342 | Array.from(edgeIdsToHide), 343 | ), 344 | visibleNodes: fsf.unsetEach(nextState.visibleNodes, Array.from(nodeIds)), 345 | }; 346 | 347 | if (nodeIds.has(nextState.selectedSourceNodeId || '')) { 348 | nextState = fsf.unset(nextState, 'selectedSourceNodeId', stateDef); 349 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 350 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 351 | } else if (nodeIds.has(nextState.selectedTargetNodeId || '')) { 352 | nextState = fsf.unset(nextState, 'selectedFieldId', stateDef); 353 | nextState = fsf.unset(nextState, 'selectedTargetNodeId', stateDef); 354 | } 355 | 356 | return nextState; 357 | } 358 | 359 | function showNodes( 360 | state: GraphState, 361 | nodeIds: Set, 362 | data?: Record, 363 | ): GraphState { 364 | let nextState = state; 365 | 366 | const visibleEdgeIds = new Set(nextState.visibleEdgeIds); 367 | 368 | const nodesToShow = fsf.index( 369 | Array.from(nodeIds) 370 | .filter( 371 | (nodeId) => !nextState.visibleNodes[nodeId] && nextState.nodes[nodeId], 372 | ) 373 | .map((nodeId) => data?.[nodeId] || { id: nodeId, isPinned: false }), 374 | visibleNodeDef, 375 | ); 376 | 377 | const edgeIdsToShow = fsf 378 | .deindex(nextState.edges) 379 | .filter( 380 | (edge) => 381 | !visibleEdgeIds.has(edge.id) && 382 | (nodeIds.has(edge.sourceNodeId) || 383 | nextState.visibleNodes[edge.sourceNodeId]) && 384 | (nodeIds.has(edge.targetNodeId) || 385 | nextState.visibleNodes[edge.targetNodeId]), 386 | ) 387 | .map((edge) => edge.id); 388 | 389 | // TODO: avoid unnecessary spread (issue: #45) 390 | nextState = { 391 | ...state, 392 | visibleEdgeIds: fsf.setEach(nextState.visibleEdgeIds, edgeIdsToShow), 393 | visibleNodes: fsf.patchEach( 394 | nextState.visibleNodes, 395 | nodesToShow, 396 | visibleNodeDef, 397 | ), 398 | }; 399 | 400 | return nextState; 401 | } 402 | --------------------------------------------------------------------------------