Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions src/components/GraphViewport.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import { GraphQLNamedType } from 'graphql';
import { Component, createRef } from 'react';

import { renderSvg } from '../graph/svg-renderer.ts';
import { TypeGraph } from '../graph/type-graph.ts';
import { Viewport } from '../graph/viewport.ts';
import { extractTypeName, typeObjToId } from '../introspection/utils.ts';
import ZoomInIcon from './icons/zoom-in.svg';
import ZoomOutIcon from './icons/zoom-out.svg';
import ZoomResetIcon from './icons/zoom-reset.svg';
import LoadingAnimation from './utils/LoadingAnimation.tsx';
import { GraphSelection } from './Voyager.tsx';
import { type NavStack } from './Voyager.tsx';

interface GraphViewportProps {
typeGraph: TypeGraph | null;

selectedTypeID: string | null;
selectedEdgeID: string | null;

onSelect: (selection: GraphSelection) => void;
navStack: NavStack | null;
onSelectNode: (type: GraphQLNamedType | null) => void;
onSelectEdge: (
edgeID: string,
fromType: GraphQLNamedType,
toType: GraphQLNamedType,
) => void;
}

interface GraphViewportState {
typeGraph: TypeGraph | null;
typeGraph: TypeGraph | null | undefined;
svgViewport: Viewport | null;
}

