From 94992baf04c889f4156e705b44151ce90763b4bb Mon Sep 17 00:00:00 2001 From: Ivan Goncharov Date: Mon, 7 Aug 2023 18:45:41 +0300 Subject: [PATCH] Unable 'strict' checks for TSC --- .eslintrc.yml | 4 +- package-lock.json | 21 ++ package.json | 1 + scripts/serve-directory.ts | 4 +- src/components/GraphViewport.tsx | 22 ++- src/components/IntrospectionModal.tsx | 6 +- src/components/Voyager.tsx | 43 ++-- src/components/doc-explorer/Argument.tsx | 2 +- src/components/doc-explorer/Description.tsx | 2 +- src/components/doc-explorer/DocExplorer.tsx | 185 +++++++++--------- src/components/doc-explorer/TypeDoc.tsx | 4 +- src/components/doc-explorer/TypeLink.tsx | 2 +- src/components/doc-explorer/TypeList.tsx | 2 +- .../doc-explorer/WrappedTypeName.tsx | 8 +- src/components/settings/RootSelector.tsx | 54 ++--- src/components/utils/Markdown.tsx | 2 +- src/graph/dot.ts | 8 +- src/graph/svg-renderer.ts | 37 ++-- src/graph/type-graph.ts | 2 +- src/graph/viewport.ts | 76 ++++--- src/introspection/introspection.ts | 28 ++- src/introspection/utils.ts | 99 +++++----- src/middleware/express.ts | 2 +- src/middleware/hapi.ts | 4 +- src/middleware/koa.ts | 4 +- src/utils/dom-helpers.ts | 5 +- src/utils/highlight.tsx | 5 +- src/utils/index.ts | 2 +- src/utils/stringify-type-wrappers.ts | 3 + src/utils/unreachable.ts | 3 + tsconfig.json | 7 +- worker/post.js | 6 +- 32 files changed, 368 insertions(+), 285 deletions(-) create mode 100644 src/utils/unreachable.ts diff --git a/.eslintrc.yml b/.eslintrc.yml index baaa2fba..9d6bf63f 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -54,9 +54,7 @@ overrides: 'react/jsx-key': off 'react/no-string-refs': off '@typescript-eslint/no-var-requires': off - - # FIXME: blocked by improper type checking should be fixed - # after we switch TSC in strict mode + '@typescript-eslint/no-non-null-assertion': off '@typescript-eslint/no-unnecessary-boolean-literal-compare': off '@typescript-eslint/no-explicit-any': off '@typescript-eslint/dot-notation': off diff --git a/package-lock.json b/package-lock.json index 89894431..d2af85b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/node": "18.15.5", "@types/react": "18.0.26", "@types/react-dom": "18.0.10", + "@types/webpack-node-externals": "^3.0.0", "@typescript-eslint/eslint-plugin": "5.30.7", "@typescript-eslint/parser": "5.30.7", "cspell": "6.2.3", @@ -3597,6 +3598,16 @@ "@types/node": "*" } }, + "node_modules/@types/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-Z3ELJiH0aZjxkoymT2nrGSmCF/CYjiqC0bpv4/DWy9h7e6gP4B2qmKZFHJFermeF0SYURbSw0puddQl9dMMV0w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "webpack": "^5" + } + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -18420,6 +18431,16 @@ "@types/node": "*" } }, + "@types/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-Z3ELJiH0aZjxkoymT2nrGSmCF/CYjiqC0bpv4/DWy9h7e6gP4B2qmKZFHJFermeF0SYURbSw0puddQl9dMMV0w==", + "dev": true, + "requires": { + "@types/node": "*", + "webpack": "^5" + } + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", diff --git a/package.json b/package.json index 1fcee6c7..b35971f8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@types/node": "18.15.5", "@types/react": "18.0.26", "@types/react-dom": "18.0.10", + "@types/webpack-node-externals": "^3.0.0", "@typescript-eslint/eslint-plugin": "5.30.7", "@typescript-eslint/parser": "5.30.7", "cspell": "6.2.3", diff --git a/scripts/serve-directory.ts b/scripts/serve-directory.ts index ae48c0ec..1d1d2ac9 100644 --- a/scripts/serve-directory.ts +++ b/scripts/serve-directory.ts @@ -26,7 +26,7 @@ function consoleError(msg: string) { http .createServer((request, response) => { - const url = new URL(request.url, 'file:'); + const url = new URL(request.url!, 'file:'); let filePath = url.pathname; if (filePath === '/') { filePath = '/index.html'; @@ -34,7 +34,7 @@ http filePath = path.join(options.directory, filePath); const extname = String(path.extname(filePath)).toLowerCase(); - const mimeTypes = { + const mimeTypes: { [ext: string]: string } = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', diff --git a/src/components/GraphViewport.tsx b/src/components/GraphViewport.tsx index c72613c6..f259b8ce 100644 --- a/src/components/GraphViewport.tsx +++ b/src/components/GraphViewport.tsx @@ -10,10 +10,10 @@ interface GraphViewportProps { typeGraph: TypeGraph | null; displayOptions: VoyagerDisplayOptions; - selectedTypeID: string; - selectedEdgeID: string; + selectedTypeID: string | null; + selectedEdgeID: string | null; - onSelectNode: (id: string) => void; + onSelectNode: (id: string | null) => void; onSelectEdge: (id: string) => void; } @@ -35,10 +35,13 @@ export default class GraphViewport extends Component< // Handle async graph rendering based on this example // https://gist.github.com/bvaughn/982ab689a41097237f6e9860db7ca8d6 - _currentTypeGraph = null; - _currentDisplayOptions = null; + _currentTypeGraph: TypeGraph | null = null; + _currentDisplayOptions: VoyagerDisplayOptions | null = null; - static getDerivedStateFromProps(props, state) { + static getDerivedStateFromProps( + props: GraphViewportProps, + state: GraphViewportState, + ): GraphViewportState | null { const { typeGraph, displayOptions } = props; if ( @@ -56,7 +59,10 @@ export default class GraphViewport extends Component< this._renderSvgAsync(typeGraph, displayOptions); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate( + prevProps: GraphViewportProps, + prevState: GraphViewportState, + ) { const { svgViewport } = this.state; if (svgViewport == null) { @@ -150,7 +156,7 @@ export default class GraphViewport extends Component< } } - focusNode(id) { + focusNode(id: string) { const { svgViewport } = this.state; if (svgViewport) { svgViewport.focusElement(id); diff --git a/src/components/IntrospectionModal.tsx b/src/components/IntrospectionModal.tsx index 255f9920..b55c51fd 100644 --- a/src/components/IntrospectionModal.tsx +++ b/src/components/IntrospectionModal.tsx @@ -33,8 +33,8 @@ interface IntrospectionModalProps { export function IntrospectionModal(props: IntrospectionModalProps) { const { open, presets, onChange, onClose } = props; - const presetNames = presets != null ? Object.keys(presets) : []; - const hasPresets = presetNames.length > 0; + const hasPresets = presets != null; + const presetNames = hasPresets ? Object.keys(presets) : []; const [submitted, setSubmitted] = useState({ inputType: hasPresets ? InputType.Presets : InputType.SDL, @@ -103,7 +103,7 @@ export function IntrospectionModal(props: IntrospectionModalProps) { function handleSubmit() { switch (inputType) { case InputType.Presets: - onChange(buildClientSchema(presets[activePreset].data)); + onChange(buildClientSchema(presets?.[activePreset].data)); break; case InputType.Introspection: // check for errors and check if valid diff --git a/src/components/Voyager.tsx b/src/components/Voyager.tsx index cca68f8a..414c869b 100644 --- a/src/components/Voyager.tsx +++ b/src/components/Voyager.tsx @@ -9,7 +9,6 @@ import { GraphQLSchema } from 'graphql/type'; import { buildClientSchema, IntrospectionQuery } from 'graphql/utilities'; import { Children, - type ReactElement, type ReactNode, useEffect, useMemo, @@ -52,7 +51,7 @@ export interface VoyagerProps { } export default function Voyager(props: VoyagerProps) { - const initialDisplayOptions: VoyagerDisplayOptions = useMemo( + const initialDisplayOptions = useMemo( () => ({ rootType: undefined, skipRelay: true, @@ -83,10 +82,19 @@ export default function Voyager(props: VoyagerProps) { return null; } - const introspectionSchema = - introspectionResult.value instanceof GraphQLSchema - ? introspectionResult.value - : buildClientSchema(introspectionResult.value.data); + let introspectionSchema; + if (introspectionResult.value instanceof GraphQLSchema) { + introspectionSchema = introspectionResult.value; + } else { + if ( + introspectionResult.value.errors != null || + introspectionResult.value.data == null + ) { + // FIXME: display errors + return null; + } + introspectionSchema = buildClientSchema(introspectionResult.value.data); + } const schema = getSchema( introspectionSchema, @@ -105,7 +113,13 @@ export default function Voyager(props: VoyagerProps) { setSelected({ typeID: null, edgeID: null }); }, [typeGraph]); - const [selected, setSelected] = useState({ typeID: null, edgeID: null }); + const [selected, setSelected] = useState<{ + typeID: string | null; + edgeID: string | null; + }>({ + typeID: null, + edgeID: null, + }); const { allowToChangeSchema = false, @@ -115,7 +129,7 @@ export default function Voyager(props: VoyagerProps) { hideVoyagerLogo = true, } = props; - const viewportRef = useRef(null); + const viewportRef = useRef(null); useEffect(() => viewportRef.current?.resize(), [hideDocs]); return ( @@ -143,7 +157,10 @@ export default function Voyager(props: VoyagerProps) { function renderPanel() { const children = Children.toArray(props.children); const panelHeader = children.find( - (child: ReactElement) => child.type === Voyager.PanelHeader, + (child) => + typeof child === 'object' && + 'type' in child && + child.type === Voyager.PanelHeader, ); return ( @@ -156,7 +173,7 @@ export default function Voyager(props: VoyagerProps) { typeGraph={typeGraph} selectedTypeID={selected.typeID} selectedEdgeID={selected.edgeID} - onFocusNode={(id) => viewportRef.current.focusNode(id)} + onFocusNode={(id) => viewportRef.current?.focusNode(id)} onSelectNode={handleSelectNode} onSelectEdge={handleSelectEdge} /> @@ -210,7 +227,7 @@ export default function Voyager(props: VoyagerProps) { ); } - function handleSelectNode(typeID: string) { + function handleSelectNode(typeID: string | null) { setSelected((oldSelected) => { if (typeID === oldSelected.typeID) { return oldSelected; @@ -219,9 +236,9 @@ export default function Voyager(props: VoyagerProps) { }); } - function handleSelectEdge(edgeID: string) { + function handleSelectEdge(edgeID: string | null) { setSelected((oldSelected) => { - if (edgeID === oldSelected.edgeID) { + if (edgeID === oldSelected.edgeID || edgeID == null) { // deselect if click again return { ...oldSelected, edgeID: null }; } else { diff --git a/src/components/doc-explorer/Argument.tsx b/src/components/doc-explorer/Argument.tsx index 9791217f..f30714a2 100644 --- a/src/components/doc-explorer/Argument.tsx +++ b/src/components/doc-explorer/Argument.tsx @@ -8,7 +8,7 @@ import WrappedTypeName from './WrappedTypeName'; interface ArgumentProps { arg: GraphQLArgument; - filter: string; + filter: string | null; expanded: boolean; onTypeLink: (type: GraphQLNamedType) => void; } diff --git a/src/components/doc-explorer/Description.tsx b/src/components/doc-explorer/Description.tsx index 9dc4f9e1..30ada327 100644 --- a/src/components/doc-explorer/Description.tsx +++ b/src/components/doc-explorer/Description.tsx @@ -3,7 +3,7 @@ import './Description.css'; import Markdown from '../utils/Markdown'; interface DescriptionProps { - text?: string; + text: string | undefined | null; className: string; } diff --git a/src/components/doc-explorer/DocExplorer.tsx b/src/components/doc-explorer/DocExplorer.tsx index a0a0c16f..efef85f7 100644 --- a/src/components/doc-explorer/DocExplorer.tsx +++ b/src/components/doc-explorer/DocExplorer.tsx @@ -13,13 +13,13 @@ import TypeInfoPopover from './TypeInfoPopover'; import TypeList from './TypeList'; interface DocExplorerProps { - typeGraph: TypeGraph; - selectedTypeID: string; - selectedEdgeID: string; + typeGraph: TypeGraph | null; + selectedTypeID: string | null; + selectedEdgeID: string | null; onFocusNode: (id: string) => void; - onSelectNode: (id: string) => void; - onSelectEdge: (id: string) => void; + onSelectNode: (id: string | null) => void; + onSelectEdge: (id: string | null) => void; } interface NavStackItem { @@ -30,10 +30,14 @@ interface NavStackItem { interface DocExplorerState { navStack: ReadonlyArray; - typeForInfoPopover: GraphQLNamedType; + typeForInfoPopover: GraphQLNamedType | null; } -const initialNav = { title: 'Type List', type: null, searchValue: null }; +const initialNav: NavStackItem = { + title: 'Type List', + type: null, + searchValue: null, +}; export default class DocExplorer extends Component< DocExplorerProps, @@ -47,13 +51,13 @@ export default class DocExplorer extends Component< static getDerivedStateFromProps( props: DocExplorerProps, state: DocExplorerState, - ) { + ): DocExplorerState | null { const { selectedTypeID, typeGraph } = props; const { navStack } = state; const lastNav = navStack[navStack.length - 1]; const type = - selectedTypeID != null + selectedTypeID != null && typeGraph != null ? assertCompositeType( typeGraph.nodes.get(extractTypeName(selectedTypeID)), ) @@ -76,9 +80,11 @@ export default class DocExplorer extends Component< } render() { - const { typeGraph } = this.props; + const { navStack } = this.state; + const previousNav = navStack.at(-2); + const currentNav = navStack.at(-1); - if (typeGraph == null) { + if (this.props.typeGraph == null || currentNav == null) { return (
Loading... @@ -86,32 +92,66 @@ export default class DocExplorer extends Component< ); } - const { navStack } = this.state; - const previousNav = navStack.at(-2); - const currentNav = navStack.at(-1); + const { typeGraph, selectedEdgeID, onFocusNode, onSelectEdge } = this.props; const name = currentNav.type ? currentNav.type.name : 'Schema'; + + const handleTypeLink = (type: GraphQLNamedType) => { + const { onFocusNode, onSelectNode } = this.props; + + if (isNode(type)) { + onFocusNode(typeObjToId(type)); + onSelectNode(typeObjToId(type)); + } else { + this.setState({ typeForInfoPopover: type }); + } + }; + + const handleFieldLink = (type: GraphQLNamedType, fieldID: string) => { + const { onFocusNode, onSelectNode, onSelectEdge } = this.props; + + onFocusNode(typeObjToId(type)); + onSelectNode(typeObjToId(type)); + // wait for docs panel to rerender with new edges + setTimeout(() => onSelectEdge(fieldID)); + }; + + const handleNavBackClick = () => { + const { onFocusNode, onSelectNode } = this.props; + const newNavStack = this.state.navStack.slice(0, -1); + const newCurrentNode = newNavStack[newNavStack.length - 1]; + + this.setState({ navStack: newNavStack, typeForInfoPopover: null }); + + if (newCurrentNode.type == null) { + return onSelectNode(null); + } + + onFocusNode(typeObjToId(newCurrentNode.type)); + onSelectNode(typeObjToId(newCurrentNode.type)); + }; + return (
- {this.renderNavigation(previousNav, currentNav)} + {renderNavigation(previousNav, currentNav)}
- {this.renderCurrentNav(currentNav)} + {renderCurrentNav(currentNav)} {currentNav.searchValue && ( )}
- {currentNav.type && ( + {this.state.typeForInfoPopover && ( this.setState({ typeForInfoPopover: type })} @@ -119,57 +159,57 @@ export default class DocExplorer extends Component< )}
); - } - renderCurrentNav(currentNav: NavStackItem) { - const { typeGraph, selectedEdgeID, onSelectEdge, onFocusNode } = this.props; + function renderCurrentNav(currentNav: NavStackItem) { + if (currentNav.type) { + return ( + + ); + } - if (currentNav.type) { return ( - onFocusNode(typeObjToId(type))} /> ); } - return ( - onFocusNode(typeObjToId(type))} - /> - ); - } + function renderNavigation( + previousNav: NavStackItem | undefined, + currentNav: NavStackItem, + ) { + const { title, type } = currentNav; + + if (previousNav && type) { + return ( +
+ + {previousNav.title} + + + {title} + onFocusNode(typeObjToId(type))} /> + +
+ ); + } - renderNavigation(previousNav: NavStackItem, currentNav: NavStackItem) { - const { onFocusNode } = this.props; - if (previousNav) { return (
- - {previousNav.title} - - - {currentNav.title} - onFocusNode(typeObjToId(currentNav.type))} - /> - + {title}
); } - - return ( -
- {currentNav.title} -
- ); } handleSearch = (value: string) => { @@ -178,39 +218,4 @@ export default class DocExplorer extends Component< navStack[navStack.length - 1] = { ...currentNav, searchValue: value }; this.setState({ navStack }); }; - - handleTypeLink = (type: GraphQLNamedType) => { - const { onFocusNode, onSelectNode } = this.props; - - if (isNode(type)) { - onFocusNode(typeObjToId(type)); - onSelectNode(typeObjToId(type)); - } else { - this.setState({ typeForInfoPopover: type }); - } - }; - - handleFieldLink = (type: GraphQLNamedType, fieldID: string) => { - const { onFocusNode, onSelectNode, onSelectEdge } = this.props; - - onFocusNode(typeObjToId(type)); - onSelectNode(typeObjToId(type)); - // wait for docs panel to rerender with new edges - setTimeout(() => onSelectEdge(fieldID)); - }; - - handleNavBackClick = () => { - const { onFocusNode, onSelectNode } = this.props; - const newNavStack = this.state.navStack.slice(0, -1); - const newCurrentNode = newNavStack[newNavStack.length - 1]; - - this.setState({ navStack: newNavStack, typeForInfoPopover: null }); - - if (newCurrentNode.type == null) { - return onSelectNode(null); - } - - onFocusNode(typeObjToId(newCurrentNode.type)); - onSelectNode(typeObjToId(newCurrentNode.type)); - }; } diff --git a/src/components/doc-explorer/TypeDoc.tsx b/src/components/doc-explorer/TypeDoc.tsx index b752e515..17ffe3ff 100644 --- a/src/components/doc-explorer/TypeDoc.tsx +++ b/src/components/doc-explorer/TypeDoc.tsx @@ -19,9 +19,9 @@ import WrappedTypeName from './WrappedTypeName'; interface TypeDocProps { selectedType: GraphQLNamedType; - selectedEdgeID: string; + selectedEdgeID: string | null; typeGraph: TypeGraph; - filter: string; + filter: string | null; onSelectEdge: (id: string) => void; onTypeLink: (type: GraphQLNamedType) => void; } diff --git a/src/components/doc-explorer/TypeLink.tsx b/src/components/doc-explorer/TypeLink.tsx index 1525fce4..4ea7ed51 100644 --- a/src/components/doc-explorer/TypeLink.tsx +++ b/src/components/doc-explorer/TypeLink.tsx @@ -12,7 +12,7 @@ import { highlightTerm } from '../../utils'; interface TypeLinkProps { type: GraphQLNamedType; onClick: (type: GraphQLNamedType) => void; - filter?: string; + filter?: string | null; } export default function TypeLink(props: TypeLinkProps) { diff --git a/src/components/doc-explorer/TypeList.tsx b/src/components/doc-explorer/TypeList.tsx index c479cdea..aafc1399 100644 --- a/src/components/doc-explorer/TypeList.tsx +++ b/src/components/doc-explorer/TypeList.tsx @@ -10,7 +10,7 @@ import TypeLink from './TypeLink'; interface TypeListProps { typeGraph: TypeGraph; - filter: string; + filter: string | null; onFocusType: (type: GraphQLNamedType) => void; onTypeLink: (type: GraphQLNamedType) => void; } diff --git a/src/components/doc-explorer/WrappedTypeName.tsx b/src/components/doc-explorer/WrappedTypeName.tsx index 8f416754..ecb57ae4 100644 --- a/src/components/doc-explorer/WrappedTypeName.tsx +++ b/src/components/doc-explorer/WrappedTypeName.tsx @@ -26,9 +26,11 @@ export default function WrappedTypeName(props: WrappedTypeNameProps) { return ( - {leftWrap} - - {rightWrap} {container.extensions.isRelayField && wrapRelayIcon()} + <> + {leftWrap} + + {rightWrap} {container.extensions.isRelayField && wrapRelayIcon()} + ); } diff --git a/src/components/settings/RootSelector.tsx b/src/components/settings/RootSelector.tsx index 9b3638d3..1059002d 100644 --- a/src/components/settings/RootSelector.tsx +++ b/src/components/settings/RootSelector.tsx @@ -1,6 +1,6 @@ import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; -import { GraphQLCompositeType } from 'graphql/type'; +import { GraphQLNamedType } from 'graphql/type'; import { isNode, TypeGraph } from '../../graph/'; @@ -12,44 +12,48 @@ interface RootSelectorProps { export default function RootSelector(props: RootSelectorProps) { const { typeGraph, onChange } = props; const { schema } = typeGraph; - const rootType = typeGraph.rootType.name; - const operationRootTypes: ReadonlyArray = [ - schema.getQueryType(), - schema.getMutationType(), - schema.getSubscriptionType(), - ].filter((type) => type != null); - const otherTypeNames = Object.values(schema.getTypeMap()) - .filter((type) => isNode(type) && !operationRootTypes.includes(type)) - .map((type) => type.name) - .sort(); + const types = Object.values(schema.getTypeMap()) + .filter((type) => isNode(type) && !isOperationRootType(type)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const subscriptionRoot = schema.getSubscriptionType(); + if (subscriptionRoot) { + types.unshift(subscriptionRoot); + } + + const mutationRoot = schema.getMutationType(); + if (mutationRoot) { + types.unshift(mutationRoot); + } + + const queryRoot = schema.getQueryType(); + if (queryRoot) { + types.unshift(queryRoot); + } return ( ); - function handleChange(event) { - const newRootType = event.target.value; - if (newRootType !== rootType) { - onChange(newRootType); - } + function isOperationRootType(type: GraphQLNamedType) { + return ( + type === schema.getQueryType() || + type === schema.getMutationType() || + type === schema.getSubscriptionType() + ); } } diff --git a/src/components/utils/Markdown.tsx b/src/components/utils/Markdown.tsx index 2572ba42..b9ebf7d9 100644 --- a/src/components/utils/Markdown.tsx +++ b/src/components/utils/Markdown.tsx @@ -4,7 +4,7 @@ const parser = new Parser(); const renderer = new HtmlRenderer({ safe: true }); interface MarkdownProps { - text: string; + text: string | null | undefined; className: string; } diff --git a/src/graph/dot.ts b/src/graph/dot.ts index aac4427e..3af8185e 100644 --- a/src/graph/dot.ts +++ b/src/graph/dot.ts @@ -19,6 +19,7 @@ import { typeObjToId, } from '../introspection/utils'; import { stringifyTypeWrappers } from '../utils/stringify-type-wrappers'; +import { unreachable } from '../utils/unreachable'; import { TypeGraph } from './type-graph'; export function getDot( @@ -181,19 +182,19 @@ export function getDot( } function forEachField( - stringify: (id: string, field: GraphQLField) => string, + stringify: (id: string, field: GraphQLField) => string | null, ): string { return mapFields(node, stringify).join('\n'); } function forEachPossibleTypes( - stringify: (id: string, type: GraphQLObjectType) => string, + stringify: (id: string, type: GraphQLObjectType) => string | null, ): string { return mapPossibleTypes(node, stringify).join('\n'); } function forEachDerivedTypes( - stringify: (id: string, type: GraphQLNamedType) => string, + stringify: (id: string, type: GraphQLNamedType) => string | null, ) { return mapDerivedTypes(schema, node, stringify).join('\n'); } @@ -229,4 +230,5 @@ function typeToKind(type: GraphQLNamedType): string { if (isInputObjectType(type)) { return 'INPUT_OBJECT'; } + unreachable(type); } diff --git a/src/graph/svg-renderer.ts b/src/graph/svg-renderer.ts index 456e2e50..44f6c690 100644 --- a/src/graph/svg-renderer.ts +++ b/src/graph/svg-renderer.ts @@ -17,7 +17,7 @@ interface SerializedError { stack?: string; } -type RenderRequestListener = (error: SerializedError, result?: string) => void; +type RenderRequestListener = (result: RenderResult) => void; interface RenderRequest { id: number; @@ -26,9 +26,9 @@ interface RenderRequest { interface RenderResponse { id: number; - error?: SerializedError; - result?: string; + result: RenderResult; } +type RenderResult = { error: SerializedError } | { value: string }; export class SVGRender { private _worker: Worker; @@ -38,9 +38,9 @@ export class SVGRender { this._worker = VizWorker; this._worker.addEventListener('message', (event) => { - const { id, error, result } = event.data as RenderResponse; + const { id, result } = event.data as RenderResponse; - this._listeners[id](error, result); + this._listeners[id](result); delete this._listeners[id]; }); } @@ -58,15 +58,16 @@ export class SVGRender { return new Promise((resolve, reject) => { const id = this._listeners.length; - this._listeners.push(function (error, result): void { - if (error) { + this._listeners.push(function (result): void { + if ('error' in result) { + const { error } = result; const e = new Error(error.message); if (error.fileName) (e as any).fileName = error.fileName; if (error.lineNumber) (e as any).lineNumber = error.lineNumber; if (error.stack) (e as any).stack = error.stack; return reject(e); } - resolve(result); + resolve(result.value); }); const renderRequest: RenderRequest = { id, src }; @@ -83,7 +84,7 @@ function preprocessVizSVG(svgString: string) { const svg = stringToSvg(svgString); for (const $a of svg.querySelectorAll('a')) { - const $g = $a.parentNode; + const $g = $a.parentNode!; const $docFrag = document.createDocumentFragment(); while ($a.firstChild) { @@ -101,13 +102,13 @@ function preprocessVizSVG(svgString: string) { $el.remove(); } - const edgesSources = {}; + const edgesSources = new Set(); for (const $edge of svg.querySelectorAll('.edge')) { const [from, to] = $edge.id.split(' => '); $edge.removeAttribute('id'); $edge.setAttribute('data-from', from); $edge.setAttribute('data-to', to); - edgesSources[from] = true; + edgesSources.add(from); } for (const $el of svg.querySelectorAll('[id*=\\:\\:]')) { @@ -119,7 +120,7 @@ function preprocessVizSVG(svgString: string) { const $newPath = $path.cloneNode() as HTMLElement; $newPath.classList.add('hover-path'); $newPath.removeAttribute('stroke-dasharray'); - $path.parentNode.appendChild($newPath); + $path.parentNode?.appendChild($newPath); } for (const $field of svg.querySelectorAll('.field')) { @@ -128,7 +129,7 @@ function preprocessVizSVG(svgString: string) { //Remove spaces used for text alignment texts[1].remove(); - if (edgesSources[$field.id]) $field.classList.add('edge-source'); + if (edgesSources.has($field.id)) $field.classList.add('edge-source'); for (let i = 2; i < texts.length; ++i) { const str = texts[i].innerHTML; @@ -146,27 +147,27 @@ function preprocessVizSVG(svgString: string) { $useIcon.setAttribute('height', `${height}px`); //FIXME: remove hardcoded offset - const y = parseInt($iconPlaceholder.getAttribute('y')) - 15; - $useIcon.setAttribute('x', $iconPlaceholder.getAttribute('x')); + const y = parseInt($iconPlaceholder.getAttribute('y')!) - 15; + $useIcon.setAttribute('x', $iconPlaceholder.getAttribute('x')!); $useIcon.setAttribute('y', y.toString()); $field.replaceChild($useIcon, $iconPlaceholder); continue; } texts[i].classList.add('field-type'); - if (edgesSources[$field.id] && !/[[\]!]/.test(str)) + if (edgesSources.has($field.id) && !/[[\]!]/.test(str)) texts[i].classList.add('type-link'); } } for (const $derivedType of svg.querySelectorAll('.derived-type')) { $derivedType.classList.add('edge-source'); - $derivedType.querySelector('text').classList.add('type-link'); + $derivedType.querySelector('text')?.classList.add('type-link'); } for (const $possibleType of svg.querySelectorAll('.possible-type')) { $possibleType.classList.add('edge-source'); - $possibleType.querySelector('text').classList.add('type-link'); + $possibleType.querySelector('text')?.classList.add('type-link'); } const serializer = new XMLSerializer(); diff --git a/src/graph/type-graph.ts b/src/graph/type-graph.ts index 8caaffff..c978175b 100644 --- a/src/graph/type-graph.ts +++ b/src/graph/type-graph.ts @@ -30,7 +30,7 @@ export function getTypeGraph( hideRoot?: boolean, ): TypeGraph { const rootType = assertCompositeType( - schema.getType(rootName) ?? schema.getQueryType(), + schema.getType(rootName ?? schema.getQueryType()!.name), ); const nodeMap = new Map(); diff --git a/src/graph/viewport.ts b/src/graph/viewport.ts index 9781ad7b..8ee363c7 100644 --- a/src/graph/viewport.ts +++ b/src/graph/viewport.ts @@ -19,20 +19,24 @@ interface Instance { } export class Viewport { - onSelectNode: (id: string) => void; + onSelectNode: (id: string | null) => void; onSelectEdge: (id: string) => void; - $svg: SVGElement; + $svg: SVGSVGElement; + // @ts-expect-error FIXME zoomer: Instance; + // @ts-expect-error FIXME offsetLeft: number; + // @ts-expect-error FIXME offsetTop: number; + // @ts-expect-error FIXME maxZoom: number; resizeObserver: ResizeObserver; constructor( svgString: string, public container: HTMLElement, - onSelectNode: (id: string) => void, + onSelectNode: (id: string | null) => void, onSelectEdge: (id: string) => void, ) { this.onSelectNode = onSelectNode; @@ -88,15 +92,15 @@ export class Viewport { this.$svg.removeEventListener('mousemove', moveHandler); if (dragged) return; - const target = event.target as Element; + const target = event.target as SVGElement; if (isLink(target)) { - const typeId = typeNameToId(target.textContent); + const typeId = typeNameToId(target.textContent!); this.focusElement(typeId); } else if (isNode(target)) { - const $node = getParent(target, 'node'); + const $node = getParent(target, 'node')!; this.onSelectNode($node.id); } else if (isEdge(target)) { - const $edge = getParent(target, 'edge'); + const $edge = getParent(target, 'edge')!; this.onSelectEdge(edgeSource($edge).id); } else if (!isControl(target)) { this.onSelectNode(null); @@ -105,8 +109,8 @@ export class Viewport { } bindHover() { - let $prevHovered = null; - let $prevHoveredEdge = null; + let $prevHovered: SVGElement | null = null; + let $prevHoveredEdge: SVGElement | null = null; function clearSelection() { if ($prevHovered) $prevHovered.classList.remove('hovered'); @@ -114,9 +118,9 @@ export class Viewport { } this.$svg.addEventListener('mousemove', (event) => { - const target = event.target as Element; + const target = event.target as SVGElement; if (isEdgeSource(target)) { - const $sourceGroup = getParent(target, 'edge-source'); + const $sourceGroup = getParent(target, 'edge-source')!; if ($sourceGroup.classList.contains('hovered')) return; clearSelection(); $sourceGroup.classList.add('hovered'); @@ -130,7 +134,7 @@ export class Viewport { }); } - selectNodeById(id: string) { + selectNodeById(id: string | null) { this.removeClass('.node.selected', 'selected'); this.removeClass('.highlighted', 'highlighted'); this.removeClass('.selected-reachable', 'selected-reachable'); @@ -141,11 +145,12 @@ export class Viewport { } this.$svg.classList.add('selection-active'); - const $selected = document.getElementById(id); + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/4689#issuecomment-690503791 + const $selected = document.getElementById(id) as SVGElement; this.selectNode($selected); } - selectNode(node: Element) { + selectNode(node: SVGElement) { node.classList.add('selected'); for (const $edge of edgesFromNode(node)) { @@ -155,11 +160,11 @@ export class Viewport { for (const $edge of edgesTo(node.id)) { $edge.classList.add('highlighted'); - edgeSource($edge).parentElement.classList.add('selected-reachable'); + edgeSource($edge).parentElement!.classList.add('selected-reachable'); } } - selectEdgeById(id: string) { + selectEdgeById(id: string | null) { this.removeClass('.edge.selected', 'selected'); this.removeClass('.edge-source.selected', 'selected'); this.removeClass('.field.selected', 'selected'); @@ -181,7 +186,7 @@ export class Viewport { } focusElement(id: string) { - const bbBox = document.getElementById(id).getBoundingClientRect(); + const bbBox = document.getElementById(id)!.getBoundingClientRect(); const currentPan = this.zoomer.getPan(); const viewPortSizes = (this.zoomer as any).getSizes(); @@ -203,7 +208,7 @@ export class Viewport { this.animatePanAndZoom(newX, newY, newZoom); } - animatePanAndZoom(x, y, zoomEnd) { + animatePanAndZoom(x: number, y: number, zoomEnd: number) { const pan = this.zoomer.getPan(); const panEnd = { x, y }; animate(pan, panEnd, (props) => { @@ -227,48 +232,51 @@ export class Viewport { } } -function getParent(elem: Element, className: string): Element | null { +function getParent(elem: SVGElement, className: string): SVGElement | null { while (elem && elem.tagName !== 'svg') { if (elem.classList.contains(className)) return elem; - elem = elem.parentNode as Element; + elem = elem.parentNode as SVGElement; } return null; } -function isNode(elem: Element): boolean { +function isNode(elem: SVGElement): boolean { return getParent(elem, 'node') != null; } -function isEdge(elem: Element): boolean { +function isEdge(elem: SVGElement): boolean { return getParent(elem, 'edge') != null; } -function isLink(elem: Element): boolean { +function isLink(elem: SVGElement): boolean { return elem.classList.contains('type-link'); } -function isEdgeSource(elem: Element): boolean { +function isEdgeSource(elem: SVGElement): boolean { return getParent(elem, 'edge-source') != null; } -function isControl(elem: Element) { +function isControl(elem: SVGElement) { if (!(elem instanceof SVGElement)) return false; return elem.className.baseVal.startsWith('svg-pan-zoom'); } -function edgeSource(edge: Element) { +function edgeSource(edge: SVGElement): SVGElement { + // @ts-expect-error FIXME return document.getElementById(edge['dataset']['from']); } -function edgeTarget(edge: Element) { +function edgeTarget(edge: SVGElement): SVGElement { + // @ts-expect-error FIXME return document.getElementById(edge['dataset']['to']); } -function edgeFrom(id: string) { +function edgeFrom(id: string): SVGElement { + // @ts-expect-error FIXME return document.querySelector(`.edge[data-from='${id}']`); } -function edgesFromNode($node) { +function edgesFromNode($node: SVGElement) { const edges = []; for (const $source of $node.querySelectorAll('.edge-source')) { const $edge = edgeFrom($source.id); @@ -277,11 +285,15 @@ function edgesFromNode($node) { return edges; } -function edgesTo(id: string) { +function edgesTo(id: string): NodeListOf { return document.querySelectorAll(`.edge[data-to='${id}']`); } -function animate(startObj, endObj, render) { +function animate( + startObj: OBJ, + endObj: OBJ, + render: (obj: OBJ) => void, +) { const defaultDuration = 350; const fps60 = 1000 / 60; const totalFrames = defaultDuration / fps60; @@ -307,7 +319,7 @@ function animate(startObj, endObj, render) { return [key, start + t * (end - start)]; }), - ); + ) as OBJ; render(frame); diff --git a/src/introspection/introspection.ts b/src/introspection/introspection.ts index 70068442..248452b6 100644 --- a/src/introspection/introspection.ts +++ b/src/introspection/introspection.ts @@ -33,6 +33,7 @@ import { } from 'graphql'; import { collectDirectlyReferencedTypes } from '../utils/collect-referenced-types'; +import { unreachable } from '../utils/unreachable'; declare module 'graphql' { interface GraphQLFieldExtensions<_TSource, _TContext, _TArgs> { @@ -53,9 +54,15 @@ declare module 'graphql' { function removeRelayTypes(schema: GraphQLSchema) { const nodeType = getNodeType(); const pageInfoType = getPageInfoType(); - const relayTypes = new Set([nodeType, pageInfoType]); + const relayTypes = new Set(); const relayTypeToNodeMap = new Map(); + if (nodeType != null) { + relayTypes.add(nodeType); + } + if (pageInfoType != null) { + relayTypes.add(pageInfoType); + } for (const type of Object.values(schema.getTypeMap())) { if (isInterfaceType(type) || isObjectType(type)) { for (const field of Object.values(type.getFields())) { @@ -134,7 +141,9 @@ function removeRelayTypes(schema: GraphQLSchema) { return type; } - function changeFields(type: GraphQLObjectType | GraphQLInterfaceType) { + function changeFields( + type: GraphQLObjectType | GraphQLInterfaceType, + ): GraphQLFieldConfigMap { return mapValues(type.toConfig().fields, (field, fieldName) => { if (type === schema.getQueryType()) { switch (fieldName) { @@ -162,7 +171,8 @@ function removeRelayTypes(schema: GraphQLSchema) { type: isNonNullType(field.type) ? new GraphQLNonNull(relayNode) : relayNode, - args: mapValues(field.args, (arg, argName) => + // FIXME: field from toConfig always has args + args: mapValues(field.args ?? {}, (arg, argName) => isRelayArgumentName(argName) ? null : arg, ), extensions: { @@ -276,7 +286,9 @@ export function getSchema( // FIXME: Contribute to graphql-js export function transformSchema( schema: GraphQLSchema, - transformType: ReadonlyArray<(type: GraphQLNamedType) => GraphQLNamedType>, + transformType: ReadonlyArray< + (type: GraphQLNamedType) => GraphQLNamedType | null + >, transformDirective: ReadonlyArray< (directive: GraphQLDirective) => GraphQLDirective > = [], @@ -383,10 +395,11 @@ export function transformSchema( let newType = oldType; for (const fn of transformType) { - newType = fn(newType); - if (newType === null) { + const resultType = fn(newType); + if (resultType === null) { return null; } + newType = resultType; } if (isScalarType(newType)) { @@ -426,12 +439,13 @@ export function transformSchema( fields: () => transformInputFields(config.fields), }); } + unreachable(newType); } } function mapValues( obj: { [key: string]: T }, - mapper: (value: T, key: string) => R, + mapper: (value: T, key: string) => R | null, ): { [key: string]: R } { return Object.fromEntries( Object.entries(obj) diff --git a/src/introspection/utils.ts b/src/introspection/utils.ts index 2a25a0e5..6497f3ea 100644 --- a/src/introspection/utils.ts +++ b/src/introspection/utils.ts @@ -9,16 +9,12 @@ import { isUnionType, } from 'graphql/type'; -export function buildId(...parts) { - return parts.join('::'); -} - export function typeObjToId(type: GraphQLNamedType) { return typeNameToId(type.name); } export function typeNameToId(name: string) { - return buildId('TYPE', name); + return `TYPE::${name}`; } export function extractTypeName(typeID: string) { @@ -26,75 +22,72 @@ export function extractTypeName(typeID: string) { return type; } -export function extractTypeId(typeID: string) { - const [, name] = extractTypeName(typeID); - return typeNameToId(name); -} - export function mapFields( type: GraphQLNamedType, - fn: (id: string, field: GraphQLField) => R, + fn: (id: string, field: GraphQLField) => R | null, ): Array { - if (!isInterfaceType(type) && !isObjectType(type)) { - return []; + const array = []; + if (isInterfaceType(type) || isObjectType(type)) { + for (const field of Object.values(type.getFields())) { + const id = `FIELD::${type.name}::${field.name}`; + const result = fn(id, field); + if (result != null) { + array.push(result); + } + } } - - return Object.values(type.getFields()) - .map((field) => { - const fieldID = `FIELD::${type.name}::${field.name}`; - return fn(fieldID, field); - }) - .filter((item) => item != null); + return array; } export function mapPossibleTypes( type: GraphQLNamedType, - fn: (id: string, type: GraphQLObjectType) => R, + fn: (id: string, type: GraphQLObjectType) => R | null, ): Array { - if (!isUnionType(type)) { - return []; - } - - return type - .getTypes() - .map((possibleType) => { + const array = []; + if (isUnionType(type)) { + for (const possibleType of type.getTypes()) { const id = `POSSIBLE_TYPE::${type.name}::${possibleType.name}`; - return fn(id, possibleType); - }) - .filter((item) => item != null); + const result = fn(id, possibleType); + if (result != null) { + array.push(result); + } + } + } + return array; } export function mapDerivedTypes( schema: GraphQLSchema, type: GraphQLNamedType, - fn: (id: string, type: GraphQLObjectType | GraphQLInterfaceType) => R, + fn: (id: string, type: GraphQLObjectType | GraphQLInterfaceType) => R | null, ): Array { - if (!isInterfaceType(type)) { - return []; + const array = []; + if (isInterfaceType(type)) { + const { interfaces, objects } = schema.getImplementations(type); + for (const derivedType of [...interfaces, ...objects]) { + const id = `DERIVED_TYPE::${type.name}::${derivedType.name}`; + const result = fn(id, derivedType); + if (result != null) { + array.push(result); + } + } } - - const { interfaces, objects } = schema.getImplementations(type); - return [...interfaces, ...objects] - .map((possibleType) => { - const id = `POSSIBLE_TYPE::${type.name}::${possibleType.name}`; - return fn(id, possibleType); - }) - .filter((item) => item != null); + return array; } export function mapInterfaces( type: GraphQLNamedType, - fn: (id: string, type: GraphQLInterfaceType) => R, + fn: (id: string, type: GraphQLInterfaceType) => R | null, ): Array { - if (!isInterfaceType(type) && !isObjectType(type)) { - return []; - } - - return type - .getInterfaces() - .map((baseType) => { + const array = []; + if (isInterfaceType(type) || isObjectType(type)) { + for (const baseType of type.getInterfaces()) { const id = `INTERFACE::${type.name}::${baseType.name}`; - return fn(id, baseType); - }) - .filter((item) => item != null); + const result = fn(id, baseType); + if (result != null) { + array.push(result); + } + } + } + return array; } diff --git a/src/middleware/express.ts b/src/middleware/express.ts index 6df749e9..4efe907d 100644 --- a/src/middleware/express.ts +++ b/src/middleware/express.ts @@ -1,7 +1,7 @@ import renderVoyagerPage, { MiddlewareOptions } from './render-voyager-page'; export default function expressMiddleware(options: MiddlewareOptions) { - return (_req, res) => { + return (_req: any, res: any) => { res.setHeader('Content-Type', 'text/html'); res.write(renderVoyagerPage(options)); res.end(); diff --git a/src/middleware/hapi.ts b/src/middleware/hapi.ts index 3f9a97c3..eb5ae7f9 100644 --- a/src/middleware/hapi.ts +++ b/src/middleware/hapi.ts @@ -4,7 +4,7 @@ const pkg = require('../package.json'); const hapi = { pkg, - register(server, options: any) { + register(server: any, options: any) { if (arguments.length !== 2) { throw new Error( `Voyager middleware expects exactly 3 arguments, got ${arguments.length}`, @@ -17,7 +17,7 @@ const hapi = { method: 'GET', path, config, - handler: (_request, h) => + handler: (_request: any, h: any) => h.response(renderVoyagerPage(middlewareOptions as MiddlewareOptions)), }); }, diff --git a/src/middleware/koa.ts b/src/middleware/koa.ts index cc154fca..d075173b 100644 --- a/src/middleware/koa.ts +++ b/src/middleware/koa.ts @@ -2,12 +2,12 @@ import renderVoyagerPage, { MiddlewareOptions } from './render-voyager-page'; export default function koaMiddleware( options: MiddlewareOptions, -): (ctx, next) => Promise { +): (ctx: any, next: any) => Promise { return async function voyager(ctx, next) { try { ctx.body = renderVoyagerPage(options); await next(); - } catch (err) { + } catch (err: any) { ctx.body = { message: err.message }; ctx.status = err.status || 500; } diff --git a/src/utils/dom-helpers.ts b/src/utils/dom-helpers.ts index a5021bd8..c3af0f38 100644 --- a/src/utils/dom-helpers.ts +++ b/src/utils/dom-helpers.ts @@ -1,4 +1,5 @@ -export function stringToSvg(svgString: string): SVGElement { +export function stringToSvg(svgString: string): SVGSVGElement { const svgDoc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); - return document.importNode(svgDoc.documentElement, true) as any as SVGElement; + // @ts-expect-error not sure how to properly type it + return document.importNode(svgDoc.documentElement, true); } diff --git a/src/utils/highlight.tsx b/src/utils/highlight.tsx index cd5ce258..dcd8ab81 100644 --- a/src/utils/highlight.tsx +++ b/src/utils/highlight.tsx @@ -1,6 +1,9 @@ import { Fragment } from 'react'; -export function highlightTerm(content: string, term: string) { +export function highlightTerm( + content: string, + term: string | null | undefined, +) { if (!term) { return content; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 9b9d7e5d..2d6db1ea 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,7 +1,7 @@ export * from './dom-helpers'; export * from './highlight'; -export function isMatch(sourceText: string, searchValue: string) { +export function isMatch(sourceText: string, searchValue: string | null) { if (!searchValue) { return true; } diff --git a/src/utils/stringify-type-wrappers.ts b/src/utils/stringify-type-wrappers.ts index a36c02a7..f18f920c 100644 --- a/src/utils/stringify-type-wrappers.ts +++ b/src/utils/stringify-type-wrappers.ts @@ -5,6 +5,8 @@ import { isNonNullType, } from 'graphql/type'; +import { unreachable } from './unreachable'; + export function stringifyTypeWrappers(type: GraphQLType): [string, string] { if (isNamedType(type)) { return ['', '']; @@ -17,4 +19,5 @@ export function stringifyTypeWrappers(type: GraphQLType): [string, string] { if (isListType(type)) { return ['[' + left, right + ']']; } + unreachable(type); } diff --git a/src/utils/unreachable.ts b/src/utils/unreachable.ts new file mode 100644 index 00000000..627d7ac3 --- /dev/null +++ b/src/utils/unreachable.ts @@ -0,0 +1,3 @@ +export function unreachable(_: never): never { + throw Error('graphql-voyager: Unreachable code triggered!'); +} diff --git a/tsconfig.json b/tsconfig.json index a46cf1a5..e4613c84 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,11 @@ { "compilerOptions": { - "experimentalDecorators": true, - "emitDecoratorMetadata": true, "module": "es2015", "moduleResolution": "node", "target": "es2021", - "noImplicitAny": false, - "noUnusedLocals": true, - "noUnusedParameters": true, "allowSyntheticDefaultImports": true, + "strict": true, + // "strictNullChecks": false, "sourceMap": true, "noEmit": true, "pretty": true, diff --git a/worker/post.js b/worker/post.js index df70fa8d..dea84566 100644 --- a/worker/post.js +++ b/worker/post.js @@ -3,7 +3,7 @@ function onmessageCallBack(event) { const { id, src } = event.data; try { - const result = Module['vizRenderFromString'](src); + const value = Module['vizRenderFromString'](src); const errorMessageString = Module['vizLastErrorMessage'](); @@ -11,7 +11,7 @@ function onmessageCallBack(event) { throw new Error(errorMessageString); } - postMessage({ id, result }); + postMessage({ id, result: { value } }); } catch (e) { const error = e instanceof Error @@ -23,7 +23,7 @@ function onmessageCallBack(event) { } : { message: e.toString() }; - postMessage({ id, error }); + postMessage({ id, result: { error } }); } }