GraphQL Docs Explorer (#1616)

* Implement a GraphQLExplorer for visually browsing schemas

* Fix some type stuff and theme

* Adjust GraphQL explorer colors for fields to match Codemirror

* Explorer state not modified when showing docs from dropdown

* A few more small tweaks and fixes
This commit is contained in:
Gregory Schier authored and GitHub committed 2019-07-31 11:05:43 -04:00
1 parent 465f7247f6
commit bb5eaaa871
16 files changed
+690 -101

No files matched your search

+3 -3
View File
@@ -8291,9 +8291,9 @@
}
},
"flow-bin": {
"version": "0.96.0",
"resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.96.0.tgz",
"integrity": "sha512-OSxERs0EdhVxEVCst/HmlT/RcnXsQQIRqcfK9J9wC8/93JQj+xQz4RtlsmYe1PSRYaozuDLyPS5pIA81Zwzaww==",
"version": "0.104.0",
"resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.104.0.tgz",
"integrity": "sha512-EZXRRmf7m7ET5Lcnwm/I/T8G3d427Bq34vmO3qIlRcPIYloGuVoqRCwjaeezLRDntHkdciagAKbhJ+NTbDjnkw==",
"dev": true
},
"flush-write-stream": {
+1 -1
View File
@@ -32,7 +32,7 @@
"babel-eslint": "^8.2.3",
"babel-jest": "^24.7.1",
"babel-loader": "^8.0.5",
"flow-bin": "^0.96.0",
"flow-bin": "^0.104.0",
"husky": "^1.0.0-rc.9",
"jest": "^24.7.1",
"lerna": "^3.14.1",
+2 -3
View File
@@ -1,13 +1,12 @@
[ignore]
.*/node_modules/.*
.*/__fixtures__/.*
!<PROJECT_ROOT>/node_modules/graphql
!<PROJECT_ROOT>/node_modules/iterall
[include]
../../plugins
../../packages
[libs]
flow-typed/
[options]
esproposal.decorators=ignore
+1
View File
@@ -11,6 +11,7 @@
<body>
<div id="root"></div>
<div id="dropdowns-container" style="z-index:1000000;position:relative"></div>
<div id="graphql-explorer-container"></div>
<script src="bundle.js" type="application/javascript"></script>
</body>
</html>
@@ -585,12 +585,20 @@ class CodeEditor extends React.Component {
// Strip of charset if there is one
const cm = this.codeMirror;
Object.keys(options).map(key => {
// Don't set the option if it hasn't changed
if (deepEqual(options[key], cm.options[key])) {
return;
let shouldSetOption = false;
if (key === 'jump' || key === 'info' || key === 'lint' || key === 'hintOptions') {
// Use stringify here because these could be infinitely recursive due to GraphQL
// schemas
shouldSetOption = JSON.stringify(options[key]) !== JSON.stringify(cm.options[key]);
} else if (!deepEqual(options[key], cm.options[key])) {
// Don't set the option if it hasn't changed
shouldSetOption = true;
}
cm.setOption(key, options[key]);
if (shouldSetOption) {
cm.setOption(key, options[key]);
}
});
}
@@ -1,9 +1,11 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import autobind from 'autobind-decorator';
import { markdownToHTML } from '../../../../common/markdown-to-html';
import { parse, print, typeFromAST, type Document as DocumentAST } from 'graphql';
import type { GraphQLArgument, GraphQLField, GraphQLSchema, GraphQLType } from 'graphql';
import { parse, print, typeFromAST } from 'graphql';
import { introspectionQuery } from 'graphql/utilities/introspectionQuery';
import { buildClientSchema } from 'graphql/utilities/buildClientSchema';
import type { CodeMirror, TextMarker } from 'codemirror';
@@ -25,6 +27,12 @@ import { newBodyRaw } from '../../../../models/request';
import ResponseDebugModal from '../../modals/response-debug-modal';
import Tooltip from '../../tooltip';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../../base/dropdown';
import GraphqlExplorer from '../../graph-ql-explorer/graph-ql-explorer';
const explorerContainer = document.querySelector('#graphql-explorer-container');
if (!explorerContainer) {
throw new Error('Failed to find #graphql-explorer-container');
}
type GraphQLBody = {
query: string,
@@ -49,7 +57,7 @@ type Props = {
type State = {
body: GraphQLBody,
schema: Object | null,
schema: GraphQLSchema | null,
schemaFetchError: {
message: string,
response: ResponsePatch | null,
@@ -59,12 +67,18 @@ type State = {
hideSchemaFetchErrors: boolean,
variablesSyntaxError: string,
automaticFetch: boolean,
explorerVisible: boolean,
activeReference: null | {
type: GraphQLType | null,
argument: GraphQLArgument | null,
field: GraphQLField<any, any> | null,
},
};
@autobind
class GraphQLEditor extends React.PureComponent<Props, State> {
_disabledOperationMarkers: TextMarker[];
_documentAST: null | DocumentAST;
_documentAST: null | Object;
_isMounted: boolean;
_queryEditor: null | CodeMirror;
_schemaFetchTimeout: TimeoutID;
@@ -92,6 +106,8 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
schemaIsFetching: false,
hideSchemaFetchErrors: false,
variablesSyntaxError: '',
activeReference: null,
explorerVisible: false,
automaticFetch,
};
}
@@ -142,6 +158,15 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
return operationName;
}
_handleCloseExplorer() {
this.setState({ explorerVisible: false });
}
_handleClickReference(reference: Object, e: MouseEvent) {
e.preventDefault();
this.setState({ explorerVisible: true, activeReference: reference });
}
_handleQueryFocus() {
this._handleQueryUserActivity();
}
@@ -314,6 +339,12 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
return variableToType;
}
_handleShowDocumentation() {
this.setState({
explorerVisible: true,
});
}
async _handleRefreshSchema(): Promise<void> {
await this._fetchAndSetSchema(this.props.request);
}
@@ -474,6 +505,11 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
return message;
}
static renderMarkdown(text: string) {
const html = markdownToHTML(text);
return `<div class="markdown-preview__content">${html}</div>`;
}
render() {
const {
content,
@@ -492,6 +528,8 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
variablesSyntaxError,
schemaIsFetching,
automaticFetch,
activeReference,
explorerVisible,
} = this.state;
const { query, variables: variablesObject } = GraphQLEditor._stringToGraphQL(content);
@@ -500,6 +538,17 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
const variableTypes = this._buildVariableTypes(schema);
// Create portal for GraphQL Explorer
const graphQLExplorerPortal = ReactDOM.createPortal(
<GraphqlExplorer
schema={schema}
visible={explorerVisible}
reference={activeReference}
handleClose={this._handleCloseExplorer}
/>,
explorerContainer,
);
return (
<div className="graphql-editor">
<Dropdown right className="graphql-editor__schema-dropdown margin-bottom-xs">
@@ -507,6 +556,9 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
schema <i className="fa fa-wrench" />
</DropdownButton>
<DropdownDivider>GraphQL Schema</DropdownDivider>
<DropdownItem onClick={this._handleShowDocumentation} disabled={!schema}>
<i className="fa fa-file-code-o" /> Show Documentation
</DropdownItem>
<DropdownItem onClick={this._handleRefreshSchema} stayOpenAfterClick>
<i className={'fa fa-refresh ' + (schemaIsFetching ? 'fa-spin' : '')} /> Refresh Schema
</DropdownItem>
@@ -532,16 +584,13 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
}}
infoOptions={{
schema: schema || null,
renderDescription: text => {
const html = markdownToHTML(text);
return `<div class="markdown-preview__content">${html}</div>`;
},
// onClick: reference => console.log('CLICK', reference)
renderDescription: GraphQLEditor.renderMarkdown,
onClick: this._handleClickReference,
}}
jumpOptions={{
schema: schema || null,
onClick: this._handleClickReference,
}}
// jumpOptions={{
// schema: schema || null,
// onClick: reference => console.log('JUMP', reference)
// }}
lintOptions={schema ? { schema } : null}
fontSize={settings.editorFontSize}
indentSize={settings.editorIndentSize}
@@ -619,6 +668,8 @@ class GraphQLEditor extends React.PureComponent<Props, State> {
Prettify GraphQL
</button>
</div>
{graphQLExplorerPortal}
</div>
);
}
@@ -0,0 +1,30 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import type { GraphQLField } from 'graphql';
type Props = {
onNavigate: (type: Object) => void,
field: GraphQLField<any, any>,
};
@autobind
class GraphQLExplorerFieldLink extends React.PureComponent<Props> {
_handleClick(e: MouseEvent) {
e.preventDefault();
const { onNavigate, field } = this.props;
onNavigate(field);
}
render() {
const { field } = this.props;
return (
<a href="#" onClick={this._handleClick} className="success">
{field.name}
</a>
);
}
}
export default GraphQLExplorerFieldLink;
@@ -0,0 +1,62 @@
// @flow
import * as React from 'react';
import type { GraphQLField, GraphQLType } from 'graphql';
import GraphQLExplorerTypeLink from './graph-ql-explorer-type-link';
import MarkdownPreview from '../markdown-preview';
type Props = {
onNavigateType: (type: GraphQLType) => void,
field: GraphQLField<any, any>,
};
class GraphQLExplorerField extends React.PureComponent<Props> {
renderDescription() {
const { field } = this.props;
return <MarkdownPreview markdown={field.description || '*no description*'} />;
}
renderType() {
const { field, onNavigateType } = this.props;
return (
<React.Fragment>
<h2 className="graphql-explorer__subheading">Type</h2>
<GraphQLExplorerTypeLink type={field.type} onNavigate={onNavigateType} />
</React.Fragment>
);
}
renderArgumentsMaybe() {
const { field, onNavigateType } = this.props;
if (!field.args || field.args.length === 0) {
return null;
}
return (
<React.Fragment>
<h2 className="graphql-explorer__subheading">Arguments</h2>
<ul className="graphql-explorer__defs">
{field.args.map(a => (
<li key={a.name}>
<span className="info">{a.name}</span>:{' '}
<GraphQLExplorerTypeLink onNavigate={onNavigateType} type={a.type} />
{a.description && <MarkdownPreview markdown={a.description} />}
</li>
))}
</ul>
</React.Fragment>
);
}
render() {
return (
<div className="graphql-explorer__field">
{this.renderDescription()}
{this.renderType()}
{this.renderArgumentsMaybe()}
</div>
);
}
}
export default GraphQLExplorerField;
@@ -0,0 +1,60 @@
// @flow
import * as React from 'react';
import type { GraphQLSchema, GraphQLType } from 'graphql';
import GraphQLExplorerTypeLink from './graph-ql-explorer-type-link';
type Props = {
onNavigateType: (type: GraphQLType) => void,
schema: GraphQLSchema,
};
class GraphQLExplorerSchema extends React.PureComponent<Props> {
renderQueryType() {
const { schema, onNavigateType } = this.props;
const type = schema.getQueryType();
if (!type) {
return null;
}
return (
<React.Fragment>
<span className="success">query</span>:{' '}
<GraphQLExplorerTypeLink onNavigate={onNavigateType} type={type} />
</React.Fragment>
);
}
renderMutationType() {
const { schema, onNavigateType } = this.props;
const type = schema.getMutationType();
if (!type) {
return null;
}
return (
<React.Fragment>
<span className="success">mutation</span>:{' '}
<GraphQLExplorerTypeLink onNavigate={onNavigateType} type={type} />
</React.Fragment>
);
}
render() {
return (
<div className="graphql-explorer__schema">
<p>A GraphQL schema provides a root type for each kind of operation.</p>
<h2 className="graphql-explorer__subheading">Root Types</h2>
<ul className="graphql-explorer__defs">
<li>{this.renderQueryType()}</li>
<li>{this.renderMutationType()}</li>
</ul>
</div>
);
}
}
export default GraphQLExplorerSchema;
@@ -0,0 +1,48 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import { GraphQLList, GraphQLNonNull } from 'graphql';
import type { GraphQLType } from 'graphql';
type Props = {
onNavigate: (type: Object) => void,
type: GraphQLType,
};
@autobind
class GraphQLExplorerTypeLink extends React.PureComponent<Props> {
_handleClick(e: MouseEvent) {
e.preventDefault();
const { onNavigate, type } = this.props;
onNavigate(type);
}
render() {
const { type, onNavigate } = this.props;
if (type instanceof GraphQLList) {
return (
<React.Fragment>
[
<GraphQLExplorerTypeLink onNavigate={onNavigate} type={type.ofType} />]
</React.Fragment>
);
}
if (type instanceof GraphQLNonNull) {
return (
<React.Fragment>
<GraphQLExplorerTypeLink onNavigate={onNavigate} type={type.ofType} />!
</React.Fragment>
);
}
return (
<a href="#" onClick={this._handleClick} className="notice">
{type.name}
</a>
);
}
}
export default GraphQLExplorerTypeLink;
@@ -0,0 +1,101 @@
// @flow
import * as React from 'react';
import GraphQLExplorerTypeLink from './graph-ql-explorer-type-link';
import autobind from 'autobind-decorator';
import MarkdownPreview from '../markdown-preview';
import GraphQLExplorerFieldLink from './graph-ql-explorer-field-link';
import type { GraphQLType, GraphQLField } from 'graphql';
type Props = {
onNavigateType: (type: Object) => void,
onNavigateField: (field: Object) => void,
type: GraphQLType,
};
@autobind
class GraphQLExplorerType extends React.PureComponent<Props> {
_handleNavigateType(type: Object) {
const { onNavigateType } = this.props;
onNavigateType(type);
}
_handleNavigateField(field: Object) {
const { onNavigateField } = this.props;
onNavigateField(field);
}
renderDescription() {
const { type } = this.props;
return <MarkdownPreview markdown={type.description || '*no description*'} />;
}
renderFieldsMaybe() {
const { type, onNavigateType } = this.props;
if (typeof type.getFields !== 'function') {
return null;
}
// $FlowFixMe
const fields = type.getFields();
return (
<React.Fragment>
<h2 className="graphql-explorer__subheading">Fields</h2>
<ul className="graphql-explorer__defs">
{Object.keys(fields).map(key => {
const field: GraphQLField<any, any> = (fields[key]: any);
let argLinks = null;
const args = (field: any).args;
if (args && args.length) {
argLinks = (
<React.Fragment>
(
{args.map(a => (
<div key={a.name} className="graphql-explorer__defs__arg">
<span className="info">{a.name}</span>:{' '}
<GraphQLExplorerTypeLink onNavigate={onNavigateType} type={a.type} />
</div>
))}
)
</React.Fragment>
);
}
const fieldLink = (
<GraphQLExplorerFieldLink onNavigate={this._handleNavigateField} field={field} />
);
const typeLink = (
<GraphQLExplorerTypeLink onNavigate={this._handleNavigateType} type={field.type} />
);
const description = field.description;
return (
<li key={key}>
{fieldLink}
{argLinks}: {typeLink}
{description && (
<div className="graphql-explorer__defs__description">
<MarkdownPreview markdown={description} />
</div>
)}
</li>
);
})}
</ul>
</React.Fragment>
);
}
render() {
return (
<div className="graphql-explorer__type">
{this.renderDescription()}
{this.renderFieldsMaybe()}
</div>
);
}
}
export default GraphQLExplorerType;
@@ -0,0 +1,200 @@
// @flow
import * as React from 'react';
import autobind from 'autobind-decorator';
import GraphQLExplorerField from './graph-ql-explorer-field';
import GraphQLExplorerType from './graph-ql-explorer-type';
import type { GraphQLArgument, GraphQLField, GraphQLSchema, GraphQLType } from 'graphql';
import GraphQLExplorerSchema from './graph-ql-explorer-schema';
type Props = {
handleClose: () => void,
schema: GraphQLSchema | null,
visible: boolean,
reference: null | {
type: GraphQLType | null,
argument: GraphQLArgument | null,
field: GraphQLField<any, any> | null,
},
};
type HistoryItem = {
currentType: null | GraphQLType,
currentField: null | GraphQLField<any, any>,
};
type State = HistoryItem & {
history: Array<HistoryItem>,
};
@autobind
class GraphQLExplorer extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
history: [],
currentType: null,
currentField: null,
};
}
_handleNavigateType(type: GraphQLType) {
this.setState({
currentType: type,
currentField: null,
history: this._addToHistory(),
});
}
_handleNavigateField(field: GraphQLField<any, any>) {
this.setState({
currentType: field.type,
currentField: field,
history: this._addToHistory(),
});
}
_handlePopHistory() {
this.setState(({ history }) => {
const last = history[history.length - 1] || null;
return {
history: history.slice(0, history.length - 1),
currentType: last ? last.currentType : null,
currentField: last ? last.currentField : null,
};
});
}
_addToHistory() {
const { currentType, currentField, history } = this.state;
// Nothing to add
if (!currentType && !currentField) {
return history;
}
return [...history, { currentType, currentField }];
}
componentWillReceiveProps(nextProps: Props) {
if (!nextProps.reference) {
return;
}
const { type, field } = nextProps.reference;
const { currentType, currentField } = this.state;
const compare = (a: any, b: any) => (!a && !b) || (a && b && a.name === b.name);
const sameType = compare(currentType, type);
const sameField = compare(currentField, field);
const nothingChanged = sameType && sameField;
if (nothingChanged) {
return;
}
let history = this._addToHistory();
this.setState({
history,
currentType: type || null,
currentField: field || null,
});
}
renderHistoryItem() {
const { history, currentField, currentType } = this.state;
if (history.length === 0 && (currentType || currentField)) {
return (
<a
href="#"
className="graphql-explorer__header__back-btn"
onClick={e => {
e.preventDefault();
this._handlePopHistory();
}}>
<i className="fa--skinny fa fa-angle-left" /> Schema
</a>
);
} else if (history.length === 0) {
return null;
}
const { currentType: lastType, currentField: lastField } = history[history.length - 1];
let name = null;
if (lastField) {
name = (lastField: any).name || 'Unknown';
} else if (lastType) {
name = (lastType: any).name || 'Unknown';
} else {
return null;
}
return (
<a
href="#"
className="graphql-explorer__header__back-btn"
onClick={e => {
e.preventDefault();
this._handlePopHistory();
}}>
<i className="fa--skinny fa fa-angle-left" /> {name}
</a>
);
}
render() {
const { schema, handleClose, visible } = this.props;
if (!visible) {
return null;
}
const { currentType, currentField } = this.state;
let child = null;
if (currentField) {
child = (
<GraphQLExplorerField onNavigateType={this._handleNavigateType} field={currentField} />
);
} else if (currentType) {
child = (
<GraphQLExplorerType
type={currentType}
onNavigateType={this._handleNavigateType}
onNavigateField={this._handleNavigateField}
/>
);
} else if (schema) {
child = <GraphQLExplorerSchema onNavigateType={this._handleNavigateType} schema={schema} />;
}
if (!child) {
return null;
}
const fieldName = currentField ? (currentField: any).name : null;
const typeName = currentType ? (currentType: any).name : null;
const schemaName = schema ? 'Schema' : null;
return (
<div className="graphql-explorer theme--dialog">
<div className="graphql-explorer__header">
{this.renderHistoryItem()}
<h1>{fieldName || typeName || schemaName || 'Unknown'}</h1>
<button
className="btn btn--compact graphql-explorer__header__close-btn"
onClick={handleClose}>
<i className="fa fa-close" />
</button>
</div>
<div className="graphql-explorer__body">{child}</div>
</div>
);
}
}
export default GraphQLExplorer;
@@ -22,20 +22,25 @@ class MarkdownPreview extends PureComponent {
*/
_compileMarkdown(markdown) {
clearTimeout(this._compileTimeout);
this._compileTimeout = setTimeout(async () => {
try {
const rendered = await this.props.handleRender(markdown);
this.setState({
compiled: markdownToHTML(rendered),
renderError: '',
});
} catch (err) {
this.setState({
renderError: err.message,
compiled: '',
});
}
}, this.state.compiled ? this.props.debounceMillis : 0);
this._compileTimeout = setTimeout(
async () => {
try {
const { handleRender } = this.props;
const rendered = handleRender ? await handleRender(markdown) : markdown;
const compiled = markdownToHTML(rendered);
this.setState({
compiled,
renderError: '',
});
} catch (err) {
this.setState({
renderError: err.message,
compiled: '',
});
}
},
this.state.compiled ? this.props.debounceMillis : 0,
);
}
_setPreviewRef(n) {
@@ -106,9 +111,9 @@ class MarkdownPreview extends PureComponent {
MarkdownPreview.propTypes = {
// Required
markdown: PropTypes.string.isRequired,
handleRender: PropTypes.func.isRequired,
// Optional
handleRender: PropTypes.func,
className: PropTypes.string,
debounceMillis: PropTypes.number,
heading: PropTypes.string,
@@ -0,0 +1,86 @@
@import '../constants/dimensions';
@import '../constants/colors';
.graphql-explorer {
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto minmax(0, 1fr);
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 400px;
max-width: 100%;
z-index: 100;
background: var(--color-bg);
box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.4);
.markdown-preview__content *:first-child {
margin-top: @padding-sm;
}
&__subheading {
border-bottom: 1px solid var(--hl-md);
padding-bottom: @padding-sm;
margin-top: @padding-lg;
margin-bottom: @padding-md;
}
&__header {
overflow: hidden;
border-bottom: 1px solid @hl-md;
box-sizing: border-box;
height: @height-nav;
line-height: @line-height-md;
color: var(--color-font);
display: flex;
justify-content: space-between;
flex-direction: row;
h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: @font-size-lg;
font-weight: bold;
}
& > * {
white-space: nowrap;
padding-left: @padding-md;
display: inline-block;
margin: 0;
}
&__close-btn {
height: 100% !important;
}
}
&__body {
box-sizing: border-box;
padding: @padding-md @padding-md 10rem;
overflow: auto;
height: 100%;
max-height: 100%;
}
&__defs {
&__description {
margin-left: @padding-sm;
color: var(--color-font);
}
&__arg {
margin-left: @padding-md;
}
li {
margin-bottom: @padding-md;
}
}
a {
font-weight: normal !important;
}
}
@@ -22,6 +22,7 @@
@import 'components/cookie-list';
@import 'components/cookie-modify-modal';
@import 'components/graph-ql-editor';
@import 'components/graph-ql-explorer';
@import 'components/dropdown';
@import 'components/editable';
@import 'components/environment-modal';
-63
View File
@@ -1,63 +0,0 @@
// @flow
declare module 'graphql' {
declare type Tok = {
column: number,
end: number,
kind: string,
line: number,
next: Tok,
prev: Tok,
start: number,
};
declare type Loc = {
end: number,
endToken: Tok,
source: Source,
start: number,
startToken: Tok,
};
declare type ParseOptions = mixed;
declare type Source = mixed;
declare type Document = {
definitions: Array<{
kind: string,
loc: Loc,
variableDefinitions: Array<{
variable: {
name: {
kind: string,
value: string,
},
},
type: string,
}>,
name: {
kind: string,
value: string,
},
}>,
};
declare function parse(source: Source | string, options?: ParseOptions): Document;
declare module.exports: {
parse: typeof parse,
print: *,
typeFromAST: *,
};
}
declare module 'graphql/utilities/introspectionQuery' {
declare module.exports: {
introspectionQuery: string,
};
}
declare module 'graphql/utilities/buildClientSchema' {
declare module.exports: {
buildClientSchema: Function,
};
}