Expand All @@ -41,7 +44,7 @@ export default class GraphViewport extends Component<
props: GraphViewportProps,
state: GraphViewportState,
): GraphViewportState | null {
const { typeGraph } = props;
const typeGraph = props.navStack?.typeGraph;

if (typeGraph !== state.typeGraph) {
return { typeGraph, svgViewport: null };
Expand All @@ -51,29 +54,34 @@ export default class GraphViewport extends Component<
}

componentDidMount() {
this._renderSvgAsync(this.props.typeGraph);
this._renderSvgAsync(this.props.navStack?.typeGraph);
}

componentDidUpdate(
prevProps: GraphViewportProps,
prevState: GraphViewportState,
) {
const navStack = this.props.navStack;
const prevNavStack = prevProps.navStack;
const { svgViewport } = this.state;

if (svgViewport == null) {
this._renderSvgAsync(this.props.typeGraph);
this._renderSvgAsync(navStack?.typeGraph);
return;
}

const isJustRendered = prevState.svgViewport == null;
const { selectedTypeID, selectedEdgeID } = this.props;

if (prevProps.selectedTypeID !== selectedTypeID || isJustRendered) {
svgViewport.selectNodeById(selectedTypeID);
if (prevNavStack?.type !== navStack?.type || isJustRendered) {
const nodeId = navStack?.type == null ? null : typeObjToId(navStack.type);
svgViewport.selectNodeById(nodeId);
}

if (prevProps.selectedEdgeID !== selectedEdgeID || isJustRendered) {
svgViewport.selectEdgeById(selectedEdgeID);
if (
prevNavStack?.selectedEdgeID !== navStack?.selectedEdgeID ||
isJustRendered
) {
svgViewport.selectEdgeById(navStack?.selectedEdgeID);
}
}

Expand All @@ -82,7 +90,7 @@ export default class GraphViewport extends Component<
this._cleanupSvgViewport();
}

_renderSvgAsync(typeGraph: TypeGraph | null) {
_renderSvgAsync(typeGraph: TypeGraph | null | undefined) {
if (typeGraph == null) {
return; // Nothing to render
}
Expand All @@ -93,7 +101,7 @@ export default class GraphViewport extends Component<

this._currentTypeGraph = typeGraph;

const { onSelect } = this.props;
const { onSelectNode, onSelectEdge } = this.props;
renderSvg(typeGraph)
.then((svg) => {
if (typeGraph !== this._currentTypeGraph) {
Expand All @@ -104,7 +112,22 @@ export default class GraphViewport extends Component<
const svgViewport = new Viewport(
svg,
this._containerRef.current!,
onSelect,
(nodeId: string | null) => {
if (nodeId == null) {
return onSelectNode(null);
}
const type = typeGraph.nodes.get(extractTypeName(nodeId));
if (type != null) {
onSelectNode(type);
}
},
(edgeID: string, toID: string) => {
const fromType = typeGraph.nodes.get(extractTypeName(edgeID));
const toType = typeGraph.nodes.get(extractTypeName(toID));
if (fromType != null && toType != null) {
onSelectEdge(edgeID, fromType, toType);
}
},
);
this.setState({ svgViewport });
})
Expand Down Expand Up @@ -178,10 +201,10 @@ export default class GraphViewport extends Component<
);
}

focusNode(id: string) {
focusNode(type: GraphQLNamedType): void {
const { svgViewport } = this.state;
if (svgViewport) {
svgViewport.focusElement(id);
svgViewport.focusElement(typeObjToId(type));
}
}

Expand Down
146 changes: 107 additions & 39 deletions src/components/Voyager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { ThemeProvider } from '@mui/material/styles';
import { ExecutionResult } from 'graphql/execution';
import { GraphQLSchema } from 'graphql/type';
import { GraphQLNamedType, GraphQLSchema } from 'graphql/type';
import { buildClientSchema, IntrospectionQuery } from 'graphql/utilities';
import {
Children,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import { getTypeGraph } from '../graph/type-graph.ts';
import { getTypeGraph, TypeGraph } from '../graph/type-graph.ts';
import { getSchema } from '../introspection/introspection.ts';
import { MaybePromise, usePromise } from '../utils/usePromise.ts';
import DocExplorer from './doc-explorer/DocExplorer.tsx';
Expand Down Expand Up @@ -51,9 +52,23 @@ export interface VoyagerProps {
children?: ReactNode;
}

export type GraphSelection =
| { typeID: null; edgeID: null }
| { typeID: string; edgeID: string | null };
interface NavStackTypeList {
prev: null;
typeGraph: TypeGraph;
type: null;
selectedEdgeID: null;
searchValue: string;
}

interface NavStackType {
prev: NavStack;
typeGraph: TypeGraph;
type: GraphQLNamedType;
selectedEdgeID: string | null;
searchValue: string;
}

export type NavStack = NavStackTypeList | NavStackType;

export default function Voyager(props: VoyagerProps) {
const initialDisplayOptions = useMemo(
Expand Down Expand Up @@ -81,10 +96,10 @@ export default function Voyager(props: VoyagerProps) {
setDisplayOptions(initialDisplayOptions);
}, [introspectionResult, initialDisplayOptions]);

const typeGraph = useMemo(() => {
const [navStack, setNavStack] = useState<NavStack | null>(null);
useEffect(() => {
if (introspectionResult.loading || introspectionResult.value == null) {
// FIXME: display introspectionResult.error
return null;
return; // FIXME: display introspectionResult.error
}

let introspectionSchema;
Expand All @@ -95,24 +110,22 @@ export default function Voyager(props: VoyagerProps) {
introspectionResult.value.errors != null ||
introspectionResult.value.data == null
) {
// FIXME: display errors
return null;
return; // FIXME: display errors
}
introspectionSchema = buildClientSchema(introspectionResult.value.data);
}

const schema = getSchema(introspectionSchema, displayOptions);
return getTypeGraph(schema, displayOptions);
}, [introspectionResult, displayOptions]);
const typeGraph = getTypeGraph(schema, displayOptions);

useEffect(() => {
setSelected({ typeID: null, edgeID: null });
}, [typeGraph]);

const [selected, setSelected] = useState<GraphSelection>({
typeID: null,
edgeID: null,
});
setNavStack(() => ({
prev: null,
typeGraph,
type: null,
selectedEdgeID: null,
searchValue: '',
}));
}, [introspectionResult, displayOptions]);

const {
allowToChangeSchema = false,
Expand All @@ -124,6 +137,70 @@ export default function Voyager(props: VoyagerProps) {

const viewportRef = useRef<GraphViewport>(null);

const handleNavigationBack = useCallback(() => {
setNavStack((old) => {
if (old?.prev == null) {
return old;
}
return old.prev;
});
}, []);

const handleSearch = useCallback((searchValue: string) => {
setNavStack((old) => {
if (old == null) {
return old;
}
return { ...old, searchValue };
});
}, []);

const handleSelectNode = useCallback((type: GraphQLNamedType | null) => {
setNavStack((old) => {
if (old == null) {
return old;
}
if (type == null) {
let first = old;
while (first.prev != null) {
first = first.prev;
}
return first;
}
return {
prev: old,
typeGraph: old.typeGraph,
type,
selectedEdgeID: null,
searchValue: '',
};
});
}, []);

const handleSelectEdge = useCallback(
(edgeID: string, fromType: GraphQLNamedType, _toType: GraphQLNamedType) => {
setNavStack((old) => {
if (old == null) {
return old;
}
if (fromType === old.type) {
// deselect if click again
return edgeID === old.selectedEdgeID
? { ...old, selectedEdgeID: null }
: { ...old, selectedEdgeID: edgeID };
}
return {
prev: old,
typeGraph: old.typeGraph,
type: fromType,
selectedEdgeID: edgeID,
searchValue: '',
};
});
},
[],
);

return (
<ThemeProvider theme={theme}>
<div className="graphql-voyager">
Expand Down Expand Up @@ -161,11 +238,12 @@ export default function Voyager(props: VoyagerProps) {
{allowToChangeSchema && renderChangeSchemaButton()}
{panelHeader}
<DocExplorer
typeGraph={typeGraph}
selectedTypeID={selected.typeID}
selectedEdgeID={selected.edgeID}
onFocusNode={(id) => viewportRef.current?.focusNode(id)}
onSelect={handleSelect}
navStack={navStack}
onNavigationBack={handleNavigationBack}
onSearch={handleSearch}
onFocusNode={(type) => viewportRef.current?.focusNode(type)}
onSelectNode={handleSelectNode}
onSelectEdge={handleSelectEdge}
/>
<PoweredBy />
</div>
Expand Down Expand Up @@ -209,31 +287,21 @@ export default function Voyager(props: VoyagerProps) {
{!hideSettings && (
<Settings
options={displayOptions}
typeGraph={typeGraph}
typeGraph={navStack?.typeGraph}
onChange={(options) =>
setDisplayOptions((oldOptions) => ({ ...oldOptions, ...options }))
}
/>
)}
<GraphViewport
typeGraph={typeGraph}
selectedTypeID={selected.typeID}
selectedEdgeID={selected.edgeID}
onSelect={handleSelect}
navStack={navStack}
onSelectNode={handleSelectNode}
onSelectEdge={handleSelectEdge}
ref={viewportRef}
/>
</Box>
);
}

function handleSelect(newSel: GraphSelection) {
setSelected((oldSel) => {
if (newSel.typeID === oldSel.typeID && newSel.edgeID === oldSel.edgeID) {
return { typeID: newSel.typeID, edgeID: null }; // deselect if click again
}
return newSel;
});
}
}

function PanelHeader(props: { children: ReactNode }) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/doc-explorer/Argument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import WrappedTypeName from './WrappedTypeName.tsx';

interface ArgumentProps {
arg: GraphQLArgument;
filter: string | null;
filter: string;
expanded: boolean;
onTypeLink: (type: GraphQLNamedType) => void;
}
Expand Down
Loading
Loading