├── .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 |
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 |
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 |
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 | [](https://github.com/domain-graph/domain-graph/actions?query=workflow%3Abuild+branch%3Amaster+event%3Apush)
2 | [](https://www.npmjs.com/package/domain-graph)
3 |
4 | # DomainGraph
5 |
6 | Beautiful, interactive visualizations for GraphQL schemas
7 |
8 | 
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 |
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 |
--------------------------------------------------------------------------------