diff --git a/LICENSE b/LICENSE index cd2262e3a31..cb1d3c5c3a8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 GraphQL Contributors +Copyright (c) 2020 GraphQL Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/examples/graphiql-cdn-subscriptions/index.html b/examples/graphiql-cdn-subscriptions/index.html index 7a0907aff2f..f0acd44c1fc 100644 --- a/examples/graphiql-cdn-subscriptions/index.html +++ b/examples/graphiql-cdn-subscriptions/index.html @@ -1,5 +1,5 @@ + + + + + + + + + + + + + + +
Loading...
+ + + + diff --git a/packages/graphiql-2-rfc-context/resources/renderExample.js b/packages/graphiql-2-rfc-context/resources/renderExample.js new file mode 100644 index 00000000000..9c86779caf4 --- /dev/null +++ b/packages/graphiql-2-rfc-context/resources/renderExample.js @@ -0,0 +1,153 @@ +/** + * This GraphiQL example illustrates how to use some of GraphiQL's props + * in order to enable reading and updating the URL parameters, making + * link sharing of queries a little bit easier. + * + * This is only one example of this kind of feature, GraphiQL exposes + * various React params to enable interesting integrations. + */ + +// Parse the search string to get url parameters. +var search = window.location.search; +var parameters = {}; +search + .substr(1) + .split('&') + .forEach(function (entry) { + var eq = entry.indexOf('='); + if (eq >= 0) { + parameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent( + entry.slice(eq + 1), + ); + } + }); + +// If variables was provided, try to format it. +if (parameters.variables) { + try { + parameters.variables = JSON.stringify( + JSON.parse(parameters.variables), + null, + 2, + ); + } catch (e) { + // Do nothing, we want to display the invalid JSON as a string, rather + // than present an error. + } +} + +// If headers was provided, try to format it. +if (parameters.headers) { + try { + parameters.headers = JSON.stringify( + JSON.parse(parameters.headers), + null, + 2, + ); + } catch (e) { + // Do nothing, we want to display the invalid JSON as a string, rather + // than present an error. + } +} + +// When the query and variables string is edited, update the URL bar so +// that it can be easily shared. +function onEditQuery(newQuery) { + parameters.query = newQuery; + updateURL(); +} + +function onEditVariables(newVariables) { + parameters.variables = newVariables; + updateURL(); +} + +function onEditHeaders(newHeaders) { + parameters.headers = newHeaders; + updateURL(); +} + +function onEditOperationName(newOperationName) { + parameters.operationName = newOperationName; + updateURL(); +} + +function updateURL() { + var newSearch = + '?' + + Object.keys(parameters) + .filter(function (key) { + return Boolean(parameters[key]); + }) + .map(function (key) { + return ( + encodeURIComponent(key) + '=' + encodeURIComponent(parameters[key]) + ); + }) + .join('&'); + history.replaceState(null, null, newSearch); +} + +const isDev = window.location.hostname.match(/localhost$/); +const api = isDev + ? '/graphql' + : 'https://swapi-graphql.netlify.app/.netlify/functions/index'; + +// Defines a GraphQL fetcher using the fetch API. You're not required to +// use fetch, and could instead implement graphQLFetcher however you like, +// as long as it returns a Promise or Observable. +function graphQLFetcher(graphQLParams, opts = { headers: {} }) { + // When working locally, the example expects a GraphQL server at the path /graphql. + // In a PR preview, it connects to the Star Wars API externally. + // Change this to point wherever you host your GraphQL server. + + let headers = opts.headers; + // Convert headers to an object. + if (typeof headers === 'string') { + headers = JSON.parse(opts.headers); + } + + return fetch(api, { + method: 'post', + headers: Object.assign( + { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + headers, + ), + body: JSON.stringify(graphQLParams), + credentials: 'omit', + }) + .then(function (response) { + return response.text(); + }) + .then(function (responseBody) { + try { + return JSON.parse(responseBody); + } catch (error) { + return responseBody; + } + }); +} +// Render into the body. +// See the README in the top level of this module to learn more about +// how you can customize GraphiQL by providing different values or +// additional child elements. +ReactDOM.render( + React.createElement(GraphiQL, { + uri: api, + fetcher: graphQLFetcher, + query: parameters.query, + variables: parameters.variables, + headers: parameters.headers, + operationName: parameters.operationName, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditHeaders: onEditHeaders, + defaultVariableEditorOpen: true, + onEditOperationName: onEditOperationName, + headerEditorEnabled: true, + }), + document.getElementById('graphiql'), +); diff --git a/packages/graphiql-2-rfc-context/resources/webpack.config.js b/packages/graphiql-2-rfc-context/resources/webpack.config.js new file mode 100644 index 00000000000..db7cbdc9508 --- /dev/null +++ b/packages/graphiql-2-rfc-context/resources/webpack.config.js @@ -0,0 +1,210 @@ +const path = require('path'); +const webpack = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); +const ErrorOverlayPlugin = require('error-overlay-webpack-plugin'); + +const isDev = process.env.NODE_ENV === 'development'; +const isHMR = Boolean(isDev && process.env.WEBPACK_DEV_SERVER); + +const relPath = (...args) => path.resolve(__dirname, ...args); +const rootPath = (...args) => relPath('../', ...args); + +const resultConfig = { + mode: process.env.NODE_ENV, + entry: { + graphiql: './cdn.ts', + 'graphql.worker': './workers/graphql.worker.ts', + 'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js', + 'json.worker': 'monaco-editor/esm/vs/language/json/json.worker.js', + }, + context: rootPath('src'), + output: { + path: isDev ? rootPath('bundle/dev') : rootPath('bundle/dist'), + // library: 'GraphiQL', + libraryTarget: 'window', + // libraryExport: 'default', + filename: '[name].js', + globalObject: 'this', + // filename: isDev ? 'graphiql.js' : 'graphiql.min.js', + }, + devServer: { + // hot: true, + // bypass simple localhost CORS restrictions by setting + // these to 127.0.0.1 in /etc/hosts + allowedHosts: ['local.example.com', 'graphiql.com'], + before: require('../test/beforeDevServer'), + }, + devtool: isDev ? 'cheap-module-source-map' : 'source-map', + node: { + fs: 'empty', + module: 'empty', + }, + externals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + optimization: { + splitChunks: { name: 'vendor' }, + }, + module: { + rules: [ + // for graphql module, which uses .mjs + { + type: 'javascript/auto', + test: /\.mjs$/, + use: [], + include: /node_modules/, + exclude: /\.(ts|d\.ts|d\.ts\.map)$/, + }, + // i think we need to add another rule for + // codemirror-graphql esm.js files to load + { + test: /\.(js|jsx|ts|tsx)$/, + use: [{ loader: 'babel-loader' }], + exclude: /\.(d\.ts|d\.ts\.map|spec\.tsx)$/, + }, + + { + test: /\.svg$/, + use: [{ loader: 'svg-inline-loader' }], + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + use: ['file-loader'], + }, + { + test: /\.css$/, + include: rootPath('src'), + use: [ + { + loader: MiniCssExtractPlugin.loader, + options: { + hmr: isHMR, + }, + }, + 'css-loader', + 'postcss-loader', + ], + }, + { + test: /\.css$/, + include: rootPath('../../node_modules/monaco-editor'), + use: ['style-loader', 'css-loader'], + }, + ], + }, + plugins: [ + // in order to prevent async modules for CDN builds + // until we can guarantee it will work with the CDN properly + // and so that graphiql.min.js can retain parity + new HtmlWebpackPlugin({ + template: relPath('index.html.ejs'), + inject: 'head', + filename: 'index.html', + }), + new MiniCssExtractPlugin({ + // Options similar to the same options in webpackOptions.output + // both options are optional + filename: isDev ? 'graphiql.css' : 'graphiql.min.css', + chunkFilename: '[id].css', + }), + new ForkTsCheckerWebpackPlugin({ + async: isDev, + tsconfig: rootPath('tsconfig.json'), + }), + // TODO: reduces bundle size, but then we lose the JSON grammar + // new webpack.IgnorePlugin({ + // contextRegExp: /monaco-editor\/esm\/vs\/language\/css\/$/ + // }) + ], + resolve: { + extensions: ['.mjs', '.js', '.json', '.jsx', '.css', '.ts', '.tsx'], + modules: [rootPath('node_modules'), rootPath('../', '../', 'node_modules')], + }, +}; + +const cssLoaders = [ + { + loader: MiniCssExtractPlugin.loader, + }, + 'css-loader', +]; + +if (!isDev) { + cssLoaders.push('postcss-loader'); +} else { + // TODO: This worked, but somehow this ended up totally busted + // resultConfig.plugins.push(new ErrorOverlayPlugin()); +} + +if (process.env.ANALYZE) { + resultConfig.plugins.push( + new BundleAnalyzerPlugin({ + analyzerMode: 'static', + openAnalyzer: false, + reportFilename: rootPath('analyzer.html'), + }), + ); +} + +module.exports = resultConfig; + +// const otherConfig = +// { +// mode: process.env.NODE_ENV, +// entry: { +// 'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js', +// 'json.worker': 'monaco-editor/esm/vs/language/json/json.worker.js', +// 'graphql.worker': 'monaco-graphql/esm/graphql.worker.js', +// }, +// output: { +// path: isDev ? rootPath('build') : rootPath('bundle'), +// filename: '[name].js', +// globalObject: 'self', +// }, +// node: { +// fs: 'empty', +// module: 'empty', +// }, +// module: { +// rules: [ +// // for graphql module, which uses .mjs +// { +// type: 'javascript/auto', +// test: /\.mjs$/, +// use: [], +// include: /node_modules/, +// exclude: /\.(ts|d\.ts|d\.ts\.map)$/, +// }, +// // i think we need to add another rule for +// // codemirror-graphql esm.js files to load +// { +// test: /\.(js|jsx|ts|tsx)$/, +// use: [{ loader: 'babel-loader' }], +// exclude: /\.(d\.ts|d\.ts\.map|spec\.tsx)$/, +// }, + +// { +// test: /\.svg$/, +// use: [{ loader: 'svg-inline-loader' }], +// }, +// { +// test: /\.(woff|woff2|eot|ttf|otf)$/, +// use: ['file-loader'], +// }, +// { +// test: /\.css$/, +// use: ['style-loader', 'css-loader'], +// }, +// ], +// }, +// plugins: [ +// new ForkTsCheckerWebpackPlugin({ +// async: isDev, +// tsconfig: rootPath('tsconfig.json'), +// }), +// ], +// } diff --git a/packages/graphiql-2-rfc-context/src/api/actions/editorActions.ts b/packages/graphiql-2-rfc-context/src/api/actions/editorActions.ts new file mode 100644 index 00000000000..53ab214b9da --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/actions/editorActions.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export enum EditorActionTypes { + EditorLoaded = 'EditorLoaded', +} + +export type EditorAction = EditorLoadedAction; + +export const editorLoadedAction = ( + editorKey: string, + editor: monaco.editor.IStandaloneCodeEditor, +) => + ({ + type: EditorActionTypes.EditorLoaded, + payload: { editor, editorKey }, + } as const); + +export type EditorLoadedAction = ReturnType; diff --git a/packages/graphiql-2-rfc-context/src/api/actions/schemaActions.ts b/packages/graphiql-2-rfc-context/src/api/actions/schemaActions.ts new file mode 100644 index 00000000000..b8fcc85c43e --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/actions/schemaActions.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { SchemaConfig } from '../../types'; +import { GraphQLSchema } from 'graphql'; + +export enum SchemaActionTypes { + SchemaChanged = 'SchemaChanged', + SchemaRequested = 'SchemaRequested', + SchemaSucceeded = 'SchemaSucceeded', + SchemaErrored = 'SchemaErrored', + SchemaReset = 'SchemaReset', +} + +export type SchemaAction = + | SchemaChangedAction + | SchemaRequestedAction + | SchemaSucceededAction + | SchemaErroredAction + | SchemaResetAction; + +export const schemaChangedAction = (config: SchemaConfig) => + ({ + type: SchemaActionTypes.SchemaChanged, + payload: config, + } as const); + +export type SchemaChangedAction = ReturnType; + +export const schemaRequestedAction = () => + ({ + type: SchemaActionTypes.SchemaRequested, + } as const); + +export type SchemaRequestedAction = ReturnType; + +export const schemaSucceededAction = (schema: GraphQLSchema) => + ({ + type: SchemaActionTypes.SchemaSucceeded, + payload: schema, + } as const); + +export type SchemaSucceededAction = ReturnType; + +export const schemaErroredAction = (error: Error) => + ({ + type: SchemaActionTypes.SchemaErrored, + payload: error, + } as const); + +export type SchemaErroredAction = ReturnType; + +export const schemaResetAction = () => + ({ + type: SchemaActionTypes.SchemaReset, + } as const); + +export type SchemaResetAction = ReturnType; diff --git a/packages/graphiql-2-rfc-context/src/api/actions/sessionActions.ts b/packages/graphiql-2-rfc-context/src/api/actions/sessionActions.ts new file mode 100644 index 00000000000..732ecfb9aae --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/actions/sessionActions.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { EditorContexts } from '../types'; + +export enum SessionActionTypes { + OperationRequested = 'OperationRequested', + EditorLoaded = 'EditorLoaded', + OperationChanged = 'OperationChanged', + VariablesChanged = 'VariablesChanged', + OperationSucceeded = 'OperationSucceeded', + OperationErrored = 'OperationErrored', + TabChanged = 'TabChanged', +} + +export type ErrorPayload = { error: Error; sessionId: number }; +export type SuccessPayload = { sessionId: number; result: string }; +export type ChangeValuePayload = { sessionId: number; value: string }; + +export type SessionAction = + | OperationRequestedAction + | EditorLoadedAction + | OperationChangedAction + | VariablesChangedAction + | OperationSucceededAction + | OperationErroredAction + | TabChangedAction; + +export const operationRequestAction = () => + ({ + type: SessionActionTypes.OperationRequested, + } as const); + +export type OperationRequestedAction = ReturnType< + typeof operationRequestAction +>; + +export const editorLoadedAction = ( + context: EditorContexts, + editor: CodeMirror.Editor, +) => + ({ + type: SessionActionTypes.EditorLoaded, + payload: { + context, + editor, + }, + } as const); + +type EditorLoadedAction = ReturnType; + +export const operationChangedAction = (value: string, sessionId: number) => + ({ + type: SessionActionTypes.OperationChanged, + payload: { value, sessionId }, + } as const); + +export type OperationChangedAction = ReturnType; + +export const variableChangedAction = (value: string, sessionId: number) => + ({ + type: SessionActionTypes.VariablesChanged, + payload: { value, sessionId }, + } as const); + +export type VariablesChangedAction = ReturnType; + +export const operationSucceededAction = (result: string, sessionId: number) => + ({ + type: SessionActionTypes.OperationSucceeded, + payload: { + result, + sessionId, + }, + } as const); + +export type OperationSucceededAction = ReturnType< + typeof operationSucceededAction +>; + +export const operationErroredAction = (error: Error, sessionId: number) => + ({ + type: SessionActionTypes.OperationErrored, + payload: { error, sessionId }, + } as const); + +export type OperationErroredAction = ReturnType; + +export const tabChangedAction = (pane: string, tabId: number) => + ({ + type: SessionActionTypes.TabChanged, + payload: { pane, tabId }, + } as const); + +export type TabChangedAction = ReturnType; diff --git a/packages/graphiql-2-rfc-context/src/api/common.ts b/packages/graphiql-2-rfc-context/src/api/common.ts new file mode 100644 index 00000000000..c4ca3ef3d87 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/common.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { SchemaConfig, Fetcher } from '../types'; +import { GraphQLParams } from './types'; + +export function getDefaultFetcher(schemaConfig: SchemaConfig) { + return async function defaultFetcher(graphqlParams: GraphQLParams) { + try { + const rawResult = await fetch(schemaConfig.uri, { + method: 'post', + body: JSON.stringify(graphqlParams), + headers: { 'Content-Type': 'application/json', credentials: 'omit' }, + }); + + const responseBody = await rawResult.json(); + + if (!rawResult.ok) { + throw responseBody; + } + + return responseBody; + } catch (err) { + console.error(err); + throw err; + } + }; +} + +export function getFetcher({ + fetcher, + uri, +}: { + fetcher?: Fetcher; + uri?: string; +}) { + if (fetcher) { + return fetcher; + } + + if (uri) { + return getDefaultFetcher({ uri }); + } + + throw new Error('Must provide either a fetcher or a uri'); +} diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/index.ts b/packages/graphiql-2-rfc-context/src/api/hooks/index.ts new file mode 100644 index 00000000000..ca361267f8b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/hooks/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './useOperation'; +export * from './useQueryFacts'; +export * from './useSchema'; +export * from './useValueRef'; diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/useOperation.ts b/packages/graphiql-2-rfc-context/src/api/hooks/useOperation.ts new file mode 100644 index 00000000000..d9501101dee --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/hooks/useOperation.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSessionContext } from '../providers/GraphiQLSessionProvider'; + +export default function useOperation() { + const { operation } = useSessionContext(); + return operation; +} diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts b/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts new file mode 100644 index 00000000000..04583bbd3b3 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/hooks/useQueryFacts.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; + +import getQueryFacts from '../../utility/getQueryFacts'; +import useSchema from './useSchema'; +import useOperation from './useOperation'; + +export default function useQueryFacts() { + const schema = useSchema(); + const { text } = useOperation(); + return useMemo(() => (schema ? getQueryFacts(schema, text) : null), [ + schema, + text, + ]); +} diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/useSchema.ts b/packages/graphiql-2-rfc-context/src/api/hooks/useSchema.ts new file mode 100644 index 00000000000..2dd8a1c37c0 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/hooks/useSchema.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSchemaContext } from '../providers/GraphiQLSchemaProvider'; + +export default function useSchema() { + const { schema } = useSchemaContext(); + return schema; +} diff --git a/packages/graphiql-2-rfc-context/src/api/hooks/useValueRef.ts b/packages/graphiql-2-rfc-context/src/api/hooks/useValueRef.ts new file mode 100644 index 00000000000..9e7557c2997 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/hooks/useValueRef.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useRef, MutableRefObject } from 'react'; + +/** + * useValueRef + * + * Returns a reference to a given value. Automatically updates the value of the refrence when the value updates + * + */ +export default function useValueRef(value: T): MutableRefObject { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref; +} diff --git a/packages/graphiql-2-rfc-context/src/api/index.ts b/packages/graphiql-2-rfc-context/src/api/index.ts new file mode 100644 index 00000000000..e941afd965b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './providers/GraphiQLEditorsProvider'; +export * from './providers/GraphiQLSessionProvider'; +export * from './providers/GraphiQLSchemaProvider'; +export * from './hooks'; +export * from './types'; diff --git a/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLEditorsProvider.tsx b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLEditorsProvider.tsx new file mode 100644 index 00000000000..e056f234c9c --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLEditorsProvider.tsx @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useCallback } from 'react'; + +import { + editorLoadedAction, + EditorActionTypes, + EditorAction, +} from '../actions/editorActions'; + +import EditorWorker from 'worker-loader!monaco-editor/esm/vs/editor/editor.worker'; +import JSONWorker from 'worker-loader!monaco-editor/esm/vs/language/json/json.worker'; +import GraphQLWorker from 'worker-loader!../../workers/graphql.worker'; + +window.MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + if (label === 'graphqlDev') { + return new GraphQLWorker(); + } + if (label === 'json') { + return new JSONWorker(); + } + return new EditorWorker(); + }, +}; + +/** + * Initial State + */ +export type EditorLookup = { + [editorKey: string]: { editor: monaco.editor.IStandaloneCodeEditor }; +}; + +export type EditorState = { editors: EditorLookup }; + +export type EditorReducer = React.Reducer; + +export type EditorHandlers = { + loadEditor: ( + editorKey: string, + editor: monaco.editor.IStandaloneCodeEditor, + ) => void; +}; + +export const editorReducer: EditorReducer = (state, action): EditorState => { + switch (action.type) { + case EditorActionTypes.EditorLoaded: + return { + ...state, + editors: { + ...state.editors, + [action.payload.editorKey]: { editor: action.payload.editor }, + }, + }; + default: { + return state; + } + } +}; + +export const EditorContext = React.createContext({ + editors: {}, + loadEditor: () => { + return {}; + }, +}); + +export const useEditorsContext = () => React.useContext(EditorContext); + +export function EditorsProvider(props: { children?: any }) { + const [state, dispatch] = React.useReducer(editorReducer, { + editors: {}, + }); + + const loadEditor = useCallback( + (editorKey: string, editor: monaco.editor.IStandaloneCodeEditor) => { + dispatch(editorLoadedAction(editorKey, editor)); + }, + [], + ); + + return ( + + {props.children} + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSchemaProvider.tsx b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSchemaProvider.tsx new file mode 100644 index 00000000000..85f16590451 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSchemaProvider.tsx @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useCallback } from 'react'; +import { GraphQLSchema } from 'graphql'; +import { SchemaConfig, Fetcher } from '../../types'; + +import { defaultSchemaBuilder } from 'graphql-language-service'; + +import { + SchemaAction, + SchemaActionTypes, + schemaRequestedAction, + schemaSucceededAction, + schemaErroredAction, +} from '../actions/schemaActions'; + +/** + * Initial State + */ + +export type SchemaState = { + schema: GraphQLSchema | null; + isLoading: boolean; + errors: Error[] | null; + config: SchemaConfig; +}; + +export type SchemaReducer = React.Reducer; + +export const initialReducerState: SchemaState = { + isLoading: false, + config: { uri: '' }, + schema: null, + errors: null, +}; + +export const getInitialState = ( + initialState?: Partial, +): SchemaState => ({ + ...initialReducerState, + ...initialState, +}); + +/** + * Context + */ + +export type SchemaContextValue = SchemaState & ProjectHandlers; + +export const SchemaContext = React.createContext({ + ...getInitialState(), + loadCurrentSchema: async () => undefined, + dispatch: async () => undefined, +}); + +export const useSchemaContext = () => React.useContext(SchemaContext); + +/** + * Action Types & Reducers + */ + +export const schemaReducer: SchemaReducer = (state, action): SchemaState => { + switch (action.type) { + case SchemaActionTypes.SchemaChanged: + return { + ...state, + isLoading: true, + config: action.payload, + }; + case SchemaActionTypes.SchemaRequested: + return { + ...state, + isLoading: true, + }; + case SchemaActionTypes.SchemaSucceeded: + return { + ...state, + isLoading: false, + schema: action.payload, + }; + case SchemaActionTypes.SchemaErrored: + return { + ...state, + isLoading: false, + errors: state.errors + ? [...state.errors, action.payload] + : [action.payload], + }; + default: { + return state; + } + } +}; + +/** + * Provider + */ + +export type SchemaProviderProps = { + config?: SchemaConfig; + children?: any; + fetcher: Fetcher; +}; + +export type ProjectHandlers = { + loadCurrentSchema: (state: SchemaState) => Promise; + dispatch: React.Dispatch; +}; + +export function SchemaProvider({ + config: userSchemaConfig = initialReducerState.config, + fetcher, + ...props +}: SchemaProviderProps) { + const [state, dispatch] = React.useReducer( + schemaReducer, + getInitialState({ config: userSchemaConfig }), + ); + + const loadCurrentSchema = useCallback(async () => { + dispatch(schemaRequestedAction()); + try { + const { api: GraphQLAPI } = await import( + 'monaco-graphql/esm/monaco.contribution' + ); + + // @ts-ignore + const schema: GraphQLSchema = await GraphQLAPI.getSchema(); + console.log('schema fetched'); + // @ts-ignore + dispatch(schemaSucceededAction(defaultSchemaBuilder(schema))); + } catch (error) { + console.error(error); + dispatch(schemaErroredAction(error)); + } + }, []); + + React.useEffect(() => { + if (state.config) { + const { + api: GraphQLAPI, + } = require('monaco-graphql/esm/monaco.contribution'); + // @ts-ignore + GraphQLAPI.setSchemaConfig(state.config); + } + setTimeout(() => { + loadCurrentSchema() + .then(() => { + console.log('completed'); + }) + .catch(err => console.error(err)); + }, 200); + }, [state.config, loadCurrentSchema]); + + return ( + + {props.children} + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSessionProvider.tsx b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSessionProvider.tsx new file mode 100644 index 00000000000..d888d177519 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/providers/GraphiQLSessionProvider.tsx @@ -0,0 +1,248 @@ +/* global monaco */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import { Fetcher, FetcherResult } from '../../types'; + +import { GraphQLParams, SessionState } from '../types'; + +import { SchemaContext } from './GraphiQLSchemaProvider'; +import { EditorContext } from './GraphiQLEditorsProvider'; +import { + SessionAction, + SessionActionTypes, + operationRequestAction, + operationSucceededAction, + variableChangedAction, + operationChangedAction, + operationErroredAction, + tabChangedAction, +} from '../actions/sessionActions'; + +import { observableToPromise } from '../../utility/observableToPromise'; +// import { KeyMod, KeyCode } from 'monaco-editor'; + +export type SessionReducer = React.Reducer; +export interface SessionHandlers { + changeOperation: (operation: string) => void; + changeVariables: (variables: string) => void; + changeTab: (pane: string, tabId: number) => void; + executeOperation: (operationName?: string) => Promise; + operationError: (error: Error) => void; + dispatch: React.Dispatch; +} + +export const initialState: SessionState = { + sessionId: 0, + operation: { uri: 'graphql://graphiql/operations/0.graphql' }, + variables: { uri: 'graphql://graphiql/variables/0.graphql' }, + results: { uri: 'graphql://graphiql/results/0.graphql' }, + currentTabs: { + operation: 0, + variables: 0, + results: 0, + }, + operationLoading: true, + operationErrors: null, + operations: [], + // @ts-ignore + editors: {}, +}; + +export const initialContextState: SessionState & SessionHandlers = { + executeOperation: async () => {}, + changeOperation: () => null, + changeVariables: () => null, + changeTab: () => null, + operationError: () => null, + dispatch: () => null, + ...initialState, +}; + +export const SessionContext = React.createContext< + SessionState & SessionHandlers +>(initialContextState); + +export const useSessionContext = () => React.useContext(SessionContext); + +const sessionReducer: SessionReducer = (state, action) => { + switch (action.type) { + case SessionActionTypes.OperationRequested: + return { + ...state, + operationLoading: true, + }; + case SessionActionTypes.OperationChanged: { + const { value } = action.payload; + return { + ...state, + operation: { + ...state.operation, + text: value, + }, + }; + } + case SessionActionTypes.VariablesChanged: { + const { value } = action.payload; + return { + ...state, + variables: { + ...state.variables, + text: value, + }, + }; + } + case SessionActionTypes.OperationSucceeded: { + const { result } = action.payload; + return { + ...state, + results: { + ...state.results, + text: JSON.stringify(result, null, 2), + }, + operationErrors: null, + }; + } + case SessionActionTypes.OperationErrored: { + const { error } = action.payload; + return { + ...state, + operationErrors: state.operationErrors + ? [...state.operationErrors, error] + : [error], + }; + } + case SessionActionTypes.TabChanged: { + const { pane, tabId } = action.payload; + return { + ...state, + currentTabs: { + ...state.currentTabs, + [pane]: tabId, + }, + }; + } + default: { + return state; + } + } +}; + +export type SessionProviderProps = { + sessionId: number; + fetcher: Fetcher; + session?: SessionState; + children: React.ReactNode; +}; + +export function SessionProvider({ + sessionId, + fetcher, + session, + children, +}: SessionProviderProps) { + const schemaState = React.useContext(SchemaContext); + const editorsState = React.useContext(EditorContext); + + const [state, dispatch] = React.useReducer( + sessionReducer, + initialState, + ); + + const operationError = React.useCallback( + (error: Error) => dispatch(operationErroredAction(error, sessionId)), + [sessionId], + ); + + const changeOperation = React.useCallback( + (operationText: string) => + dispatch(operationChangedAction(operationText, sessionId)), + [sessionId], + ); + + const changeVariables = React.useCallback( + (variablesText: string) => + dispatch(variableChangedAction(variablesText, sessionId)), + [sessionId], + ); + + const changeTab = React.useCallback( + (pane: string, tabId: number) => dispatch(tabChangedAction(pane, tabId)), + [], + ); + + const executeOperation = React.useCallback( + async (operationName?: string) => { + try { + dispatch(operationRequestAction()); + const { operation: op, variables: vars } = editorsState.editors; + const operation = op.editor.getValue(); + const variables = vars.editor.getValue(); + + const fetchValues: GraphQLParams = { + query: operation ?? '', + }; + if (variables && variables !== '{}') { + fetchValues.variables = variables; + } + if (operationName) { + fetchValues.operationName = operationName as string; + } + const result = await observableToPromise( + fetcher(fetchValues), + ); + dispatch(operationSucceededAction(result, sessionId)); + } catch (err) { + console.error(err.name, err.stack); + operationError(err); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + fetcher, + operationError, + schemaState.config, + sessionId, + editorsState.editors, + ], + ); + + React.useEffect(() => { + if (editorsState.editors.operation) { + editorsState.editors.operation.editor.addAction({ + id: 'run-command', + label: 'Run Operation', + contextMenuOrder: 0, + contextMenuGroupId: 'operation', + // eslint-disable-next-line no-bitwise + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run: async () => { + return executeOperation(); + }, + }); + } + }, [editorsState.editors.operation, executeOperation]); + + return ( + + {children} + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/api/providers/__tests__/GraphiQLSchemaProvider.spec.tsx b/packages/graphiql-2-rfc-context/src/api/providers/__tests__/GraphiQLSchemaProvider.spec.tsx new file mode 100644 index 00000000000..05b0cdb2e64 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/providers/__tests__/GraphiQLSchemaProvider.spec.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import fetchMock from 'fetch-mock'; +import { + SchemaContext, + SchemaProvider, + SchemaProviderProps, +} from '../GraphiQLSchemaProvider'; +import { loadConfig } from 'graphql-config'; +import { introspectionFromSchema } from 'graphql'; +import path from 'path'; +import { renderProvider, getProviderData } from './util'; + +const configDir = path.join( + __dirname, + '../../../../../../packages/graphql-language-service-server/src/__tests__', +); + +const renderSchemaProvider = (props: SchemaProviderProps) => + renderProvider(SchemaProvider, SchemaContext, props); + +const wait = async (delay: number = 1000) => setTimeout(Promise.resolve, delay); + +describe('GraphiQLSchemaProvider', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + it('SchemaProvider loads the schema', async () => { + const graphQLRC = await loadConfig({ rootDir: configDir }); + + const introspectionResult = { + data: introspectionFromSchema( + await graphQLRC.getProject('testWithSchema').getSchema(), + { descriptions: true }, + ), + }; + fetchMock.post('https://example', { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + body: introspectionResult, + }); + const provider = await renderSchemaProvider({ + config: { uri: 'https://example' }, + }); + await wait(1000); + const { schema, isLoading, error } = getProviderData(provider); + expect(schema).toBeTruthy(); + expect(isLoading).toEqual(false); + expect(error).toBeFalsy(); + }); + + it('SchemaProvider errors on bad schema', async () => { + fetchMock.post( + 'https://bad', + { + headers: { + 'Content-Type': 'application/json', + }, + status: 500, + body: { errors: ['invalid introspection query'] }, + }, + { overwriteRoutes: true }, + ); + const provider = await renderSchemaProvider({ + config: { uri: 'https://bad' }, + }); + const { schema, isLoading } = getProviderData(provider); + expect(schema).toBeFalsy(); + const { hasError, error } = getProviderData(provider); + expect(hasError).toBeTruthy(); + expect(error).toEqual('Error: error fetching introspection schema'); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/api/providers/__tests__/util.tsx b/packages/graphiql-2-rfc-context/src/api/providers/__tests__/util.tsx new file mode 100644 index 00000000000..68f7586cedd --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/providers/__tests__/util.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import { render, RenderResult, act } from '@testing-library/react'; + +export async function renderProvider(Provider, Context, props) { + const tree = ( + + + {value => ( + {JSON.stringify(value.state)} + )} + + + ); + let provider; + await act(async () => (provider = render(tree))); + return provider; +} + +export function getProviderData({ getByTestId }: RenderResult) { + const JSONString = getByTestId('output').textContent; + return JSONString && JSON.parse(JSONString); +} diff --git a/packages/graphiql-2-rfc-context/src/api/types.ts b/packages/graphiql-2-rfc-context/src/api/types.ts new file mode 100644 index 00000000000..64ce1c40a31 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/api/types.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { OperationDefinitionNode } from 'graphql'; + +import { Unsubscribable } from '../types'; + +export type File = { + uri: string; + text?: string; + json?: JSON; + formattedText?: string; +}; + +export type GraphQLParams = { + query: string; + variables?: string; + operationName?: string; +}; + +export type EditorContexts = 'operation' | 'variables' | 'results'; + +export type SessionState = { + sessionId: number; + operation: File; + variables: File; + results: File; + operationLoading: boolean; + operationErrors: Error[] | null; + // diagnostics?: IMarkerData[]; + currentTabs?: { [pane: string]: number }; // maybe this could live in another context for each "pane"? within session context + operations: OperationDefinitionNode[]; + subscription?: Unsubscribable | null; + operationName?: string; // current operation name +}; diff --git a/packages/graphiql-2-rfc-context/src/cdn.ts b/packages/graphiql-2-rfc-context/src/cdn.ts new file mode 100644 index 00000000000..e9024f06e88 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/cdn.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { GraphiQL } from './components/GraphiQL'; +export default GraphiQL; + +if (typeof window !== 'undefined') { + // @ts-ignore + window.GraphiQL = GraphiQL; +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer.tsx new file mode 100644 index 00000000000..c7197763d7f --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer.tsx @@ -0,0 +1,224 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { GraphQLSchema, isType, GraphQLNamedType } from 'graphql'; +import { FieldType } from './DocExplorer/types'; + +import FieldDoc from './DocExplorer/FieldDoc'; +import SchemaDoc from './DocExplorer/SchemaDoc'; +import SearchBox from './DocExplorer/SearchBox'; +import SearchResults from './DocExplorer/SearchResults'; +import TypeDoc from './DocExplorer/TypeDoc'; + +type NavStackItem = { + name: string; + title?: string; + search?: string; + def?: GraphQLNamedType | FieldType; +}; + +const initialNav: NavStackItem = { + name: 'Schema', + title: 'Documentation Explorer', +}; + +type DocExplorerProps = { + schema?: GraphQLSchema | null; +}; + +type DocExplorerState = { + navStack: NavStackItem[]; +}; + +/** + * DocExplorer + * + * Shows documentations for GraphQL definitions from the schema. + * + * Props: + * + * - schema: A required GraphQLSchema instance that provides GraphQL document + * definitions. + * + * Children: + * + * - Any provided children will be positioned in the right-hand-side of the + * top bar. Typically this will be a "close" button for temporary explorer. + * + */ +export class DocExplorer extends React.Component< + DocExplorerProps, + DocExplorerState +> { + // handleClickTypeOrField: OnClickTypeFunction | OnClickFieldFunction + constructor(props: DocExplorerProps) { + super(props); + + this.state = { navStack: [initialNav] }; + } + + shouldComponentUpdate( + nextProps: DocExplorerProps, + nextState: DocExplorerState, + ) { + return ( + this.props.schema !== nextProps.schema || + this.state.navStack !== nextState.navStack + ); + } + + render() { + const { schema } = this.props; + const navStack = this.state.navStack; + const navItem = navStack[navStack.length - 1]; + + let content; + if (schema === undefined) { + // Schema is undefined when it is being loaded via introspection. + content = ( +
+
+
+ ); + } else if (!schema) { + // Schema is null when it explicitly does not exist, typically due to + // an error during introspection. + content =
{'No Schema Available'}
; + } else if (navItem.search) { + content = ( + + ); + } else if (navStack.length === 1) { + content = ( + + ); + } else if (isType(navItem.def)) { + content = ( + + ); + } else { + content = ( + + ); + } + + const shouldSearchBoxAppear = + navStack.length === 1 || + (isType(navItem.def) && 'getFields' in navItem.def); + + let prevName; + if (navStack.length > 1) { + prevName = navStack[navStack.length - 2].name; + } + + return ( +
+
+ {prevName && ( + + )} +
+ {navItem.title || navItem.name} +
+
{this.props.children}
+
+
+ {shouldSearchBoxAppear && ( + + )} + {content} +
+
+ ); + } + + // Public API + showDoc(typeOrField: GraphQLNamedType | FieldType) { + const navStack = this.state.navStack; + const topNav = navStack[navStack.length - 1]; + if (topNav.def !== typeOrField) { + this.setState({ + navStack: navStack.concat([ + { + name: typeOrField.name, + def: typeOrField, + }, + ]), + }); + } + } + + // Public API + showDocForReference(reference: any) { + if (reference && reference.kind === 'Type') { + this.showDoc(reference.type); + } else if (reference.kind === 'Field') { + this.showDoc(reference.field); + } else if (reference.kind === 'Argument' && reference.field) { + this.showDoc(reference.field); + } else if (reference.kind === 'EnumValue' && reference.type) { + this.showDoc(reference.type); + } + } + + // Public API + showSearch(search: string) { + const navStack = this.state.navStack.slice(); + const topNav = navStack[navStack.length - 1]; + navStack[navStack.length - 1] = { ...topNav, search }; + this.setState({ navStack }); + } + + reset() { + this.setState({ navStack: [initialNav] }); + } + + handleNavBackClick = () => { + if (this.state.navStack.length > 1) { + this.setState({ navStack: this.state.navStack.slice(0, -1) }); + } + }; + + handleClickType = (type: GraphQLNamedType) => { + this.showDoc(type); + }; + + handleClickField = (field: FieldType) => { + this.showDoc(field); + }; + + handleSearch = (value: string) => { + this.showSearch(value); + }; +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/Argument.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/Argument.tsx new file mode 100644 index 00000000000..52218658137 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/Argument.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { GraphQLArgument } from 'graphql'; +import TypeLink from './TypeLink'; +import DefaultValue from './DefaultValue'; +import { OnClickTypeFunction } from './types'; + +type ArgumentProps = { + arg: GraphQLArgument; + onClickType: OnClickTypeFunction; + showDefaultValue?: boolean; +}; + +export default function Argument({ + arg, + onClickType, + showDefaultValue, +}: ArgumentProps) { + return ( + + {arg.name} + {': '} + + {showDefaultValue !== false && } + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/DefaultValue.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/DefaultValue.tsx new file mode 100644 index 00000000000..baef39b8776 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/DefaultValue.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { astFromValue, print, ValueNode } from 'graphql'; +import { FieldType } from './types'; + +const printDefault = (ast?: ValueNode | null): string => { + if (!ast) { + return ''; + } + return print(ast); +}; + +type DefaultValueProps = { + field: FieldType; +}; + +export default function DefaultValue({ field }: DefaultValueProps) { + // field.defaultValue could be null or false, so be careful here! + if ('defaultValue' in field && field.defaultValue !== undefined) { + return ( + + {' = '} + + {printDefault(astFromValue(field.defaultValue, field.type))} + + + ); + } + + return null; +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/FieldDoc.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/FieldDoc.tsx new file mode 100644 index 00000000000..e882da5a41b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/FieldDoc.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import Argument from './Argument'; +import MarkdownContent from './MarkdownContent'; +import TypeLink from './TypeLink'; +import { GraphQLArgument } from 'graphql'; +import { OnClickTypeFunction, FieldType } from './types'; + +type FieldDocProps = { + field?: FieldType; + onClickType: OnClickTypeFunction; +}; + +export default function FieldDoc({ field, onClickType }: FieldDocProps) { + let argsDef; + if (field && 'args' in field && field.args.length > 0) { + argsDef = ( +
+
{'arguments'}
+ {field.args.map((arg: GraphQLArgument) => ( +
+
+ +
+ +
+ ))} +
+ ); + } + + return ( +
+ + {field && 'deprecationReason' in field && ( + + )} +
+
{'type'}
+ +
+ {argsDef} +
+ ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/MarkdownContent.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/MarkdownContent.tsx new file mode 100644 index 00000000000..6a4758cc471 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/MarkdownContent.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import MD from 'markdown-it'; +import { Maybe } from '../../types'; + +const md = new MD(); + +type MarkdownContentProps = { + markdown?: Maybe; + className?: string; +}; + +export default function MarkdownContent({ + markdown, + className, +}: MarkdownContentProps) { + if (!markdown) { + return
; + } + + return ( +
+ ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/SchemaDoc.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SchemaDoc.tsx new file mode 100644 index 00000000000..93770bdb40b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SchemaDoc.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import TypeLink from './TypeLink'; +import MarkdownContent from './MarkdownContent'; +import { GraphQLSchema } from 'graphql'; +import { OnClickTypeFunction } from './types'; + +type SchemaDocProps = { + schema: GraphQLSchema; + onClickType: OnClickTypeFunction; +}; + +// Render the top level Schema +export default function SchemaDoc({ schema, onClickType }: SchemaDocProps) { + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType && schema.getMutationType(); + const subscriptionType = + schema.getSubscriptionType && schema.getSubscriptionType(); + + return ( +
+ +
+
{'root types'}
+
+ {'query'} + {': '} + +
+ {mutationType && ( +
+ {'mutation'} + {': '} + +
+ )} + {subscriptionType && ( +
+ {'subscription'} + {': '} + +
+ )} +
+
+ ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchBox.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchBox.tsx new file mode 100644 index 00000000000..429bae16205 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchBox.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { ChangeEventHandler } from 'react'; + +import debounce from './../../utility/debounce'; + +type OnSearchFn = (value: string) => void; + +type SearchBoxProps = { + value?: string; + placeholder: string; + onSearch: OnSearchFn; +}; + +type SearchBoxState = { + value: string; +}; + +export default class SearchBox extends React.Component< + SearchBoxProps, + SearchBoxState +> { + debouncedOnSearch: OnSearchFn; + + constructor(props: SearchBoxProps) { + super(props); + this.state = { value: props.value || '' }; + this.debouncedOnSearch = debounce(200, this.props.onSearch); + } + + render() { + return ( + + ); + } + + handleChange: ChangeEventHandler = event => { + const value = event.currentTarget.value; + this.setState({ value }); + this.debouncedOnSearch(value); + }; + + handleClear = () => { + this.setState({ value: '' }); + this.props.onSearch(''); + }; +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchResults.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchResults.tsx new file mode 100644 index 00000000000..c0e32f6ffac --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/SearchResults.tsx @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { ReactNode } from 'react'; +import { GraphQLSchema, GraphQLNamedType } from 'graphql'; + +import Argument from './Argument'; +import TypeLink from './TypeLink'; +import { OnClickFieldFunction, OnClickTypeFunction } from './types'; + +type SearchResultsProps = { + schema: GraphQLSchema; + withinType?: GraphQLNamedType; + searchValue: string; + onClickType: OnClickTypeFunction; + onClickField: OnClickFieldFunction; +}; + +export default class SearchResults extends React.Component< + SearchResultsProps, + {} +> { + shouldComponentUpdate(nextProps: SearchResultsProps) { + return ( + this.props.schema !== nextProps.schema || + this.props.searchValue !== nextProps.searchValue + ); + } + + render() { + const searchValue = this.props.searchValue; + const withinType = this.props.withinType; + const schema = this.props.schema; + const onClickType = this.props.onClickType; + const onClickField = this.props.onClickField; + + const matchedWithin: ReactNode[] = []; + const matchedTypes: ReactNode[] = []; + const matchedFields: ReactNode[] = []; + + const typeMap = schema.getTypeMap(); + let typeNames = Object.keys(typeMap); + + // Move the within type name to be the first searched. + if (withinType) { + typeNames = typeNames.filter(n => n !== withinType.name); + typeNames.unshift(withinType.name); + } + + for (const typeName of typeNames) { + if ( + matchedWithin.length + matchedTypes.length + matchedFields.length >= + 100 + ) { + break; + } + + const type = typeMap[typeName]; + if (withinType !== type && isMatch(typeName, searchValue)) { + matchedTypes.push( +
+ +
, + ); + } + + if ('getFields' in type) { + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + let matchingArgs; + + if (!isMatch(fieldName, searchValue)) { + if ('args' in field && field.args.length) { + matchingArgs = field.args.filter(arg => + isMatch(arg.name, searchValue), + ); + if (matchingArgs.length === 0) { + return; + } + } else { + return; + } + } + + const match = ( +
+ {withinType !== type && [ + , + '.', + ]} + onClickField(field, type, event)}> + {field.name} + + {matchingArgs && [ + '(', + + {matchingArgs.map(arg => ( + + ))} + , + ')', + ]} +
+ ); + + if (withinType === type) { + matchedWithin.push(match); + } else { + matchedFields.push(match); + } + }); + } + } + + if ( + matchedWithin.length + matchedTypes.length + matchedFields.length === + 0 + ) { + return {'No results found.'}; + } + + if (withinType && matchedTypes.length + matchedFields.length > 0) { + return ( +
+ {matchedWithin} +
+
{'other results'}
+ {matchedTypes} + {matchedFields} +
+
+ ); + } + + return ( +
+ {matchedWithin} + {matchedTypes} + {matchedFields} +
+ ); + } +} + +function isMatch(sourceText: string, searchValue: string) { + try { + const escaped = searchValue.replace(/[^_0-9A-Za-z]/g, ch => '\\' + ch); + return sourceText.search(new RegExp(escaped, 'i')) !== -1; + } catch (e) { + return sourceText.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1; + } +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeDoc.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeDoc.tsx new file mode 100644 index 00000000000..642d07a0d1b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeDoc.tsx @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { ReactNode } from 'react'; +import { + GraphQLSchema, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLType, + GraphQLEnumValue, +} from 'graphql'; + +import Argument from './Argument'; +import MarkdownContent from './MarkdownContent'; +import TypeLink from './TypeLink'; +import DefaultValue from './DefaultValue'; +import { FieldType, OnClickTypeFunction, OnClickFieldFunction } from './types'; + +type TypeDocProps = { + schema: GraphQLSchema; + type: GraphQLType; + onClickType: OnClickTypeFunction; + onClickField: OnClickFieldFunction; +}; + +type TypeDocState = { + showDeprecated: boolean; +}; + +export default class TypeDoc extends React.Component< + TypeDocProps, + TypeDocState +> { + constructor(props: TypeDocProps) { + super(props); + this.state = { showDeprecated: false }; + } + + shouldComponentUpdate(nextProps: TypeDocProps, nextState: TypeDocState) { + return ( + this.props.type !== nextProps.type || + this.props.schema !== nextProps.schema || + this.state.showDeprecated !== nextState.showDeprecated + ); + } + + render() { + const schema = this.props.schema; + const type = this.props.type; + const onClickType = this.props.onClickType; + const onClickField = this.props.onClickField; + + let typesTitle: string | null = null; + let types: readonly (GraphQLObjectType | GraphQLInterfaceType)[] = []; + if (type instanceof GraphQLUnionType) { + typesTitle = 'possible types'; + types = schema.getPossibleTypes(type); + } else if (type instanceof GraphQLInterfaceType) { + typesTitle = 'implementations'; + types = schema.getPossibleTypes(type); + } else if (type instanceof GraphQLObjectType) { + typesTitle = 'implements'; + types = type.getInterfaces(); + } + + let typesDef; + if (types && types.length > 0) { + typesDef = ( +
+
{typesTitle}
+ {types.map(subtype => ( +
+ +
+ ))} +
+ ); + } + + // InputObject and Object + let fieldsDef; + let deprecatedFieldsDef; + if ('getFields' in type) { + const fieldMap = type.getFields(); + const fields = Object.keys(fieldMap).map(name => fieldMap[name]); + fieldsDef = ( +
+
{'fields'}
+ {fields + .filter(field => + 'isDeprecated' in field ? !field.isDeprecated : true, + ) + .map(field => ( + + ))} +
+ ); + + const deprecatedFields = fields.filter(field => + 'isDeprecated' in field ? field.isDeprecated : true, + ); + if (deprecatedFields.length > 0) { + deprecatedFieldsDef = ( +
+
{'deprecated fields'}
+ {!this.state.showDeprecated ? ( + + ) : ( + deprecatedFields.map(field => ( + + )) + )} +
+ ); + } + } + + let valuesDef: ReactNode; + let deprecatedValuesDef: ReactNode; + if (type instanceof GraphQLEnumType) { + const values = type.getValues(); + valuesDef = ( +
+
{'values'}
+ {values + .filter(value => !value.isDeprecated) + .map(value => ( + + ))} +
+ ); + + const deprecatedValues = values.filter(value => value.isDeprecated); + if (deprecatedValues.length > 0) { + deprecatedValuesDef = ( +
+
{'deprecated values'}
+ {!this.state.showDeprecated ? ( + + ) : ( + deprecatedValues.map(value => ( + + )) + )} +
+ ); + } + } + + return ( +
+ + {type instanceof GraphQLObjectType && typesDef} + {fieldsDef} + {deprecatedFieldsDef} + {valuesDef} + {deprecatedValuesDef} + {!(type instanceof GraphQLObjectType) && typesDef} +
+ ); + } + + handleShowDeprecated = () => this.setState({ showDeprecated: true }); +} + +type FieldProps = { + type: GraphQLType; + field: FieldType; + onClickType: OnClickTypeFunction; + onClickField: OnClickFieldFunction; +}; + +function Field({ type, field, onClickType, onClickField }: FieldProps) { + return ( +
+ onClickField(field, type, event)}> + {field.name} + + {'args' in field && + field.args && + field.args.length > 0 && [ + '(', + + {field.args.map(arg => ( + + ))} + , + ')', + ]} + {': '} + + + {field.description && ( + + )} + {'deprecationReason' in field && field.deprecationReason && ( + + )} +
+ ); +} + +type EnumValue = { + value: GraphQLEnumValue; +}; + +function EnumValue({ value }: EnumValue) { + return ( +
+
{value.name}
+ + {value.deprecationReason && ( + + )} +
+ ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeLink.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeLink.tsx new file mode 100644 index 00000000000..b4a40037f72 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/TypeLink.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { + GraphQLList, + GraphQLNonNull, + GraphQLType, + GraphQLNamedType, +} from 'graphql'; +import { OnClickTypeFunction } from './types'; + +import { Maybe } from '../../types'; + +type TypeLinkProps = { + type?: Maybe; + onClick?: OnClickTypeFunction; +}; + +export default function TypeLink(props: TypeLinkProps) { + const onClick = props.onClick ? props.onClick : () => null; + return renderType(props.type, onClick); +} + +function renderType(type: Maybe, onClick: OnClickTypeFunction) { + if (type instanceof GraphQLNonNull) { + return ( + + {renderType(type.ofType, onClick)} + {'!'} + + ); + } + if (type instanceof GraphQLList) { + return ( + + {'['} + {renderType(type.ofType, onClick)} + {']'} + + ); + } + return ( + { + event.preventDefault(); + onClick(type as GraphQLNamedType, event); + }} + href="#"> + {type?.name} + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/ExampleSchema.ts b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/ExampleSchema.ts new file mode 100644 index 00000000000..ba887be37a2 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/ExampleSchema.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + GraphQLObjectType, + GraphQLString, + GraphQLSchema, + GraphQLUnionType, + GraphQLInterfaceType, + GraphQLBoolean, + GraphQLEnumType, +} from 'graphql'; + +export const ExampleInterface = new GraphQLInterfaceType({ + name: 'exampleInterface', + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + }, +}); + +export const ExampleEnum = new GraphQLEnumType({ + name: 'exampleEnum', + values: { + value1: { value: 'Value 1' }, + value2: { value: 'Value 2' }, + value3: { value: 'Value 3', deprecationReason: 'Only two are needed' }, + }, +}); + +export const ExampleUnionType1 = new GraphQLObjectType({ + name: 'Union Type 1', + interfaces: [ExampleInterface], + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + enum: { + name: 'enumField', + type: ExampleEnum, + }, + }, +}); + +export const ExampleUnionType2 = new GraphQLObjectType({ + name: 'Union Type 2', + interfaces: [ExampleInterface], + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + string: { + name: 'stringField', + type: GraphQLString, + }, + }, +}); + +export const ExampleUnion = new GraphQLUnionType({ + name: 'exampleUnion', + types: [ExampleUnionType1, ExampleUnionType2], +}); + +export const ExampleQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + string: { + name: 'exampleString', + type: GraphQLString, + }, + union: { + name: 'exampleUnion', + type: ExampleUnion, + }, + fieldWithArgs: { + name: 'exampleWithArgs', + type: GraphQLString, + args: { + stringArg: { + name: 'exampleStringArg', + type: GraphQLString, + }, + }, + }, + deprecatedField: { + name: 'booleanField', + type: GraphQLBoolean, + deprecationReason: 'example deprecation reason', + }, + }, +}); + +export const ExampleSchema = new GraphQLSchema({ + query: ExampleQuery, +}); diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/FieldDoc.spec.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/FieldDoc.spec.tsx new file mode 100644 index 00000000000..2e4d49cf4ab --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/FieldDoc.spec.tsx @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import FieldDoc from '../FieldDoc'; + +import { GraphQLString, GraphQLObjectType } from 'graphql'; + +const exampleObject = new GraphQLObjectType({ + name: 'Query', + fields: { + string: { + name: 'simpleStringField', + type: GraphQLString, + }, + stringWithArgs: { + name: 'stringWithArgs', + type: GraphQLString, + description: 'Example String field with arguments', + args: { + stringArg: { + name: 'stringArg', + type: GraphQLString, + }, + }, + }, + }, +}); + +describe('FieldDoc', () => { + it('should render a simple string field', () => { + const { container } = render( + , + ); + expect(container.querySelector('.doc-type-description')).toHaveTextContent( + 'No Description', + ); + expect(container.querySelector('.type-name')).toHaveTextContent('String'); + expect(container.querySelector('.arg')).not.toBeInTheDocument(); + }); + + it('should re-render on field change', () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector('.doc-type-description')).toHaveTextContent( + 'No Description', + ); + expect(container.querySelector('.type-name')).toHaveTextContent('String'); + expect(container.querySelector('.arg')).not.toBeInTheDocument(); + + rerender( + , + ); + expect(container.querySelector('.type-name')).toHaveTextContent('String'); + expect(container.querySelector('.doc-type-description')).toHaveTextContent( + 'Example String field with arguments', + ); + }); + + it('should render a string field with arguments', () => { + const { container } = render( + , + ); + expect(container.querySelector('.type-name')).toHaveTextContent('String'); + expect(container.querySelector('.doc-type-description')).toHaveTextContent( + 'Example String field with arguments', + ); + expect(container.querySelectorAll('.arg')).toHaveLength(1); + expect(container.querySelector('.arg')).toHaveTextContent( + 'stringArg: String', + ); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeDoc.spec.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeDoc.spec.tsx new file mode 100644 index 00000000000..b8dd9edfd32 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeDoc.spec.tsx @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { GraphQLString } from 'graphql'; + +import TypeDoc from '../TypeDoc'; + +import { + ExampleSchema, + ExampleQuery, + ExampleUnion, + ExampleEnum, +} from './ExampleSchema'; + +describe('TypeDoc', () => { + it('renders a top-level query object type', () => { + const { container } = render( + // @ts-ignore + , + ); + const cats = container.querySelectorAll('.doc-category-item'); + expect(cats[0]).toHaveTextContent('string: String'); + expect(cats[1]).toHaveTextContent('union: exampleUnion'); + expect(cats[2]).toHaveTextContent( + 'fieldWithArgs(stringArg: String): String', + ); + }); + + it('handles onClickField and onClickType', () => { + const onClickType = jest.fn(); + const onClickField = jest.fn(); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('.type-name')!); + expect(onClickType.mock.calls.length).toEqual(1); + expect(onClickType.mock.calls[0][0]).toEqual(GraphQLString); + + fireEvent.click(container.querySelector('.field-name')!); + expect(onClickField.mock.calls.length).toEqual(1); + expect(onClickField.mock.calls[0][0].name).toEqual('string'); + expect(onClickField.mock.calls[0][0].type).toEqual(GraphQLString); + expect(onClickField.mock.calls[0][1]).toEqual(ExampleQuery); + }); + + it('renders deprecated fields when you click to see them', () => { + const { container } = render( + // @ts-ignore + , + ); + let cats = container.querySelectorAll('.doc-category-item'); + expect(cats).toHaveLength(3); + + fireEvent.click(container.querySelector('.show-btn')!); + + cats = container.querySelectorAll('.doc-category-item'); + expect(cats).toHaveLength(4); + expect(container.querySelectorAll('.field-name')[3]).toHaveTextContent( + 'deprecatedField', + ); + expect(container.querySelector('.doc-deprecation')).toHaveTextContent( + 'example deprecation reason', + ); + }); + + it('renders a Union type', () => { + const { container } = render( + // @ts-ignore + , + ); + expect(container.querySelector('.doc-category-title')).toHaveTextContent( + 'possible types', + ); + }); + + it('renders an Enum type', () => { + const { container } = render( + // @ts-ignore + , + ); + expect(container.querySelector('.doc-category-title')).toHaveTextContent( + 'values', + ); + const enums = container.querySelectorAll('.enum-value'); + expect(enums[0]).toHaveTextContent('value1'); + expect(enums[1]).toHaveTextContent('value2'); + }); + + it('shows deprecated enum values on click', () => { + const { getByText, container } = render( + // @ts-ignore + , + ); + const showBtn = getByText('Show deprecated values...'); + expect(showBtn).toBeInTheDocument(); + const titles = container.querySelectorAll('.doc-category-title'); + expect(titles[0]).toHaveTextContent('values'); + expect(titles[1]).toHaveTextContent('deprecated values'); + let enums = container.querySelectorAll('.enum-value'); + expect(enums).toHaveLength(2); + + // click button to show deprecated enum values + fireEvent.click(showBtn); + expect(showBtn).not.toBeInTheDocument(); + enums = container.querySelectorAll('.enum-value'); + expect(enums).toHaveLength(3); + expect(enums[2]).toHaveTextContent('value3'); + expect(container.querySelector('.doc-deprecation')).toHaveTextContent( + 'Only two are needed', + ); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeLink.spec.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeLink.spec.tsx new file mode 100644 index 00000000000..e01e8ad1982 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/TypeLink.spec.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import TypeLink from '../TypeLink'; + +import { GraphQLNonNull, GraphQLList, GraphQLString } from 'graphql'; + +const nonNullType = new GraphQLNonNull(GraphQLString); +const listType = new GraphQLList(GraphQLString); + +describe('TypeLink', () => { + it('should render a string', () => { + const { container } = render(); + expect(container).toHaveTextContent('String'); + expect(container.querySelectorAll('a')).toHaveLength(1); + expect(container.querySelector('a')).toHaveClass('type-name'); + }); + it('should render a nonnull type', () => { + const { container } = render(); + expect(container).toHaveTextContent('String!'); + expect(container.querySelectorAll('span')).toHaveLength(1); + }); + it('should render a list type', () => { + const { container } = render(); + expect(container).toHaveTextContent('[String]'); + expect(container.querySelectorAll('span')).toHaveLength(1); + }); + it('should handle a click event', () => { + const op = jest.fn(); + const { container } = render(); + fireEvent.click(container.querySelector('a')!); + expect(op.mock.calls.length).toEqual(1); + expect(op.mock.calls[0][0]).toEqual(GraphQLString); + }); + it('should re-render on type change', () => { + const { container, rerender } = render(); + expect(container).toHaveTextContent('[String]'); + rerender(); + expect(container).toHaveTextContent('String'); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/index.tsx b/packages/graphiql-2-rfc-context/src/components/DocExplorer/__tests__/index.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/graphiql-2-rfc-context/src/components/DocExplorer/types.ts b/packages/graphiql-2-rfc-context/src/components/DocExplorer/types.ts new file mode 100644 index 00000000000..a31c14e42d4 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/DocExplorer/types.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { MouseEvent } from 'react'; +import { + GraphQLField, + GraphQLInputField, + GraphQLArgument, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLInputObjectType, + GraphQLType, + GraphQLNamedType, +} from 'graphql'; + +export type FieldType = + | GraphQLField<{}, {}, {}> + | GraphQLInputField + | GraphQLArgument; + +export type OnClickFieldFunction = ( + field: FieldType, + type?: + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLInputObjectType + | GraphQLType, + event?: MouseEvent, +) => void; + +export type OnClickTypeFunction = ( + type: GraphQLNamedType, + event?: MouseEvent, +) => void; + +export type OnClickFieldOrTypeFunction = + | OnClickFieldFunction + | OnClickTypeFunction; diff --git a/packages/graphiql-2-rfc-context/src/components/ExecuteButton.tsx b/packages/graphiql-2-rfc-context/src/components/ExecuteButton.tsx new file mode 100644 index 00000000000..eac0dc603f5 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ExecuteButton.tsx @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { MouseEventHandler, useState } from 'react'; +import { OperationDefinitionNode } from 'graphql'; +import { useSessionContext } from '../api/providers/GraphiQLSessionProvider'; +import useQueryFacts from '../api/hooks/useQueryFacts'; +import { useTranslation } from 'react-i18next'; + +/** + * ExecuteButton + * + * What a nice round shiny button. Shows a drop-down when there are multiple + * queries to run. + */ + +type ExecuteButtonProps = { + isRunning: boolean; + onStop: () => void; +}; + +export function ExecuteButton(props: ExecuteButtonProps) { + const [optionsOpen, setOptionsOpen] = useState(false); + const queryFacts = useQueryFacts(); + const [highlight, setHighlight] = useState( + null, + ); + const session = useSessionContext(); + const operations = queryFacts?.operations ?? []; + const hasOptions = operations && operations.length > 1; + const { t } = useTranslation('Toolbar'); + + let options: JSX.Element | null = null; + if (hasOptions && optionsOpen) { + options = ( +
    + {operations.map((operation, i) => { + const opName = operation.name + ? operation.name.value + : ``; + return ( +
  • setHighlight(operation)} + onMouseOut={() => setHighlight(null)} + onMouseUp={() => { + setOptionsOpen(false); + session.executeOperation(operation?.name?.value); + }}> + {opName} +
  • + ); + })} +
+ ); + } + + const onClick = () => { + // Allow click event if there is a running query or if there are not options + // for which operation to run. + if (props.isRunning || !hasOptions) { + if (props.isRunning) { + props.onStop(); + } else { + session.executeOperation(); + } + } + }; + + const onMouseDown: MouseEventHandler = downEvent => { + // Allow mouse down if there is no running query, there are options for + // which operation to run, and the dropdown is currently closed. + if (!props.isRunning && hasOptions && !optionsOpen) { + let initialPress = true; + const downTarget = downEvent.currentTarget; + setHighlight(null); + setOptionsOpen(true); + + type MouseUpEventHandler = (upEvent: MouseEvent) => void; + let onMouseUp: MouseUpEventHandler | null = upEvent => { + if (initialPress && upEvent.target === downTarget) { + initialPress = false; + } else { + document.removeEventListener('mouseup', onMouseUp!); + onMouseUp = null; + const isOptionsMenuClicked = + upEvent.currentTarget && + downTarget.parentNode?.compareDocumentPosition( + upEvent.currentTarget as Node, + ) && + Node.DOCUMENT_POSITION_CONTAINED_BY; + if (!isOptionsMenuClicked) { + // menu calls setState if it was clicked + setOptionsOpen(false); + } + } + }; + + document.addEventListener('mouseup', onMouseUp); + } + }; + + const pathJSX = props.isRunning ? ( + + ) : ( + + ); + + return ( +
+ + {options} +
+ ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/GraphiQL.tsx b/packages/graphiql-2-rfc-context/src/components/GraphiQL.tsx new file mode 100644 index 00000000000..bec1c11ae2f --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/GraphiQL.tsx @@ -0,0 +1,796 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { ComponentType, PropsWithChildren } from 'react'; +import { GraphQLSchema, OperationDefinitionNode, GraphQLType } from 'graphql'; + +import type { SchemaConfig } from 'graphql-language-service'; + +import { ExecuteButton } from './ExecuteButton'; +import { ToolbarButton } from './ToolbarButton'; +import { QueryEditor } from './QueryEditor'; +import { VariableEditor } from './VariableEditor'; +import { ResultViewer } from './ResultViewer'; +import { DocExplorer } from './DocExplorer'; +import { QueryHistory } from './QueryHistory'; +import StorageAPI, { Storage } from '../utility/StorageAPI'; +import { VariableToType } from '../utility/getQueryFacts'; + +import find from '../utility/find'; +import { GetDefaultFieldNamesFn, fillLeafs } from '../utility/fillLeafs'; + +import { + SchemaProvider, + SchemaContext, +} from '../api/providers/GraphiQLSchemaProvider'; +import { EditorsProvider } from '../api/providers/GraphiQLEditorsProvider'; +import { + SessionProvider, + SessionContext, +} from '../api/providers/GraphiQLSessionProvider'; +import { getFetcher } from '../api/common'; + +import { Unsubscribable, Fetcher, ReactNodeLike } from '../types'; +import { Provider, useThemeLayout } from './common/themes/provider'; +import Tabs from './common/Toolbar/Tabs'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../i18n'; + +const majorVersion = parseInt(React.version.slice(0, 2), 10); + +if (majorVersion < 16) { + throw Error( + [ + 'GraphiQL 0.18.0 and after is not compatible with React 15 or below.', + 'If you are using a CDN source (jsdelivr, unpkg, etc), follow this example:', + 'https://github.com/graphql/graphiql/blob/main/examples/graphiql-cdn/index.html#L49', + ].join('\n'), + ); +} + +declare namespace global { + export let g: GraphiQLInternals; +} + +type Formatters = { + formatResult: (result: any) => string; + formatError: (rawError: Error) => string; +}; + +export type Maybe = T | null | undefined; + +export type GraphiQLProps = { + uri: string; + fetcher?: Fetcher; + schemaConfig?: SchemaConfig; + schema: GraphQLSchema | null; + query?: string; + variables?: string; + headers?: string; + operationName?: string; + response?: string; + storage?: Storage; + defaultQuery?: string; + defaultVariableEditorOpen?: boolean; + defaultSecondaryEditorOpen?: boolean; + headerEditorEnabled?: boolean; + onCopyQuery?: (query?: string) => void; + onEditQuery?: (query?: string) => void; + onEditVariables?: (value: string) => void; + onEditHeaders?: (value: string) => void; + onEditOperationName?: (operationName: string) => void; + onToggleDocs?: (docExplorerOpen: boolean) => void; + getDefaultFieldNames?: GetDefaultFieldNamesFn; + editorTheme?: string; + onToggleHistory?: (historyPaneOpen: boolean) => void; + readOnly?: boolean; + docExplorerOpen?: boolean; + formatResult?: (result: any) => string; + formatError?: (rawError: Error) => string; + variablesEditorOptions?: monaco.editor.IStandaloneEditorConstructionOptions; + operationEditorOptions?: monaco.editor.IStandaloneEditorConstructionOptions; + resultsEditorOptions?: monaco.editor.IStandaloneEditorConstructionOptions; +} & Partial; + +export type GraphiQLState = { + schema?: GraphQLSchema; + query?: string; + variables?: string; + headers?: string; + operationName?: string; + response?: string; + isWaitingForResponse: boolean; + subscription?: Unsubscribable | null; + variableToType?: VariableToType; + operations?: OperationDefinitionNode[]; +}; + +/** + * The top-level React component for GraphiQL, intended to encompass the entire + * browser viewport. + * + * @see https://github.com/graphql/graphiql#usage + */ +export const GraphiQL: React.FC = props => { + if (!props.fetcher && !props.uri) { + throw Error(i18n.t('Errors:Fetcher or uri property are required')); + } + const fetcher = getFetcher(props); + return ( + + + + + + {props.children} + + + + + + ); +}; + +const formatResult = (result: any) => { + return JSON.stringify(result, null, 2); +}; + +const formatError = (rawError: Error) => { + const result = Array.isArray(rawError) + ? rawError.map(formatSingleError) + : formatSingleError(rawError); + return JSON.stringify(result, null, 2); +}; + +// Add a select-option input to the Toolbar. +// GraphiQLSelect = ToolbarSelect; +// GraphiQLSelectOption = ToolbarSelectOption; + +type GraphiQLInternalsProps = GraphiQLProps & Formatters; + +/** + * GraphiQL implementation details, intended to only be used via + * the GraphiQL component + */ +class GraphiQLInternals extends React.Component< + GraphiQLProps & Formatters, + GraphiQLState +> { + // Ensure only the last executed editor query is rendered. + _editorQueryID = 0; + _storage: StorageAPI; + // refs + graphiqlContainer: Maybe; + resultComponent: Maybe; + variableEditorComponent: Maybe; + _queryHistory: Maybe; + editorBarComponent: Maybe; + queryEditorComponent: Maybe; + resultViewerElement: Maybe; + + constructor(props: GraphiQLInternalsProps & Formatters) { + super(props); + // Ensure props are correct + if (typeof props.fetcher !== 'function') { + throw new TypeError('GraphiQL requires a fetcher function.'); + } + + // Cache the storage instance + this._storage = new StorageAPI(props.storage); + + // Initialize state + this.state = { + response: props.response, + isWaitingForResponse: false, + subscription: null, + }; + } + + componentDidMount() { + // Allow async state changes + + // Only fetch schema via introspection if a schema has not been + // provided, including if `null` was provided. + // if (this.context.schema === undefined) { + // this.fetchSchema(); + // } + // Utility for keeping CodeMirror correctly sized. + + global.g = this; + } + // When the component is about to unmount, store any persistable state, such + // that when the component is remounted, it will use the last used values. + + render() { + // eslint-disable-next-line react-hooks/rules-of-hooks + const Layout = useThemeLayout(); + const children = React.Children.toArray(this.props.children); + const logo = find(children, child => + isChildComponentType(child, GraphiQLLogo), + ) || ; + + const footer = find(children, child => + isChildComponentType(child, GraphiQLFooter), + ); + + // const queryWrapStyle = { + // WebkitFlex: this.state.editorFlex, + // flex: this.state.editorFlex, + // }; + + // const variableOpen = this.state.variableEditorOpen; + // const variableStyle = { + // height: variableOpen ? this.state.variableEditorHeight : undefined, + // }; + + const SessionTabs = ({ + name, + tabs, + children: c, + }: { + name: string; + tabs: Array; + children: Array; + }) => ( + + {session => ( + session.changeTab(name, tabId)}> + {c} + + )} + + ); + + const operationEditor = ( + //
{ + // this.editorBarComponent = n; + // }} + // className="editorBar" + // onDoubleClick={this.handleResetResize} + // onMouseDown={this.handleResizeStart}> + //
+
+ + +
{`Explorer`}
+
+
+ ); + + const variables = ( +
+ + +
{`Console`}
+
+
+ ); + + const response = ( +
+ + <> + {this.state.isWaitingForResponse && ( +
+
+
+ )} + + {footer} + +
{`Extensions`}
+
{`Playground`}
+ +
+ ); + + return ( + + + + {logo} + null} + /> + + + + null} + title="Show History" + label="History" + /> + null} + title="Open Documentation Explorer" + label="Docs" + /> + + + } + session={{ + input: operationEditor, + response, + console: variables, + }} + navPanels={[ + // TODO: rewrite this for plugin API + ...(true + ? [ + { + key: 'docs', + size: 'sidebar' as const, + component: ( + + {({ schema }) => } + + ), + }, + ] + : []), + // TODO: rewrite this for plugin API + + ...(true + ? [ + { + key: 'history', + size: 'sidebar' as const, + component: ( + + {session => { + return ( + { + if (operation) { + session.changeOperation(operation); + } + if (vars) { + session.changeVariables(vars); + } + }} + storage={this._storage} + queryID={this._editorQueryID}> + + + ); + }} + + ), + }, + ] + : []), + ]} + /> + + ); + } + + /** + * Inspect the query, automatically filling in selection sets for non-leaf + * fields which do not yet have them. + * + * @public + */ + public autoCompleteLeafs() { + const { insertions, result } = fillLeafs( + this.context.schema, + this.context?.operation.text, + this.props.getDefaultFieldNames, + ); + if (insertions && insertions.length > 0) { + // @ts-ignore + const editor = this.getQueryEditor(); + if (editor) { + editor.operation(() => { + const cursor = editor.getCursor(); + const cursorIndex = editor.indexFromPos(cursor); + editor.setValue(result || ''); + let added = 0; + const markers = insertions.map(({ index, string }) => + editor.markText( + editor.posFromIndex(index + added), + editor.posFromIndex(index + (added += string.length)), + { + className: 'autoInsertedLeaf', + clearOnEnter: true, + title: 'Automatically added leaf fields', + }, + ), + ); + setTimeout(() => markers.forEach(marker => marker.clear()), 7000); + let newCursorIndex = cursorIndex; + insertions.forEach(({ index, string }) => { + if (index < cursorIndex) { + newCursorIndex += string.length; + } + }); + editor.setCursor(editor.posFromIndex(newCursorIndex)); + }); + } + } + return result; + } + + handleClickReference = (_reference: GraphQLType) => { + // this.setState({ docExplorerOpen: true }, () => { + // if (this.docExplorerComponent) { + // this.docExplorerComponent.showDocForReference(reference); + // } + // }); + }; + + // handleStopQuery = () => { + // const subscription = this.state.subscription; + // this.setState({ + // isWaitingForResponse: false, + // subscription: null, + // }); + // if (subscription) { + // subscription.unsubscribe(); + // } + // }; + + handlePrettifyQuery = () => { + // const editor = this.getQueryEditor(); + // const editorContent = editor?.getValue() ?? ''; + // const prettifiedEditorContent = print(parse(editorContent)); + // if (prettifiedEditorContent !== editorContent) { + // editor?.setValue(prettifiedEditorContent); + // } + // const variableEditor = this.getVariableEditor(); + // const variableEditorContent = variableEditor?.getValue() ?? ''; + // try { + // const prettifiedVariableEditorContent = JSON.stringify( + // JSON.parse(variableEditorContent), + // null, + // 2, + // ); + // if (prettifiedVariableEditorContent !== variableEditorContent) { + // variableEditor?.setValue(prettifiedVariableEditorContent); + // } + // } catch { + // /* Parsing JSON failed, skip prettification */ + // } + }; + + handleMergeQuery = () => { + // const editor = this.getQueryEditor() as CodeMirror.Editor; + // const query = editor.getValue(); + // if (!query) { + // return; + // } + // const ast = parse(query); + // editor.setValue(print(mergeAST(ast))); + }; + handleCopyQuery = () => { + // const editor = this.getQueryEditor(); + // const query = editor && editor.getValue(); + // if (!query) { + // return; + // } + // copyToClipboard(query); + // if (this.props.onCopyQuery) { + // return this.props.onCopyQuery(query); + // } + }; + handleEditOperationName = (operationName: string) => { + const onEditOperationName = this.props.onEditOperationName; + if (onEditOperationName) { + onEditOperationName(operationName); + } + }; + + handleHintInformationRender = (elem: HTMLDivElement) => { + elem.addEventListener('click', this._onClickHintInformation); + + let onRemoveFn: EventListener; + elem.addEventListener( + 'DOMNodeRemoved', + (onRemoveFn = () => { + elem.removeEventListener('DOMNodeRemoved', onRemoveFn); + elem.removeEventListener('click', this._onClickHintInformation); + }), + ); + }; + + private _onClickHintInformation = ( + event: MouseEvent | React.MouseEvent, + ) => { + if ( + event?.currentTarget && + 'className' in event.currentTarget && + event.currentTarget.className === 'typeName' + ) { + const typeName = event.currentTarget.innerHTML; + const schema = this.context.schema; + if (schema) { + const type = schema.getType(typeName); + if (type) { + // this.setState({ docExplorerOpen: true }, () => { + // if (this.docExplorerComponent) { + // this.docExplorerComponent.showDoc(type); + // } + // }); + } + } + } + }; + + // handleToggleDocs = () => { + // if (typeof this.props.onToggleDocs === 'function') { + // this.props.onToggleDocs(!this.state.docExplorerOpen); + // } + // this.setState({ docExplorerOpen: !this.state.docExplorerOpen }); + // this._storage.set( + // 'docExplorerOpen', + // JSON.stringify(this.state.docExplorerOpen), + // ); + // }; + + // handleToggleHistory = () => { + // if (typeof this.props.onToggleHistory === 'function') { + // this.props.onToggleHistory(!this.state.historyPaneOpen); + // } + // this.setState({ historyPaneOpen: !this.state.historyPaneOpen }); + // this._storage.set( + // 'historyPaneOpen', + // JSON.stringify(this.state.historyPaneOpen), + // ); + // }; + + // private handleResizeStart = (downEvent: React.MouseEvent) => { + // if (!this._didClickDragBar(downEvent)) { + // return; + // } + + // downEvent.preventDefault(); + + // const offset = downEvent.clientX - getLeft(downEvent.target as HTMLElement); + + // let onMouseMove: OnMouseMoveFn = moveEvent => { + // if (moveEvent.buttons === 0) { + // return onMouseUp!(); + // } + + // const editorBar = this.editorBarComponent as HTMLElement; + // const leftSize = moveEvent.clientX - getLeft(editorBar) - offset; + // const rightSize = editorBar.clientWidth - leftSize; + // this.setState({ editorFlex: leftSize / rightSize }); + // }; + + // let onMouseUp: OnMouseUpFn = () => { + // document.removeEventListener('mousemove', onMouseMove!); + // document.removeEventListener('mouseup', onMouseUp!); + // onMouseMove = null; + // onMouseUp = null; + // }; + + // document.addEventListener('mousemove', onMouseMove); + // document.addEventListener('mouseup', onMouseUp); + // }; + + // handleResetResize = () => { + // this.setState({ editorFlex: 1 }); + // this._storage.set('editorFlex', JSON.stringify(this.state.editorFlex)); + // }; + + // private _didClickDragBar(event: React.MouseEvent) { + // // Only for primary unmodified clicks + // if (event.button !== 0 || event.ctrlKey) { + // return false; + // } + // let target = event.target as Element; + // // Specifically the result window's drag bar. + // const resultWindow = this.resultViewerElement; + // while (target) { + // if (target === resultWindow) { + // return true; + // } + // target = target.parentNode as Element; + // } + // return false; + // } + + // private handleDocsResizeStart: MouseEventHandler< + // HTMLDivElement + // > = downEvent => { + // downEvent.preventDefault(); + + // const hadWidth = this.state.docExplorerWidth; + // const offset = downEvent.clientX - getLeft(downEvent.target as HTMLElement); + + // let onMouseMove: OnMouseMoveFn = moveEvent => { + // if (moveEvent.buttons === 0) { + // return onMouseUp!(); + // } + + // const app = this.graphiqlContainer as HTMLElement; + // const cursorPos = moveEvent.clientX - getLeft(app) - offset; + // const docsSize = app.clientWidth - cursorPos; + + // if (docsSize < 100) { + // this.setState({ docExplorerOpen: false }); + // } else { + // this.setState({ + // docExplorerOpen: true, + // docExplorerWidth: Math.min(docsSize, 650), + // }); + // } + // }; + + // let onMouseUp: OnMouseUpFn = () => { + // if (!this.state.docExplorerOpen) { + // this.setState({ docExplorerWidth: hadWidth }); + // } + + // document.removeEventListener('mousemove', onMouseMove!); + // document.removeEventListener('mouseup', onMouseUp!); + // onMouseMove = null; + // onMouseUp = null; + // }; + + // document.addEventListener('mousemove', onMouseMove!); + // document.addEventListener('mouseup', onMouseUp); + // }; + + // private handleDocsResetResize = () => { + // this.setState({ + // docExplorerWidth: DEFAULT_DOC_EXPLORER_WIDTH, + // }); + // }; + + // private handleVariableResizeStart: MouseEventHandler< + // HTMLDivElement + // > = downEvent => { + // downEvent.preventDefault(); + + // let didMove = false; + // const wasOpen = this.state.variableEditorOpen; + // const hadHeight = this.state.variableEditorHeight; + // const offset = downEvent.clientY - getTop(downEvent.target as HTMLElement); + + // let onMouseMove: OnMouseMoveFn = moveEvent => { + // if (moveEvent.buttons === 0) { + // return onMouseUp!(); + // } + + // didMove = true; + + // const editorBar = this.editorBarComponent as HTMLElement; + // const topSize = moveEvent.clientY - getTop(editorBar) - offset; + // const bottomSize = editorBar.clientHeight - topSize; + // if (bottomSize < 60) { + // this.setState({ + // variableEditorOpen: false, + // variableEditorHeight: hadHeight, + // }); + // } else { + // this.setState({ + // variableEditorOpen: true, + // variableEditorHeight: bottomSize, + // }); + // } + // }; + + // let onMouseUp: OnMouseUpFn = () => { + // if (!didMove) { + // this.setState({ variableEditorOpen: !wasOpen }); + // } + + // document.removeEventListener('mousemove', onMouseMove!); + // document.removeEventListener('mouseup', onMouseUp!); + // onMouseMove = null; + // onMouseUp = null; + // }; + + // document.addEventListener('mousemove', onMouseMove); + // document.addEventListener('mouseup', onMouseUp); + // }; +} + +// // Configure the UI by providing this Component as a child of GraphiQL +function GraphiQLLogo(props: PropsWithChildren) { + return ( +
+ {props.children || ( + + {'Graph'} + {'i'} + {'QL'} + + )} +
+ ); +} +GraphiQLLogo.displayName = 'GraphiQLLogo'; + +// Configure the UI by providing this Component as a child of GraphiQL +function GraphiQLToolbar(props: PropsWithChildren) { + return ( +
+ {props.children} +
+ ); +} +GraphiQLToolbar.displayName = 'GraphiQLToolbar'; + +// Configure the UI by providing this Component as a child of GraphiQL +function GraphiQLFooter(props: PropsWithChildren) { + return
{props.children}
; +} +GraphiQLFooter.displayName = 'GraphiQLFooter'; + +const formatSingleError = (error: Error) => ({ + ...error, + // Raise these details even if they're non-enumerable + message: error.message, + stack: error.stack, +}); + +// Determines if the React child is of the same type of the provided React component +function isChildComponentType( + child: any, + component: T, +): child is T { + if ( + child?.type?.displayName && + child.type.displayName === component.displayName + ) { + return true; + } + + return child.type === component; +} diff --git a/packages/graphiql-2-rfc-context/src/components/HeaderEditor.tsx b/packages/graphiql-2-rfc-context/src/components/HeaderEditor.tsx new file mode 100644 index 00000000000..df4945f8ee9 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/HeaderEditor.tsx @@ -0,0 +1,241 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type * as CM from 'codemirror'; +import 'codemirror/addon/hint/show-hint'; +import React from 'react'; + +import onHasCompletion from '../utility/onHasCompletion'; +import commonKeys from '../utility/commonKeys'; + +declare module CodeMirror { + export interface Editor extends CM.Editor {} + export interface ShowHintOptions { + completeSingle: boolean; + hint: CM.HintFunction | CM.AsyncHintFunction; + container: HTMLElement | null; + } +} + +type HeaderEditorProps = { + value?: string; + onEdit: (value: string) => void; + readOnly?: boolean; + onHintInformationRender: (value: HTMLDivElement) => void; + onPrettifyQuery: (value?: string) => void; + onMergeQuery: (value?: string) => void; + onRunQuery: (value?: string) => void; + editorTheme?: string; + active?: boolean; +}; + +/** + * HeaderEditor + * + * An instance of CodeMirror for editing headers to be passed with the GraphQL request. + * + * Props: + * + * - value: The text of the editor. + * - onEdit: A function called when the editor changes, given the edited text. + * - readOnly: Turns the editor to read-only mode. + * + */ +export class HeaderEditor extends React.Component { + CodeMirror: any; + editor: (CM.Editor & { options: any }) | null = null; + cachedValue: string; + private _node: HTMLElement | null = null; + ignoreChangeEvent: boolean = false; + constructor(props: HeaderEditorProps) { + super(props); + + // Keep a cached version of the value, this cache will be updated when the + // editor is updated, which can later be used to protect the editor from + // unnecessary updates during the update lifecycle. + this.cachedValue = props.value || ''; + } + + componentDidMount() { + // Lazily require to ensure requiring GraphiQL outside of a Browser context + // does not produce an error. + this.CodeMirror = require('codemirror'); + require('codemirror/addon/hint/show-hint'); + require('codemirror/addon/edit/matchbrackets'); + require('codemirror/addon/edit/closebrackets'); + require('codemirror/addon/fold/brace-fold'); + require('codemirror/addon/fold/foldgutter'); + require('codemirror/addon/lint/lint'); + require('codemirror/addon/search/searchcursor'); + require('codemirror/addon/search/jump-to-line'); + require('codemirror/addon/dialog/dialog'); + require('codemirror/keymap/sublime'); + + const editor = (this.editor = this.CodeMirror(this._node, { + value: this.props.value || '', + lineNumbers: true, + tabSize: 2, + mode: 'graphql-headers', + theme: this.props.editorTheme || 'graphiql', + keyMap: 'sublime', + autoCloseBrackets: true, + matchBrackets: true, + showCursorWhenSelecting: true, + readOnly: this.props.readOnly ? 'nocursor' : false, + foldGutter: { + minFoldSize: 4, + }, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + extraKeys: { + 'Cmd-Space': () => + this.editor!.showHint({ + completeSingle: false, + container: this._node, + } as CodeMirror.ShowHintOptions), + 'Ctrl-Space': () => + this.editor!.showHint({ + completeSingle: false, + container: this._node, + } as CodeMirror.ShowHintOptions), + 'Alt-Space': () => + this.editor!.showHint({ + completeSingle: false, + container: this._node, + } as CodeMirror.ShowHintOptions), + 'Shift-Space': () => + this.editor!.showHint({ + completeSingle: false, + container: this._node, + } as CodeMirror.ShowHintOptions), + 'Cmd-Enter': () => { + if (this.props.onRunQuery) { + this.props.onRunQuery(); + } + }, + 'Ctrl-Enter': () => { + if (this.props.onRunQuery) { + this.props.onRunQuery(); + } + }, + 'Shift-Ctrl-P': () => { + if (this.props.onPrettifyQuery) { + this.props.onPrettifyQuery(); + } + }, + + 'Shift-Ctrl-M': () => { + if (this.props.onMergeQuery) { + this.props.onMergeQuery(); + } + }, + + ...commonKeys, + }, + })); + + editor.on('change', this._onEdit); + editor.on('keyup', this._onKeyUp); + editor.on('hasCompletion', this._onHasCompletion); + } + + componentDidUpdate(prevProps: HeaderEditorProps) { + this.CodeMirror = require('codemirror'); + if (!this.editor) { + return; + } + + // Ensure the changes caused by this update are not interpretted as + // user-input changes which could otherwise result in an infinite + // event loop. + this.ignoreChangeEvent = true; + if ( + this.props.value !== prevProps.value && + this.props.value !== this.cachedValue + ) { + const thisValue = this.props.value || ''; + this.cachedValue = thisValue; + this.editor.setValue(thisValue); + } + this.ignoreChangeEvent = false; + } + + componentWillUnmount() { + if (!this.editor) { + return; + } + this.editor.off('change', this._onEdit); + this.editor.off('keyup', this._onKeyUp); + this.editor.off('hasCompletion', this._onHasCompletion); + this.editor = null; + } + + render() { + return ( +
{ + this._node = node as HTMLDivElement; + }} + /> + ); + } + + /** + * Public API for retrieving the CodeMirror instance from this + * React component. + */ + getCodeMirror() { + return this.editor as CM.Editor; + } + + /** + * Public API for retrieving the DOM client height for this component. + */ + getClientHeight() { + return this._node && this._node.clientHeight; + } + + private _onKeyUp = (_cm: CodeMirror.Editor, event: KeyboardEvent) => { + const code = event.keyCode; + if (!this.editor) { + return; + } + if ( + (code >= 65 && code <= 90) || // letters + (!event.shiftKey && code >= 48 && code <= 57) || // numbers + (event.shiftKey && code === 189) || // underscore + (event.shiftKey && code === 222) // " + ) { + this.editor.execCommand('autocomplete'); + } + }; + + private _onEdit = () => { + if (!this.editor) { + return; + } + if (!this.ignoreChangeEvent) { + this.cachedValue = this.editor.getValue(); + if (this.props.onEdit) { + this.props.onEdit(this.cachedValue); + } + } + }; + + private _onHasCompletion = ( + instance: CM.Editor, + changeObj?: CM.EditorChangeLinkedList, + ) => { + onHasCompletion(instance, changeObj, this.props.onHintInformationRender); + }; +} diff --git a/packages/graphiql-2-rfc-context/src/components/HistoryQuery.tsx b/packages/graphiql-2-rfc-context/src/components/HistoryQuery.tsx new file mode 100644 index 00000000000..70e8198540a --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/HistoryQuery.tsx @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { QueryStoreItem } from '../utility/QueryStore'; +import { WithTranslation, withTranslation } from 'react-i18next'; + +export type HandleEditLabelFn = ( + query?: string, + variables?: string, + headers?: string, + operationName?: string, + label?: string, + favorite?: boolean, +) => void; + +export type HandleToggleFavoriteFn = ( + query?: string, + variables?: string, + headers?: string, + operationName?: string, + label?: string, + favorite?: boolean, +) => void; + +export type HandleSelectQueryFn = ( + query?: string, + variables?: string, + headers?: string, + operationName?: string, + label?: string, +) => void; + +export type HistoryQueryProps = { + favorite?: boolean; + favoriteSize?: number; + handleEditLabel: HandleEditLabelFn; + handleToggleFavorite: HandleToggleFavoriteFn; + operationName?: string; + onSelect: HandleSelectQueryFn; +} & QueryStoreItem & + WithTranslation; + +class HistoryQuerySource extends React.Component< + HistoryQueryProps, + { editable: boolean } +> { + editField: HTMLInputElement | null; + constructor(props: HistoryQueryProps) { + super(props); + this.state = { + editable: false, + }; + this.editField = null; + } + + render() { + const { t } = this.props; + const displayName = + this.props.label || + this.props.operationName || + this.props.query + ?.split('\n') + .filter(line => line.indexOf('#') !== 0) + .join(''); + const starIcon = this.props.favorite ? '\u2605' : '\u2606'; + return ( +
  • + {this.state.editable ? ( + { + this.editField = c; + }} + onBlur={this.handleFieldBlur.bind(this)} + onKeyDown={this.handleFieldKeyDown.bind(this)} + placeholder={t('Type a label')} + /> + ) : ( + + )} + + +
  • + ); + } + + handleClick() { + this.props.onSelect( + this.props.query, + this.props.variables, + this.props.headers, + this.props.operationName, + this.props.label, + ); + } + + handleStarClick(e: React.MouseEvent) { + e.stopPropagation(); + this.props.handleToggleFavorite( + this.props.query, + this.props.variables, + this.props.headers, + this.props.operationName, + this.props.label, + this.props.favorite, + ); + } + + handleFieldBlur(e: React.FocusEvent) { + e.stopPropagation(); + this.setState({ editable: false }); + this.props.handleEditLabel( + this.props.query, + this.props.variables, + this.props.headers, + this.props.operationName, + e.target.value, + this.props.favorite, + ); + } + + handleFieldKeyDown(e: React.KeyboardEvent) { + if (e.keyCode === 13) { + e.stopPropagation(); + this.setState({ editable: false }); + this.props.handleEditLabel( + this.props.query, + this.props.variables, + this.props.headers, + this.props.operationName, + e.currentTarget.value, + this.props.favorite, + ); + } + } + + handleEditClick(e: React.MouseEvent) { + e.stopPropagation(); + this.setState({ editable: true }, () => { + if (this.editField) { + this.editField.focus(); + } + }); + } +} + +const HistoryQuery = withTranslation('Toolbar')(HistoryQuerySource); +export default HistoryQuery; diff --git a/packages/graphiql-2-rfc-context/src/components/ImagePreview.tsx b/packages/graphiql-2-rfc-context/src/components/ImagePreview.tsx new file mode 100644 index 00000000000..b06828f9cfa --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ImagePreview.tsx @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +function tokenToURL(token: any) { + if (token.type !== 'string') { + return; + } + + const value = token.string.slice(1).slice(0, -1).trim(); + + try { + const location = window.location; + return new URL(value, location.protocol + '//' + location.host); + } catch (err) { + return; + } +} + +function isImageURL(url: URL) { + return /(bmp|gif|jpeg|jpg|png|svg)$/.test(url.pathname); +} + +type ImagePreviewProps = { + token: any; +}; + +type ImagePreviewState = { + width: number | null; + height: number | null; + src: string | null; + mime: string | null; +}; + +export class ImagePreview extends React.Component< + ImagePreviewProps, + ImagePreviewState +> { + _node: HTMLImageElement | null = null; + + static shouldRender(token: any) { + const url = tokenToURL(token); + return url ? isImageURL(url) : false; + } + + static propTypes = { + token: PropTypes.any, + }; + + state = { + width: null, + height: null, + src: null, + mime: null, + }; + + componentDidMount() { + this._updateMetadata(); + } + + componentDidUpdate() { + this._updateMetadata(); + } + + render() { + let dims = null; + if (this.state.width !== null && this.state.height !== null) { + let dimensions = this.state.width + 'x' + this.state.height; + if (this.state.mime !== null) { + dimensions += ' ' + this.state.mime; + } + + dims =
    {dimensions}
    ; + } + + return ( +
    + this._updateMetadata()} + ref={node => { + this._node = node; + }} + src={tokenToURL(this.props.token)?.href} + /> + {dims} +
    + ); + } + + _updateMetadata() { + if (!this._node) { + return; + } + + const width = this._node.naturalWidth; + const height = this._node.naturalHeight; + const src = this._node.src; + + if (src !== this.state.src) { + this.setState({ src }); + fetch(src, { method: 'HEAD' }).then(response => { + this.setState({ + mime: response.headers.get('Content-Type'), + }); + }); + } + + if (width !== this.state.width || height !== this.state.height) { + this.setState({ height, width }); + } + } +} diff --git a/packages/graphiql-2-rfc-context/src/components/QueryEditor.tsx b/packages/graphiql-2-rfc-context/src/components/QueryEditor.tsx new file mode 100644 index 00000000000..0f6c7fb4deb --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/QueryEditor.tsx @@ -0,0 +1,118 @@ +/** @jsx jsx */ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { jsx } from 'theme-ui'; +import { GraphQLType } from 'graphql'; +import type { EditorOptions } from '../types'; + +import EditorWrapper from '../components/common/EditorWrapper'; + +import { useSessionContext } from '../api/providers/GraphiQLSessionProvider'; +import { useEditorsContext } from '../api/providers/GraphiQLEditorsProvider'; + +export type QueryEditorProps = { + onEdit?: (value: string) => void; + readOnly?: boolean; + onHintInformationRender: (elem: HTMLDivElement) => void; + onClickReference?: (reference: GraphQLType) => void; + editorTheme?: string; + operation?: string; + editorOptions?: EditorOptions; +}; + +/** + * GraphQL Operation Editor + * + * @param props {QueryEditorProps} + */ +export function QueryEditor(props: QueryEditorProps) { + const divRef = React.useRef(null); + const editorRef = React.useRef(); + const [ignoreChangeEvent, setIgnoreChangeEvent] = React.useState(false); + const cachedValueRef = React.useRef(props.operation ?? ''); + const session = useSessionContext(); + + const { loadEditor } = useEditorsContext(); + + // function _onKeyUp(_cm: monaco.editor.IStandaloneCodeEditor, event: KeyboardEvent) { + // if (AUTO_COMPLETE_AFTER_KEY.test(event.key) && editorRef.current) { + // // @TODO recreat this in monaco + // // editorRef.current.execCommand('autocomplete'); + // } + // } + + React.useEffect(() => { + require('monaco-graphql/esm/monaco.contribution'); + + // Lazily require to ensure requiring GraphiQL outside of a Browser context + // does not produce an error. + const editor = (editorRef.current = monaco.editor.create( + divRef.current as HTMLDivElement, + { + value: session?.operation?.text ?? '', + language: 'graphqlDev', + automaticLayout: true, + ...props.editorOptions, + }, + )); + if (!editor) { + return; + } + loadEditor('operation', editor); + editor.onDidChangeModelContent(() => { + if (!ignoreChangeEvent) { + cachedValueRef.current = editor.getValue(); + session.changeOperation(cachedValueRef.current); + props.onEdit && props.onEdit(cachedValueRef.current); + } + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + /** + * Handle incoming changes via props (quasi-controlled component?) + */ + React.useEffect(() => { + setIgnoreChangeEvent(true); + const editor = editorRef.current; + const op = session?.operation?.text; + if (editor && op && op !== cachedValueRef.current) { + const thisValue = op || ''; + cachedValueRef.current = thisValue; + editor.setValue(thisValue); + } + setIgnoreChangeEvent(false); + }, [session, session.operation, session.operation.text]); + + React.useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + if (props.editorOptions) { + editor.updateOptions(props.editorOptions); + } + }, [props.editorOptions]); + + return ( + + ); +} + +// /** +// * Public API for retrieving the DOM client height for this component. +// */ +// QueryEditor.getClientHeight = () => { +// return this._node && this._node.clientHeight; +// }; diff --git a/packages/graphiql-2-rfc-context/src/components/QueryHistory.tsx b/packages/graphiql-2-rfc-context/src/components/QueryHistory.tsx new file mode 100644 index 00000000000..a8e7a935a1d --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/QueryHistory.tsx @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { parse } from 'graphql'; +import React from 'react'; +import QueryStore, { QueryStoreItem } from '../utility/QueryStore'; +import HistoryQuery, { + HandleEditLabelFn, + HandleToggleFavoriteFn, + HandleSelectQueryFn, +} from './HistoryQuery'; +import StorageAPI from '../utility/StorageAPI'; +import { WithTranslation, withTranslation } from 'react-i18next'; + +const MAX_QUERY_SIZE = 100000; +const MAX_HISTORY_LENGTH = 20; + +const shouldSaveQuery = ( + query?: string, + variables?: string, + headers?: string, + lastQuerySaved?: QueryStoreItem, +) => { + if (!query) { + return false; + } + + try { + parse(query); + } catch (e) { + return false; + } + + // Don't try to save giant queries + if (query.length > MAX_QUERY_SIZE) { + return false; + } + if (!lastQuerySaved) { + return true; + } + if (JSON.stringify(query) === JSON.stringify(lastQuerySaved.query)) { + if ( + JSON.stringify(variables) === JSON.stringify(lastQuerySaved.variables) + ) { + if (JSON.stringify(headers) === JSON.stringify(lastQuerySaved.headers)) { + return false; + } + if (headers && !lastQuerySaved.headers) { + return false; + } + } + if (variables && !lastQuerySaved.variables) { + return false; + } + } + return true; +}; + +type QueryHistoryProps = { + query?: string; + variables?: string; + headers?: string; + operationName?: string; + queryID?: number; + onSelectQuery: HandleSelectQueryFn; + storage: StorageAPI; +} & WithTranslation; + +type QueryHistoryState = { + queries: Array; +}; + +export class QueryHistorySource extends React.Component< + QueryHistoryProps, + QueryHistoryState +> { + historyStore: QueryStore; + favoriteStore: QueryStore; + + constructor(props: QueryHistoryProps) { + super(props); + this.historyStore = new QueryStore( + 'queries', + props.storage, + MAX_HISTORY_LENGTH, + ); + // favorites are not automatically deleted, so there's no need for a max length + this.favoriteStore = new QueryStore('favorites', props.storage, null); + const historyQueries = this.historyStore.fetchAll(); + const favoriteQueries = this.favoriteStore.fetchAll(); + const queries = historyQueries.concat(favoriteQueries); + this.state = { queries }; + } + + render() { + const { t } = this.props; + const queries = this.state.queries.slice().reverse(); + const queryNodes = queries.map((query, i) => { + return ( + + ); + }); + return ( +
    +
    +
    {t('History')}
    +
    {this.props.children}
    +
    +
      {queryNodes}
    +
    + ); + } + + // Public API + updateHistory = ( + query?: string, + variables?: string, + headers?: string, + operationName?: string, + ) => { + if ( + shouldSaveQuery( + query, + variables, + headers, + this.historyStore.fetchRecent(), + ) + ) { + this.historyStore.push({ + query, + variables, + headers, + operationName, + }); + const historyQueries = this.historyStore.items; + const favoriteQueries = this.favoriteStore.items; + const queries = historyQueries.concat(favoriteQueries); + this.setState({ + queries, + }); + } + }; + + // Public API + toggleFavorite: HandleToggleFavoriteFn = ( + query, + variables, + headers, + operationName, + label, + favorite, + ) => { + const item: QueryStoreItem = { + query, + variables, + headers, + operationName, + label, + }; + if (!this.favoriteStore.contains(item)) { + item.favorite = true; + this.favoriteStore.push(item); + } else if (favorite) { + item.favorite = false; + this.favoriteStore.delete(item); + } + this.setState({ + queries: [...this.historyStore.items, ...this.favoriteStore.items], + }); + }; + + // Public API + editLabel: HandleEditLabelFn = ( + query, + variables, + headers, + operationName, + label, + favorite, + ) => { + const item = { + query, + variables, + headers, + operationName, + label, + }; + if (favorite) { + this.favoriteStore.edit({ ...item, favorite }); + } else { + this.historyStore.edit(item); + } + this.setState({ + queries: [...this.historyStore.items, ...this.favoriteStore.items], + }); + }; +} + +export const QueryHistory = withTranslation('Toolbar')(QueryHistorySource); diff --git a/packages/graphiql-2-rfc-context/src/components/ResultViewer.tsx b/packages/graphiql-2-rfc-context/src/components/ResultViewer.tsx new file mode 100644 index 00000000000..4ce3ea771f9 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ResultViewer.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect } from 'react'; + +import type { EditorOptions } from '../types'; + +import EditorWrapper from '../components/common/EditorWrapper'; + +import { useSessionContext } from '../api/providers/GraphiQLSessionProvider'; +import { useEditorsContext } from '../api/providers/GraphiQLEditorsProvider'; + +export type ResultViewerProps = { + editorTheme?: string; + editorOptions?: EditorOptions; + onMouseUp?: (e: monaco.editor.IEditorMouseEvent) => void; + onRenderResults?: (e: monaco.editor.IModelChangedEvent) => void; +}; + +export function ResultViewer(props: ResultViewerProps) { + const divRef = React.useRef(null); + const viewerRef = React.useRef(); + const session = useSessionContext(); + const { loadEditor } = useEditorsContext(); + useEffect(() => { + // Lazily require to ensure requiring GraphiQL outside of a Browser context + // does not produce an error. + + const viewer = (viewerRef.current = monaco.editor.create( + divRef.current as HTMLElement, + { + value: session.results?.text ?? '', + readOnly: true, + language: 'json', + automaticLayout: true, + theme: props.editorTheme, + }, + )); + loadEditor('results', viewer); + props.onMouseUp && viewer.onMouseUp(props.onMouseUp); + props.onRenderResults && viewer.onDidChangeModel(props.onRenderResults); + }, []); + + useEffect(() => { + if (viewerRef.current) { + viewerRef.current.setValue(session.results.text || ''); + } + }, [session.results, session.results.text]); + + React.useEffect(() => { + const editor = viewerRef.current; + if (!editor) { + return; + } + if (props.editorOptions) { + editor.updateOptions(props.editorOptions); + } + }, [props.editorOptions]); + return ( + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/ToolbarButton.tsx b/packages/graphiql-2-rfc-context/src/components/ToolbarButton.tsx new file mode 100644 index 00000000000..e31a85ed3c6 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ToolbarButton.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +type ToolbarButtonProps = { + onClick: () => void; + title: string; + label: string; +}; + +type ToolbarButtonState = { + error: Error | null; +}; + +/** + * ToolbarButton + * + * A button to use within the Toolbar. + */ +export class ToolbarButton extends React.Component< + ToolbarButtonProps, + ToolbarButtonState +> { + constructor(props: ToolbarButtonProps) { + super(props); + this.state = { error: null }; + } + + render() { + const { error } = this.state; + return ( + + ); + } + + handleClick = () => { + try { + this.props.onClick(); + this.setState({ error: null }); + } catch (error) { + this.setState({ error }); + } + }; +} diff --git a/packages/graphiql-2-rfc-context/src/components/ToolbarGroup.tsx b/packages/graphiql-2-rfc-context/src/components/ToolbarGroup.tsx new file mode 100644 index 00000000000..7dc978c40f6 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ToolbarGroup.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { ReactNode } from 'react'; + +type ToolbarGroupProps = { + children: ReactNode; +}; + +/** + * ToolbarGroup + * + * A group of associated controls. + */ +export function ToolbarGroup({ children }: ToolbarGroupProps) { + return
    {children}
    ; +} diff --git a/packages/graphiql-2-rfc-context/src/components/ToolbarMenu.tsx b/packages/graphiql-2-rfc-context/src/components/ToolbarMenu.tsx new file mode 100644 index 00000000000..a7af6868252 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ToolbarMenu.tsx @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { FC, MouseEventHandler } from 'react'; + +type ToolbarMenuProps = { + title: string; + label: string; +}; + +type ToolbarMenuState = { + visible: boolean; +}; + +/** + * ToolbarMenu + * + * A menu style button to use within the Toolbar. + */ +export class ToolbarMenu extends React.Component< + ToolbarMenuProps, + ToolbarMenuState +> { + private _node: HTMLAnchorElement | null = null; + private _listener: this['handleClick'] | null = null; + + constructor(props: ToolbarMenuProps) { + super(props); + this.state = { visible: false }; + } + + componentWillUnmount() { + this._release(); + } + + render() { + const visible = this.state.visible; + return ( + { + if (node) { + this._node = node; + } + }} + title={this.props.title}> + {this.props.label} + + + +
      + {this.props.children} +
    +
    + ); + } + + _subscribe() { + if (!this._listener) { + this._listener = this.handleClick.bind(this); + document.addEventListener('click', this._listener); + } + } + + _release() { + if (this._listener) { + document.removeEventListener('click', this._listener); + this._listener = null; + } + } + + handleClick(e: MouseEvent | React.MouseEvent) { + if (this._node !== e.target) { + e.preventDefault(); + this.setState({ visible: false }); + this._release(); + } + } + + handleOpen: MouseEventHandler = e => { + preventDefault(e); + this.setState({ visible: true }); + this._subscribe(); + }; +} + +type ToolbarMenuItemProps = { + onSelect: () => void; + title: string; + label: string; +}; + +export const ToolbarMenuItem: FC = ({ + onSelect, + title, + label, +}) => { + return ( +
  • { + e.currentTarget.className = 'hover'; + }} + onMouseOut={e => { + e.currentTarget.className = ''; + }} + onMouseDown={preventDefault} + onMouseUp={onSelect} + title={title}> + {label} +
  • + ); +}; + +function preventDefault(e: MouseEvent | React.MouseEvent) { + e.preventDefault(); +} diff --git a/packages/graphiql-2-rfc-context/src/components/ToolbarSelect.tsx b/packages/graphiql-2-rfc-context/src/components/ToolbarSelect.tsx new file mode 100644 index 00000000000..83a74f65f91 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/ToolbarSelect.tsx @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { MouseEventHandler } from 'react'; +import PropTypes from 'prop-types'; + +type ToolbarSelectProps = { + title?: string; + label?: string; + onSelect?: (selection: string) => void; +}; + +type ToolbarSelectState = { + visible: boolean; +}; + +type HasProps = T extends { props: any } ? T : never; + +function hasProps( + child: Child, +): child is HasProps { + if (!child || typeof child !== 'object' || !('props' in child)) { + return false; + } + return true; +} + +/** + * ToolbarSelect + * + * A select-option style button to use within the Toolbar. + * + */ + +export class ToolbarSelect extends React.Component< + ToolbarSelectProps, + ToolbarSelectState +> { + private _node: HTMLAnchorElement | null = null; + private _listener: ((this: Document, ev: MouseEvent) => any) | null = null; + constructor(props: ToolbarSelectProps) { + super(props); + this.state = { visible: false }; + } + + componentWillUnmount() { + this._release(); + } + + render() { + let selectedChild: HasProps | undefined; + const visible = this.state.visible; + const optionChildren = React.Children.map( + this.props.children, + (child, i) => { + if (!hasProps(child)) { + return null; + } + if (!selectedChild || child.props.selected) { + selectedChild = child; + } + const onChildSelect = + child.props.onSelect || + (this.props.onSelect && + this.props.onSelect.bind(null, child.props.value, i)); + return ( + + ); + }, + ); + return ( + { + this._node = node; + }} + title={this.props.title}> + {selectedChild?.props.label} + + + + +
      + {optionChildren} +
    +
    + ); + } + + _subscribe() { + if (!this._listener) { + this._listener = this.handleClick.bind(this); + document.addEventListener('click', this._listener); + } + } + + _release() { + if (this._listener) { + document.removeEventListener('click', this._listener); + this._listener = null; + } + } + + handleClick(e: MouseEvent) { + if (this._node !== e.target) { + preventDefault(e); + this.setState({ visible: false }); + this._release(); + } + } + + handleOpen = (e: React.MouseEvent) => { + preventDefault(e); + this.setState({ visible: true }); + this._subscribe(); + }; +} + +type ToolbarSelectOptionProps = { + onSelect: MouseEventHandler; + label: string; + selected: boolean; + value?: any; +}; + +export function ToolbarSelectOption({ + onSelect, + label, + selected, +}: ToolbarSelectOptionProps) { + return ( +
  • { + e.currentTarget.className = 'hover'; + }} + onMouseOut={e => { + e.currentTarget.className = ''; + }} + onMouseDown={preventDefault} + onMouseUp={onSelect}> + {label} + {selected && ( + + + + )} +
  • + ); +} + +ToolbarSelectOption.propTypes = { + onSelect: PropTypes.func, + selected: PropTypes.bool, + label: PropTypes.string, + value: PropTypes.any, +}; + +function preventDefault(e: any) { + e.preventDefault(); +} diff --git a/packages/graphiql-2-rfc-context/src/components/VariableEditor.tsx b/packages/graphiql-2-rfc-context/src/components/VariableEditor.tsx new file mode 100644 index 00000000000..d12f078051b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/VariableEditor.tsx @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @jsx jsx */ +import { jsx } from 'theme-ui'; +import { GraphQLType } from 'graphql'; + +import React from 'react'; + +import EditorWrapper from '../components/common/EditorWrapper'; + +import { useEditorsContext } from '../api/providers/GraphiQLEditorsProvider'; +import { useSessionContext } from '../api/providers/GraphiQLSessionProvider'; + +import type { EditorOptions } from '../types'; +// import useQueryFacts from '../api/hooks/useQueryFacts'; + +export type VariableEditorProps = { + variableToType?: { [variable: string]: GraphQLType }; + value?: string; + readOnly?: boolean; + onHintInformationRender: (value: HTMLDivElement) => void; + onPrettifyQuery: (value?: string) => void; + onMergeQuery: (value?: string) => void; + editorTheme?: string; + editorOptions?: EditorOptions; +}; + +/** + * VariableEditor + * + * An instance of CodeMirror for editing variables defined in QueryEditor. + * + * Props: + * + * - variableToType: A mapping of variable name to GraphQLType. + * - value: The text of the editor. + * - onEdit: A function called when the editor changes, given the edited text. + * - readOnly: Turns the editor to read-only mode. + * + */ +export function VariableEditor(props: VariableEditorProps) { + const session = useSessionContext(); + // const queryFacts = useQueryFacts(); + const [ignoreChangeEvent, setIgnoreChangeEvent] = React.useState(false); + const editorRef = React.useRef(); + const cachedValueRef = React.useRef(props.value ?? ''); + const divRef = React.useRef(null); + const { loadEditor } = useEditorsContext(); + // const variableToType = queryFacts?.variableToType + + React.useEffect(() => { + // Lazily require to ensure requiring GraphiQL outside of a Browser context + // does not produce an error. + + // might need this later + // const _onKeyUp = (_cm: CodeMirror.Editor, event: KeyboardEvent) => { + // const code = event.keyCode; + // if (!editor) { + // return; + // } + // if ( + // (code >= 65 && code <= 90) || // letters + // (!event.shiftKey && code >= 48 && code <= 57) || // numbers + // (event.shiftKey && code === 189) || // underscore + // (event.shiftKey && code === 222) // " + // ) { + // editor.execCommand('autocomplete'); + // } + // }; + const editor = (editorRef.current = monaco.editor.create( + divRef.current as HTMLDivElement, + { + value: session?.variables?.text || '', + language: 'json', + theme: props?.editorTheme, + readOnly: props?.readOnly ?? false, + automaticLayout: true, + ...props.editorOptions, + }, + )); + loadEditor('variables', editor); + + editor.onDidChangeModelContent(() => { + if (!ignoreChangeEvent) { + cachedValueRef.current = editor.getValue(); + session.changeVariables(cachedValueRef.current); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + + // Ensure the changes caused by this update are not interpretted as + // user-input changes which could otherwise result in an infinite + // event loop. + setIgnoreChangeEvent(true); + if (session.variables.text !== cachedValueRef.current) { + const thisValue = session.variables.text || ''; + cachedValueRef.current = thisValue; + editor.setValue(thisValue); + } + + setIgnoreChangeEvent(false); + }, [session.variables.text]); + + React.useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + if (props.editorOptions) { + editor.updateOptions(props.editorOptions); + } + }, [props.editorOptions]); + + // TODO: for variables linting/etc + // React.useEffect(() => { + // const editor = editorRef.current; + // if (!editor) { + // return; + // } + // if (queryFacts?.variableToType) { + // // editor.options.lint.variableToType = queryFacts.variableToType; + // // editor.options.hintOptions.variableToType = queryFacts.variableToType; + // } + // }, [queryFacts, variableToType]); + + return ; +} diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/DocExplorer.spec.tsx b/packages/graphiql-2-rfc-context/src/components/__tests__/DocExplorer.spec.tsx new file mode 100644 index 00000000000..1b3fe6632ef --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/DocExplorer.spec.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { DocExplorer } from '../DocExplorer'; + +import { ExampleSchema } from './ExampleSchema'; + +describe('DocExplorer', () => { + it('renders spinner when no schema prop is present', () => { + const { container } = render(); + const spinner = container.querySelectorAll('.spinner-container'); + expect(spinner).toHaveLength(1); + }); + it('renders with null schema', () => { + const { container } = render(); + const error = container.querySelectorAll('.error-container'); + expect(error).toHaveLength(1); + expect(error[0]).toHaveTextContent('No Schema Available'); + }); + it('renders with schema', () => { + const { container } = render(); + const error = container.querySelectorAll('.error-container'); + expect(error).toHaveLength(0); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/ExampleSchema.ts b/packages/graphiql-2-rfc-context/src/components/__tests__/ExampleSchema.ts new file mode 100644 index 00000000000..ba887be37a2 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/ExampleSchema.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + GraphQLObjectType, + GraphQLString, + GraphQLSchema, + GraphQLUnionType, + GraphQLInterfaceType, + GraphQLBoolean, + GraphQLEnumType, +} from 'graphql'; + +export const ExampleInterface = new GraphQLInterfaceType({ + name: 'exampleInterface', + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + }, +}); + +export const ExampleEnum = new GraphQLEnumType({ + name: 'exampleEnum', + values: { + value1: { value: 'Value 1' }, + value2: { value: 'Value 2' }, + value3: { value: 'Value 3', deprecationReason: 'Only two are needed' }, + }, +}); + +export const ExampleUnionType1 = new GraphQLObjectType({ + name: 'Union Type 1', + interfaces: [ExampleInterface], + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + enum: { + name: 'enumField', + type: ExampleEnum, + }, + }, +}); + +export const ExampleUnionType2 = new GraphQLObjectType({ + name: 'Union Type 2', + interfaces: [ExampleInterface], + fields: { + name: { + name: 'nameField', + type: GraphQLString, + }, + string: { + name: 'stringField', + type: GraphQLString, + }, + }, +}); + +export const ExampleUnion = new GraphQLUnionType({ + name: 'exampleUnion', + types: [ExampleUnionType1, ExampleUnionType2], +}); + +export const ExampleQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + string: { + name: 'exampleString', + type: GraphQLString, + }, + union: { + name: 'exampleUnion', + type: ExampleUnion, + }, + fieldWithArgs: { + name: 'exampleWithArgs', + type: GraphQLString, + args: { + stringArg: { + name: 'exampleStringArg', + type: GraphQLString, + }, + }, + }, + deprecatedField: { + name: 'booleanField', + type: GraphQLBoolean, + deprecationReason: 'example deprecation reason', + }, + }, +}); + +export const ExampleSchema = new GraphQLSchema({ + query: ExampleQuery, +}); diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/GraphiQL.spec.tsx b/packages/graphiql-2-rfc-context/src/components/__tests__/GraphiQL.spec.tsx new file mode 100644 index 00000000000..ec96ab41d36 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/GraphiQL.spec.tsx @@ -0,0 +1,526 @@ +// @ts-nocheck + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { GraphiQL, Fetcher } from '../GraphiQL'; +import { getMockStorage } from './helpers/storage'; +import { codeMirrorModules } from './helpers/codeMirror'; +import { + mockQuery1, + mockVariables1, + mockOperationName1, + mockBadQuery, + mockQuery2, + mockVariables2, + mockHeaders1, + mockHeaders2, +} from './fixtures'; + +codeMirrorModules.forEach(m => jest.mock(m, () => {})); + +// The smallest possible introspection result that builds a schema. +const simpleIntrospection = { + data: { + __schema: { + queryType: { name: 'Q' }, + types: [ + { + kind: 'OBJECT', + name: 'Q', + interfaces: [], + fields: [{ name: 'q', args: [], type: { name: 'Q' } }], + }, + ], + }, + }, +}; + +// Spins the promise loop a few times before continuing. +const wait = () => + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => Promise.resolve()) + .then(() => Promise.resolve()); + +const waitTime = (timeout: number) => + new Promise(resolve => setTimeout(resolve, timeout)); + +Object.defineProperty(window, 'localStorage', { + value: getMockStorage(), +}); + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe('GraphiQL', () => { + const noOpFetcher: Fetcher = () => {}; + + it('should throw error without fetcher', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrowError( + 'GraphiQL requires a fetcher function', + ); + spy.mockRestore(); + }); + + it('should construct correctly with fetcher', () => { + expect(() => render()).not.toThrow(); + }); + + it('should refetch schema with new fetcher', async () => { + let firstCalled = false; + function firstFetcher() { + firstCalled = true; + return Promise.resolve(simpleIntrospection); + } + + let secondCalled = false; + function secondFetcher() { + secondCalled = true; + return Promise.resolve(simpleIntrospection); + } + + // Initial render calls fetcher + const { rerender } = render(); + expect(firstCalled).toEqual(true); + + await wait(); + + // Re-render does not call fetcher again + firstCalled = false; + rerender(); + expect(firstCalled).toEqual(false); + + await wait(); + + // Re-render with new fetcher is called. + rerender(); + expect(secondCalled).toEqual(true); + }); + + it('should not throw error if schema missing and query provided', () => { + expect(() => + render(), + ).not.toThrow(); + }); + + it('defaults to the built-in default query', () => { + const { container } = render(); + expect( + container.querySelector('.query-editor .mockCodeMirror')?.value, + ).toContain('# Welcome to GraphiQL'); + }); + + it('accepts a custom default query', () => { + const { container } = render( + , + ); + expect( + container.querySelector('.query-editor .mockCodeMirror'), + ).toHaveValue('GraphQL Party!!'); + }); + it('accepts a docExplorerOpen prop', () => { + const { container } = render( + , + ); + expect(container.querySelector('.docExplorerWrap')).toBeInTheDocument(); + }); + it('defaults to closed docExplorer', () => { + const { container } = render(); + expect(container.querySelector('.docExplorerWrap')).not.toBeInTheDocument(); + }); + + it('accepts a defaultVariableEditorOpen param', () => { + const { container: container1 } = render( + , + ); + const queryVariables = container1.querySelector('.variable-editor'); + + expect(queryVariables.style.height).toEqual(''); + + const secondaryEditorTitle = container1.querySelector( + '#secondary-editor-title', + ); + fireEvent.mouseDown(secondaryEditorTitle); + fireEvent.mouseMove(secondaryEditorTitle); + expect(queryVariables.style.height).toEqual('200px'); + + const { container: container2 } = render( + , + ); + expect( + container2.querySelector('[aria-label="Query Variables"]')?.style.height, + ).toEqual('200px'); + + const { container: container3 } = render( + , + ); + const queryVariables3 = container3.querySelector('.variable-editor'); + expect(queryVariables3?.style.height).toEqual(''); + }); + + it('adds a history item when the execute query function button is clicked', () => { + const { getByTitle, container } = render( + , + ); + fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + }); + + it('will not save invalid queries', () => { + const { getByTitle, container } = render( + , + ); + fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(0); + }); + + it('will save if there was not a previously saved query', () => { + const { getByTitle, container } = render( + , + ); + fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + }); + + it('will not save a query if the query is the same as previous query', () => { + const { getByTitle, container } = render( + , + ); + fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + fireEvent.click(getByTitle('Execute Query (Ctrl-Enter)')); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + }); + + it('will save if new query is different than previous query', async () => { + const { getByTitle, container } = render( + , + ); + const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-contents li')).toHaveLength(1); + + fireEvent.change( + container.querySelector('[aria-label="Query Editor"] .mockCodeMirror'), + { + target: { value: mockQuery2 }, + }, + ); + + // wait for onChange debounce + await waitTime(150); + + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-label')).toHaveLength(2); + }); + + it('will save query if variables are different ', () => { + const { getByTitle, container } = render( + , + ); + const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-label')).toHaveLength(1); + + fireEvent.change( + container.querySelector('[aria-label="Query Variables"] .mockCodeMirror'), + { + target: { value: mockVariables2 }, + }, + ); + + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-label')).toHaveLength(2); + }); + + it('will save query if headers are different ', () => { + const { getByTitle, getByText, container } = render( + , + ); + const executeQueryButton = getByTitle('Execute Query (Ctrl-Enter)'); + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-label')).toHaveLength(1); + + fireEvent.click(getByText('Request Headers')); + + fireEvent.change( + container.querySelector('[aria-label="Request Headers"] .mockCodeMirror'), + { + target: { value: mockHeaders2 }, + }, + ); + + fireEvent.click(executeQueryButton); + expect(container.querySelectorAll('.history-label')).toHaveLength(2); + }); + + describe('children overrides', () => { + const MyFunctionalComponent = () => { + return null; + }; + const wrap = component => () => ( +
    {component}
    + ); + + it('properly ignores fragments', () => { + const myFragment = ( + + + + + ); + + const { container, getByRole } = render( + {myFragment}, + ); + + expect( + container.querySelector('.graphiql-container'), + ).toBeInTheDocument(); + expect(container.querySelector('.title')).toBeInTheDocument(); + expect(getByRole('toolbar')).toBeInTheDocument(); + }); + + it('properly ignores non-override children components', () => { + const { container, getByRole } = render( + + + , + ); + + expect( + container.querySelector('.graphiql-container'), + ).toBeInTheDocument(); + expect(container.querySelector('.title')).toBeInTheDocument(); + expect(getByRole('toolbar')).toBeInTheDocument(); + }); + + it('properly ignores non-override class components', () => { + class MyClassComponent { + render() { + return null; + } + } + + const { container, getByRole } = render( + + + , + ); + + expect( + container.querySelector('.graphiql-container'), + ).toBeInTheDocument(); + expect(container.querySelector('.title')).toBeInTheDocument(); + expect(getByRole('toolbar')).toBeInTheDocument(); + }); + + describe('GraphiQL.Logo', () => { + it('can be overridden using the exported type', () => { + const { container } = render( + + {'My Great Logo'} + , + ); + + expect( + container.querySelector('.graphiql-container'), + ).toBeInTheDocument(); + }); + + it('can be overridden using a named component', () => { + const WrappedLogo = wrap( + {'My Great Logo'}, + ); + WrappedLogo.displayName = 'GraphiQLLogo'; + + const { getByText } = render( + + + , + ); + + expect(getByText('My Great Logo')).toBeInTheDocument(); + }); + }); + + describe('GraphiQL.Toolbar', () => { + it('can be overridden using the exported type', () => { + const { container } = render( + + + + + , + ); + + expect( + container.querySelectorAll('[role="toolbar"] .toolbar-button'), + ).toHaveLength(1); + }); + + it('can be overridden using a named component', () => { + const WrappedToolbar = wrap( + + + , + ); + WrappedToolbar.displayName = 'GraphiQLToolbar'; + + const { container } = render( + + + , + ); + + expect(container.querySelector('.test-wrapper')).toBeInTheDocument(); + expect( + container.querySelectorAll('[role="toolbar"] button'), + ).toHaveLength(1); + }); + }); + + describe('GraphiQL.Footer', () => { + it('can be overridden using the exported type', () => { + const { container } = render( + + + + + , + ); + + expect(container.querySelectorAll('.footer button')).toHaveLength(1); + }); + + it('can be overridden using a named component', () => { + const WrappedFooter = wrap( + + + , + ); + WrappedFooter.displayName = 'GraphiQLFooter'; + + const { container } = render( + + + , + ); + + expect(container.querySelector('.test-wrapper')).toBeInTheDocument(); + expect(container.querySelectorAll('.footer button')).toHaveLength(1); + }); + }); + }); + + it('readjusts the query wrapper flex style field when the result panel is resized', () => { + const spy = jest + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(900); + + const { container } = render(); + + const codeMirrorGutter = container.querySelector( + '.result-window .CodeMirror-gutter', + ); + const queryWrap = container.querySelector('.queryWrap'); + + fireEvent.mouseDown(codeMirrorGutter, { + button: 0, + ctrlKey: false, + }); + + fireEvent.mouseMove(codeMirrorGutter, { + buttons: 1, + clientX: 700, + }); + + fireEvent.mouseUp(codeMirrorGutter); + + expect(queryWrap.style.flex).toEqual('3.5'); + + spy.mockRestore(); + }); + + it('allows for resizing the doc explorer correctly', () => { + const spy = jest + .spyOn(Element.prototype, 'clientWidth', 'get') + .mockReturnValue(1200); + + const { container, getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText(/Open Documentation Explorer/i)); + const docExplorerResizer = container.querySelector( + '.docExplorerResizer', + ) as Element; + + fireEvent.mouseDown(docExplorerResizer, { + clientX: 3, + }); + + fireEvent.mouseMove(docExplorerResizer, { + buttons: 1, + clientX: 800, + }); + + fireEvent.mouseUp(docExplorerResizer); + + expect(container.querySelector('.docExplorerWrap').style.width).toBe( + '403px', + ); + + spy.mockRestore(); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/HistoryQuery.spec.tsx b/packages/graphiql-2-rfc-context/src/components/__tests__/HistoryQuery.spec.tsx new file mode 100644 index 00000000000..de80a15d4b2 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/HistoryQuery.spec.tsx @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import HistoryQuery, { HistoryQueryProps } from '../HistoryQuery'; +import { + mockOperationName1, + mockQuery1, + mockVariables1, + mockHeaders1, +} from './fixtures'; + +const noOp = () => {}; + +const baseMockProps = { + favorite: false, + handleEditLabel: noOp, + handleToggleFavorite: noOp, + onSelect: noOp, + query: mockQuery1, + variables: mockVariables1, + headers: mockHeaders1, +}; + +function getMockProps( + customProps?: Partial, +): HistoryQueryProps { + return { + ...baseMockProps, + ...customProps, + }; +} + +describe('HistoryQuery', () => { + it('renders operationName if label is not provided', () => { + const otherMockProps = { operationName: mockOperationName1 }; + const props = getMockProps(otherMockProps); + const { container } = render(); + expect(container.querySelector('button.history-label')!.textContent).toBe( + mockOperationName1, + ); + }); + + it('renders a string version of the query if label or operation name are not provided', () => { + const { container } = render(); + expect(container.querySelector('button.history-label')!.textContent).toBe( + mockQuery1 + .split('\n') + .filter(line => line.indexOf('#') !== 0) + .join(''), + ); + }); + + it('calls onSelect with the correct arguments when history label button is clicked', () => { + const onSelectSpy = jest.spyOn(baseMockProps, 'onSelect'); + const otherMockProps = { + operationName: mockOperationName1, + }; + const { container } = render( + , + ); + fireEvent.click(container.querySelector('button.history-label')!); + expect(onSelectSpy).toHaveBeenCalledWith( + mockQuery1, + mockVariables1, + mockHeaders1, + mockOperationName1, + undefined, + ); + }); + + it('renders label input if the edit label button is clicked', () => { + const { container } = render(); + fireEvent.click(container.querySelector('[aria-label="Edit label"]')!); + expect(container.querySelectorAll('li.editable').length).toBe(1); + expect(container.querySelectorAll('input').length).toBe(1); + expect(container.querySelectorAll('button.history-label').length).toBe(0); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/fixtures.ts b/packages/graphiql-2-rfc-context/src/components/__tests__/fixtures.ts new file mode 100644 index 00000000000..72d71b2dc58 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/fixtures.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export const mockBadQuery = `bad {} query`; + +export const mockQuery1 = /* GraphQL */ ` + query Test($string: String) { + test { + hasArgs(string: $string) + } + } +`; + +export const mockQuery2 = /* GraphQL */ ` + query Test2 { + test { + id + } + } +`; + +export const mockVariables1 = JSON.stringify({ string: 'string' }); +export const mockVariables2 = JSON.stringify({ string: 'string2' }); + +export const mockHeaders1 = JSON.stringify({ foo: 'bar' }); +export const mockHeaders2 = JSON.stringify({ foo: 'baz' }); + +export const mockOperationName1 = 'Test'; +export const mockOperationName2 = 'Test2'; + +export const mockHistoryLabel1 = 'Test'; diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/codeMirror.js b/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/codeMirror.js new file mode 100644 index 00000000000..68a406f8e51 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/codeMirror.js @@ -0,0 +1,23 @@ +export const codeMirrorModules = [ + 'codemirror/addon/hint/show-hint', + 'codemirror/addon/comment/comment', + 'codemirror/addon/edit/matchbrackets', + 'codemirror/addon/edit/closebrackets', + 'codemirror/addon/fold/foldgutter', + 'codemirror/addon/fold/brace-fold', + 'codemirror/addon/search/search', + 'codemirror/addon/search/searchcursor', + 'codemirror/addon/search/jump-to-line', + 'codemirror/addon/dialog/dialog', + 'codemirror/addon/lint/lint', + 'codemirror/keymap/sublime', + 'codemirror-graphql/hint', + 'codemirror-graphql/lint', + 'codemirror-graphql/info', + 'codemirror-graphql/jump', + 'codemirror-graphql/mode', + 'codemirror-graphql/results/mode', + 'codemirror-graphql/variables/hint', + 'codemirror-graphql/variables/lint', + 'codemirror-graphql/variables/mode', +]; diff --git a/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/storage.js b/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/storage.js new file mode 100644 index 00000000000..3a67d08c384 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/__tests__/helpers/storage.js @@ -0,0 +1,25 @@ +export function getMockStorage() { + let store = {}; + return { + getItem(key) { + return store.hasOwnProperty(key) ? store[key] : null; + }, + setItem(key, value) { + store[key] = value.toString(); + }, + clear() { + store = {}; + }, + removeItem(key) { + if (store.hasOwnProperty(key)) { + const updatedStore = {}; + for (const k in store) { + if (k !== key) { + updatedStore[k] = store[k]; + } + } + store = updatedStore; + } + }, + }; +} diff --git a/packages/graphiql-2-rfc-context/src/components/common/EditorWrapper.tsx b/packages/graphiql-2-rfc-context/src/components/common/EditorWrapper.tsx new file mode 100644 index 00000000000..bd1505af5a6 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/EditorWrapper.tsx @@ -0,0 +1,34 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx, SxStyleProp } from 'theme-ui'; +import * as React from 'react'; + +export default function EditorWrapper( + props: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > & { + sx?: SxStyleProp; + innerRef: any; + }, +) { + const { innerRef, sx, ...rest } = props; + return ( +
    + ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/common/Layout.stories.tsx b/packages/graphiql-2-rfc-context/src/components/common/Layout.stories.tsx new file mode 100644 index 00000000000..0ef311a954e --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Layout.stories.tsx @@ -0,0 +1,132 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import { Nav, NavItem } from './Nav'; +import List, { ListRow } from './List'; +import { useThemeLayout } from './themes/provider'; +import Logo from './Logo'; + +const explorer = { + input: ( + + {'Input'} + + ), + response: ( + + {'Response'} + + ), + console: ( + + {'Console/Inspector'} + + ), +}; +const nav = ( + +); +const slots = { nav, explorer }; + +export default { title: 'Layout' }; + +export const WithSlots = () => { + const Layout = useThemeLayout(); + return ; +}; + +export const WithManySidebars = () => { + const Layout = useThemeLayout(); + return ( + + {'Sidebar'} + + ), + }, + { + key: 2, + size: 'aside', + component: ( + + {'aside'} + + ), + }, + { + key: 3, + size: 'aside', + component: ( + + {'Another aside'} + + ), + }, + ]} + /> + ); +}; + +export const WithFullScreenPanel = () => { + const Layout = useThemeLayout(); + return ( + + {'Woooo'} + + ), + }, + ]} + /> + ); +}; + +export const WithStringsOnly = () => { + const Layout = useThemeLayout(); + return ( + + ); +}; diff --git a/packages/graphiql-2-rfc-context/src/components/common/List/List.stories.tsx b/packages/graphiql-2-rfc-context/src/components/common/List/List.stories.tsx new file mode 100644 index 00000000000..e9f88d4d460 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/List/List.stories.tsx @@ -0,0 +1,55 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import List, { ListRow } from './index'; + +export default { title: 'Lists' }; + +const longText = Array(300) + .fill('scroll') + .map((c, i) =>
    {c}
    ); + +export const WithFlexChild = () => ( +
    + + +
    + { + 'Lists are a vertical stack of components and form the basis of most modules. This one is very long' + } +
    +
    + + {'You normally want 1 flex area that grows forever like this one'} + {longText} + {'the end'} + +
    +
    +); + +export const WithStackedRows = () => ( +
    + + {'Title'} + {'Navigation'} + {'Search'} + {'Filter'} + + {'Actual content'} + {longText} + {'Actual content ends here'} + + {'Footer'} + {'Footers footer'} + +
    +); diff --git a/packages/graphiql-2-rfc-context/src/components/common/List/index.tsx b/packages/graphiql-2-rfc-context/src/components/common/List/index.tsx new file mode 100644 index 00000000000..a95c8d21720 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/List/index.tsx @@ -0,0 +1,58 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx, SxStyleProp } from 'theme-ui'; +import { PropsWithChildren } from 'react'; + +export type ListRowPropTypes = PropsWithChildren<{ + flex?: boolean; + padding?: boolean; +}>; + +const ListRow = ({ + children, + flex = false, + padding = false, +}: ListRowPropTypes) => ( +
    spaces.rowPadding : undefined, + minHeight: ({ spaces }) => spaces.rowMinHeight, + } as SxStyleProp + }> + {children} +
    +); + +export type ListPropTypes = PropsWithChildren<{}>; + +const List = ({ children }: ListPropTypes) => ( +
    *:not(:first-of-type)': { + borderTop: theme => `1px solid ${theme.colors.border}`, + }, + '> *': { + flex: '0 0 auto', + }, + }}> + {children} +
    +); + +export default List; +export { ListRow }; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Logo.tsx b/packages/graphiql-2-rfc-context/src/components/common/Logo.tsx new file mode 100644 index 00000000000..ce42b142429 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Logo.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +interface Props { + color?: string; + size?: string | number; +} + +export default function Logo({ color = 'currentColor', size = 40 }: Props) { + return ( + + + + + + + + + + ); +} diff --git a/packages/graphiql-2-rfc-context/src/components/common/Nav.stories.tsx b/packages/graphiql-2-rfc-context/src/components/common/Nav.stories.tsx new file mode 100644 index 00000000000..45ac3244fdd --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Nav.stories.tsx @@ -0,0 +1,28 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import { NavItem, Nav } from './Nav'; +import { layout } from './themes/decorators'; + +import Logo from './Logo'; + +export default { title: 'Navbar', decorators: [layout] }; + +export const NavBar = () => ( + +); diff --git a/packages/graphiql-2-rfc-context/src/components/common/Nav.tsx b/packages/graphiql-2-rfc-context/src/components/common/Nav.tsx new file mode 100644 index 00000000000..52cfd744e4b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Nav.tsx @@ -0,0 +1,66 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import { PropsWithChildren } from 'react'; + +import { ReactNodeLike } from '../../types'; + +interface NavProps { + children: Array; +} + +interface NavItemProps { + label: string; + active?: boolean; +} + +export const NavItem = ({ + active, + label, + children, +}: PropsWithChildren) => ( + +); + +export const Nav = ({ children }: NavProps) => { + return ( + + ); +}; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/ResizeHandler.tsx b/packages/graphiql-2-rfc-context/src/components/common/Resizer/ResizeHandler.tsx new file mode 100644 index 00000000000..fc2460bd690 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/ResizeHandler.tsx @@ -0,0 +1,93 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ResizeHandlerData, ResizeHandlerProps } from './types'; + +export const ResizeHandler: React.FC = props => { + const { dir, onStart, onEnd, onUpdate, className, style, children } = props; + + const [isDragging, setIsDragging] = useState(false); + const listenersRef = useRef(null); + + const handleMouseMove = useCallback( + e => { + onUpdate(e); + }, + [onUpdate], + ); + + const cleanMouseListeners = useCallback(() => { + const oldRef = listenersRef.current; + if (oldRef) { + window.removeEventListener('mousemove', oldRef.handleMouseMove); + window.removeEventListener('touchmove', oldRef.handleMouseMove); + window.removeEventListener('mouseup', oldRef.handleMouseUp); + window.removeEventListener('touchend', oldRef.handleMouseUp); + } + }, []); + + const handleMouseUp = useCallback( + e => { + setIsDragging(false); + cleanMouseListeners(); + onEnd(e); + }, + [cleanMouseListeners, onEnd], + ); + + const handleMouseDown = useCallback( + e => { + setIsDragging(true); + cleanMouseListeners(); + + listenersRef.current = { + handleMouseMove, + handleMouseUp, + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('touchmove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchend', handleMouseUp); + + onStart(e); + }, + [cleanMouseListeners, handleMouseMove, handleMouseUp, onStart], + ); + + useEffect(() => { + return () => { + cleanMouseListeners(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
    + {isDragging && ( + + )} + {children} +
    + ); +}; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.stories.tsx b/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.stories.tsx new file mode 100644 index 00000000000..65f22fa317b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.stories.tsx @@ -0,0 +1,22 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import { Resizer } from './Resizer'; + +export default { title: 'Resizer' }; + +export const resizer = () => ( + +
    {`Main content`}
    +
    +); diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.tsx b/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.tsx new file mode 100644 index 00000000000..f94c25b323e --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/Resizer.tsx @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useCallback, useRef, useState } from 'react'; +import { ResizeHandler } from './ResizeHandler'; +import { ResizingData, ResizeProps, MEvent } from './types'; +import { + getContainerInfo, + getContainerMeta, + getHandlerInfo, + isNil, + normalizeMEvent, +} from './util'; + +export const Resizer: React.FC = props => { + const { + border, + onStart, + onEnd, + onUpdate, + id, + className, + style, + handlerClassName, + handlerStyle: _handlerStyle, + handlerWidth: _handlerWidth, + handlerOffset: _handlerOffset, + handlerZIndex: _handlerZIndex, + children, + } = props; + + const handlerWidth = isNil(_handlerWidth) ? 16 : (_handlerWidth as number); + const handlerOffset = (isNil(_handlerOffset) + ? -handlerWidth / 2 + : _handlerOffset) as number; + const handlerZIndex = (isNil(_handlerZIndex) ? 10 : _handlerZIndex) as number; + + const [diffCoord, setDiffCoord] = useState(0); + const [oldSize, setOldSize] = useState(null); + const oldCoordRef = useRef(null); + const boxRef = useRef(null); + + const containerMeta = getContainerMeta({ border }); + + const { style: containerStyle } = getContainerInfo({ + style, + containerMeta, + diffCoord, + oldSize, + }); + + const { dir, style: handlerStyle } = getHandlerInfo({ + border, + handlerWidth, + handlerOffset, + handlerStyle: _handlerStyle, + }); + + const handleStart = useCallback( + (_e: MEvent) => { + const e = normalizeMEvent(_e); + + const { wh, xy } = containerMeta; + const el = boxRef.current; + if (!el) { + return; + } + + const px = window.getComputedStyle(el)[wh] as string; + + setDiffCoord(0); + setOldSize(parseInt(px, 10)); + oldCoordRef.current = e[xy]; + + if (onStart) { + onStart(e); + } + }, + [containerMeta, onStart], + ); + + const handleEnd = useCallback( + (_e: MEvent) => { + const e = normalizeMEvent(_e); + if (onEnd) { + onEnd(e); + } + }, + [onEnd], + ); + + const handleUpdate = useCallback( + (_e: MEvent) => { + const e = normalizeMEvent(_e); + + const { xy } = containerMeta; + if (oldCoordRef.current === null) { + return; + } + + setDiffCoord(e[xy] - oldCoordRef.current); + + if (onUpdate) { + onUpdate(e); + } + }, + [containerMeta, onUpdate], + ); + + return ( +
    + + {children} +
    + ); +}; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/index.ts b/packages/graphiql-2-rfc-context/src/components/common/Resizer/index.ts new file mode 100644 index 00000000000..71a840cc9be --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './Resizer'; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/types.ts b/packages/graphiql-2-rfc-context/src/components/common/Resizer/types.ts new file mode 100644 index 00000000000..78a4e229c74 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/types.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export type MEvent = MouseEvent | TouchEvent; + +export type RdsMEvent = + | MouseEvent + | (TouchEvent & { + clientX: number; + clientY: number; + }); + +export interface ResizeHandlerProps { + dir: 'ew' | 'ns'; + onStart: (e: MEvent) => void; + onEnd: (e: MEvent) => void; + onUpdate: (e: MEvent) => void; + className?: string; + style?: React.CSSProperties; +} + +export interface ResizeHandlerData { + listenersRef: { + handleMouseMove: (e: MEvent) => void; + handleMouseUp: (e: MEvent) => void; + } | null; +} + +export interface ResizeProps { + border: 'top' | 'bottom' | 'left' | 'right'; + onStart?: ResizeHandlerProps['onStart']; + onEnd?: ResizeHandlerProps['onEnd']; + onUpdate?: ResizeHandlerProps['onUpdate']; + id?: string; + className?: string; + style?: React.CSSProperties; + handlerClassName?: string; + handlerStyle?: React.CSSProperties; + handlerWidth?: number; + handlerOffset?: number; + handlerZIndex?: number; +} + +export interface ResizingData { + diffCoord: number; + oldCorrd: number | null; + oldSize: number | null; +} diff --git a/packages/graphiql-2-rfc-context/src/components/common/Resizer/util.ts b/packages/graphiql-2-rfc-context/src/components/common/Resizer/util.ts new file mode 100644 index 00000000000..540728c94bb --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Resizer/util.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + ResizeHandlerProps, + ResizingData, + ResizeProps, + MEvent, + RdsMEvent, +} from './types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isNil = (v: any) => v === null || v === undefined; + +export const normalizeMEvent = (e: MEvent): RdsMEvent => { + if ((e as TouchEvent).touches && (e as TouchEvent).touches[0]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).clientX = Math.round((e as TouchEvent).touches[0].clientX); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).clientY = Math.round((e as TouchEvent).touches[0].clientY); + } + return e as RdsMEvent; +}; + +export const getContainerMeta = ({ + border, +}: { + border: ResizeProps['border']; +}) => { + let wh: 'width' | 'height'; + let xy: 'clientX' | 'clientY'; + let sn: 1 | -1; + + if (/^(left|right)$/.test(border)) { + wh = 'width'; + xy = 'clientX'; + sn = border === 'right' ? 1 : -1; + } else { + wh = 'height'; + xy = 'clientY'; + sn = border === 'bottom' ? 1 : -1; + } + return { wh, xy, sn }; +}; + +export const getContainerInfo = ({ + style, + containerMeta, + diffCoord, + oldSize, +}: { + style: ResizeProps['style']; + containerMeta: ReturnType; + diffCoord: ResizingData['diffCoord']; + oldSize: ResizingData['oldSize']; +}) => { + const { wh, sn } = containerMeta; + let retStyle: React.CSSProperties = {}; + + if (oldSize != null) { + retStyle[wh] = oldSize + diffCoord * sn; + } + retStyle = { + ...style, + ...retStyle, + }; + return { style: retStyle }; +}; + +export const getHandlerInfo = ({ + border, + handlerWidth, + handlerOffset, + handlerStyle, +}: { + border: ResizeProps['border']; + handlerWidth: ResizeProps['handlerWidth']; + handlerOffset: ResizeProps['handlerOffset']; + handlerStyle: ResizeProps['handlerStyle']; +}) => { + let dir: ResizeHandlerProps['dir']; + let style: React.CSSProperties = {}; + + if (/^(left|right)$/.test(border)) { + dir = 'ew'; + style.width = handlerWidth; + style.top = 0; + style.bottom = 0; + } else { + dir = 'ns'; + style.height = handlerWidth; + style.left = 0; + style.right = 0; + } + style[border] = handlerOffset; + + style = { ...style, ...handlerStyle }; + return { dir, style }; +}; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Content.tsx b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Content.tsx new file mode 100644 index 00000000000..067a9fd7757 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Content.tsx @@ -0,0 +1,28 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { jsx } from 'theme-ui'; +import { DetailedHTMLProps } from 'react'; + +export type ContentProps = DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +>; + +const Content = ({ ...props }: ContentProps) => ( +
    spaces.rowPadding, + }} + /> +); + +export default Content; diff --git a/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.stories.tsx b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.stories.tsx new file mode 100644 index 00000000000..851c6a0bfd7 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.stories.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import List, { ListRow } from '../List'; +import Tabs from './Tabs'; +import React, { useState } from 'react'; +import { layout } from '../themes/decorators'; +import { ReactNodeLike } from '../../../types'; + +export default { title: 'Tabbar', decorators: [layout] }; + +const ManagedTabs = ({ + tabs, + children, +}: { + tabs: Array; + children: Array; +}) => { + const [active, setActive] = useState(0); + return ( + + {children} + + ); +}; + +export const Tabbar = () => ( + + + +

    {'One'}

    +

    {'Two'}

    +

    {'Three'}

    +
    +
    + + + {'Component '} + {'2'} + , + ]}> +

    {'With'}

    +

    {'a'}

    +

    {'nested'}

    +

    {'component'}

    +
    +
    + +
    + +

    {'a'}

    +

    {'b'}

    +
    +
    +
    +
    +); diff --git a/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.tsx b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.tsx new file mode 100644 index 00000000000..e065add4d02 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/Toolbar/Tabs.tsx @@ -0,0 +1,66 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { jsx } from 'theme-ui'; +import WithDividers from './support/WithDividers'; +import { ReactNodeLike } from '../../../types'; + +export type TabProps = { active: boolean } & React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>; + +const Tab = ({ active, ...props }: TabProps) => ( + + + + +
    +
    +
    +
    +
    + Woooo +
    +
    +
    +
    +
    +
    +
    +
    + Input +
    +
    +
    +
    +
    +
    + Response +
    +
    +
    +
    +
    +
    + Console/Inspector +
    +
    +
    +
    + +`; + +exports[`Storyshots Layout With Many Sidebars 1`] = ` +
    +
    + +
    +
    +
    +
    +
    + Sidebar +
    +
    +
    +
    +
    +
    + aside +
    +
    +
    +
    +
    +
    + Another aside +
    +
    +
    +
    +
    +
    +
    +
    + Input +
    +
    +
    +
    +
    +
    + Response +
    +
    +
    +
    +
    +
    + Console/Inspector +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Layout With Slots 1`] = ` +
    +
    + +
    +
    +
    +
    +
    + Input +
    +
    +
    +
    +
    +
    + Response +
    +
    +
    +
    +
    +
    + Console/Inspector +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Layout With Strings Only 1`] = ` +
    +
    + nav +
    +
    +
    + sidebar +
    +
    +
    +
    + input +
    +
    + response +
    +
    + console +
    +
    +
    +`; + +exports[`Storyshots Lists With Flex Child 1`] = ` +
    +
    +
    +
    + Lists are a vertical stack of components and form the basis of most modules. This one is very long +
    +
    +
    + You normally want 1 flex area that grows forever like this one +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    + the end +
    +
    +
    +`; + +exports[`Storyshots Lists With Stacked Rows 1`] = ` +
    +
    +
    + Title +
    +
    + Navigation +
    +
    + Search +
    +
    + Filter +
    +
    + Actual content +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    +
    + scroll +
    + Actual content ends here +
    +
    + Footer +
    +
    + Footers footer +
    +
    +
    +`; + +exports[`Storyshots Tabbar Tabbar 1`] = ` +
    +
    +
    +
      +
    • + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    +
    +
    +
      +
    • + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    +
    +
    +
    +
      +
    • + +
    • +
    • +
      + +
    • +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Toolbar Basic 1`] = ` +
    +
    +
    +

    + Toolbars group together widgets in a flexbox. You can cutomize what type of + justification to use and if elements go together it'll add dividers + between them +

    +
    +
    +
    +
      +
    • +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    • +
      +
      + Some text +
      +
    • +
    +
    +
    +
    +
    +
    + Some text +
    +
    + Some text +
    +
    + Some text +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Toolbar Toolbar With Tabs 1`] = ` +
    +
    +
    +

    + The dividers don't nest so if you have tabs inside a toolbar the tabs won't get dividers +

    +
    +
    +
    +
      +
    • +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    • +
      +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    • +
      +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    +
    +
    +
    +
    +
      +
    • +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    • +
      +
        +
      • + +
      • +
      • +
        + +
      • +
      • +
        + +
      • +
      +
    • +
    +
    +
    +
    +
    +
      +
    • + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    +
      +
    • + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    +
    +
    +
    +
    +`; + +exports[`Storyshots Type Type 1`] = ` +
    +
    +
    +

    + Title +

    +
    +
    + + Small explainer text + +
    +
    + Normal text +
    +
    +
    +`; diff --git a/packages/graphiql-2-rfc-context/src/components/common/__tests__/stories.spec.js b/packages/graphiql-2-rfc-context/src/components/common/__tests__/stories.spec.js new file mode 100644 index 00000000000..c26fa6594ff --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/__tests__/stories.spec.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import initStoryshots from '@storybook/addon-storyshots'; +import path from 'path'; + +initStoryshots({ + configPath: path.resolve(__dirname, '../../../.storybook'), +}); diff --git a/packages/graphiql-2-rfc-context/src/components/common/index.tsx b/packages/graphiql-2-rfc-context/src/components/common/index.tsx new file mode 100644 index 00000000000..fb0d867c73f --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/index.tsx @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Export all the shared components for plugin developers :D + */ + +export * from './Logo'; +export * from './Nav'; + +export * from './List'; +export * from './Toolbar'; +export * from './Toolbar/Tabs'; +export * from './Toolbar/Content'; +export * from './Toolbar/support/WithDividers'; +export * from './Type'; diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/README.md b/packages/graphiql-2-rfc-context/src/components/common/themes/README.md new file mode 100644 index 00000000000..34c7f841407 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/README.md @@ -0,0 +1,34 @@ +_work in progress_ + +# What's in a theme? + +Themes export a `` component and a `theme-ui` theme. Smaller themes can just re-export the default layout component and more ambitious themes can provide their own `` and override how the application is rendered. This allows top level markup/layout customization beyond simple aesthetics. + +## The theme-ui theme + +Components that are not the Layout itself rely on the `theme-ui` constants for styling. This is how they can be customized. To make sure this doesn't break, the theme will have some must-have constants yet to be defined. + +## The Layout component + +The Layout component takes a specific shape of `PropTypes` (defined in `themes/constants.js`) that helps it render all necessary elements in place. It's meant to be a controlled component so a higher level router or whatnot in graphiql can actually shuffle the blocks around and drive decisions like routing. + +# Using themes from graphiql/storybook + +`themes/provider.js` exports a context provider that must wrap the app, it also exports a `useThemeLayout` hook that returns the `` component. Currently this is shimmed to return the default theme in any event, but the userland api calls are ready for switching themes (read on:) + +## Switching themes + +_(Not done)_ Just pass the theme object to the provider, the API could look like this: + +```jsx +import ThemeProvider from 'themes/provider.js'; + +import defaultTheme from 'themes/default/index.js'; +import nightTheme from 'themes/night/index.js'; + +export default App = () => ( + + {'...'} + +); +``` diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/decorators.tsx b/packages/graphiql-2-rfc-context/src/components/common/themes/decorators.tsx new file mode 100644 index 00000000000..88a700ecf4b --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/decorators.tsx @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ReactNodeLike } from '../../../types'; + +const styles = { + maxWidth: '60em', + margin: '5em auto', + border: '1px solid #eee', +}; + +export const layout = (storyFn: () => ReactNodeLike) => ( +
    {storyFn()}
    +); diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/default/Layout.tsx b/packages/graphiql-2-rfc-context/src/components/common/themes/default/Layout.tsx new file mode 100644 index 00000000000..45fd96c0a28 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/default/Layout.tsx @@ -0,0 +1,112 @@ +/** @jsx jsx */ + +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { PropsWithChildren } from 'react'; +import { GraphiQLTheme, PanelSize, LayoutPropTypes } from '../types'; + +import { jsx, SxStyleProp } from 'theme-ui'; + +const NAV_WIDTH = '6em'; +const CONTENT_MIN_WIDTH = '60em'; + +function sizeInCSSUnits(theme: GraphiQLTheme, size: PanelSize) { + switch (size) { + case 'sidebar': + return '10em'; + case 'aside': + return '20em'; + default: + return `calc(100vw - ${theme.space[2] * 3}px - ${NAV_WIDTH})`; + } +} + +type CardPropTypes = PropsWithChildren<{ + size?: PanelSize; + transparent?: boolean; + innerSx?: SxStyleProp; +}>; + +const Card = ({ + children, + size, + transparent = false, + innerSx, +}: CardPropTypes) => ( +
    sizeInCSSUnits(theme, size)), + gridTemplate: '100% / 100%', + ...(innerSx ?? {}), + } as SxStyleProp + }> + {children} +
    +); + +const gridBase = { + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: '1fr', + gridAutoRows: '100%', + gap: 3, +}; + +const Layout = ({ nav, navPanels, session }: LayoutPropTypes) => { + const hasNavPanels = (navPanels && navPanels?.length > 0) || false; + return ( +
    + {nav && ( + + {nav} + + )} + {hasNavPanels && ( +
    + {navPanels!.map(({ component, key, size }) => ( + + {component} + + ))} +
    + )} + {session && ( +
    + {session.input} + {session.response} + {session.console} +
    + )} +
    + ); +}; + +export default Layout; diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/default/index.ts b/packages/graphiql-2-rfc-context/src/components/common/themes/default/index.ts new file mode 100644 index 00000000000..1ca464a7798 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/default/index.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Layout from './Layout'; +import { Colors, Space, GraphiQLTheme } from '../types'; + +const palette = { + neutral: { + 20: '#999999', + 70: '#333333', + 90: `rgba(0, 0, 0, 0.1)`, + 100: '#fff', + }, + fuchsia: { + 90: 'rgba(254, 247, 252, 0.940177)', + 50: '#E535AB', + }, +}; + +const colors: Colors = { + text: palette.neutral[20], + darkText: palette.neutral[70], + background: palette.fuchsia[90], + cardBackground: palette.neutral[100], + primary: palette.fuchsia[50], + border: palette.neutral[90], +}; + +const space: Space = [0, 5, 10, 15, 20]; +const fontSizes = [12, 16, 20]; + +const theme: GraphiQLTheme = { + fonts: { + body: 'system-ui, sans-serif', + heading: '"Avenir Next", sans-serif', + monospace: 'Menlo, monospace', + }, + fontSizes, + space, + colors, + transitions: ['.25s'], + spaces: { + rowPadding: space[3], + rowMinHeight: space[3] + fontSizes[1] + space[3], + }, + shadows: { + card: `0 0 0 .1px ${colors.border}, 0 1px 4px 0 ${colors.border}`, + primaryUnderline: `inset 0 -4px 0 0 ${colors.primary}`, + underline: `inset 0 -4px 0 0 ${colors.border}`, + }, +}; + +export { Layout, theme }; diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/provider.tsx b/packages/graphiql-2-rfc-context/src/components/common/themes/provider.tsx new file mode 100644 index 00000000000..6bf65cfb40c --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/provider.tsx @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { ThemeProvider } from 'theme-ui'; +import { theme, Layout } from './default'; +import React from 'react'; +import { Global } from '@emotion/core'; + +const Reset = () => ( + ({ + '*': { + margin: 0, + padding: 0, + boxSizing: 'border-box', + listStyle: 'none', + }, + body: { + fontFamily: themeStyles.fonts.body, + fontSize: themeStyles.fontSizes[1], + color: themeStyles.colors.text, + backgroundColor: themeStyles.colors.background, + }, + small: { + fontSize: '100%', + }, + a: { + textDecoration: 'none', + }, + button: { + border: 0, + padding: 0, + fontSize: '100%', + backgroundColor: 'transparent', + }, + })} + /> +); + +export function useThemeLayout() { + return Layout; +} + +export function Provider({ children }: React.PropsWithChildren<{}>) { + return ( + + + {children} + + ); +} + +export default Provider; diff --git a/packages/graphiql-2-rfc-context/src/components/common/themes/types.ts b/packages/graphiql-2-rfc-context/src/components/common/themes/types.ts new file mode 100644 index 00000000000..e7efec66338 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/components/common/themes/types.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Maybe, ReactNodeLike } from '../../../types'; + +export type Color = string; +export type Colors = { + text: Color; + darkText: Color; + background: Color; + cardBackground: Color; + primary: Color; + border: Color; +}; + +export type FontSizes = number[]; + +export type Spaces = { + rowPadding: number; + rowMinHeight: number; +}; + +type Shadow = string; +export type Shadows = { + card: Shadow; + primaryUnderline: Shadow; + underline: Shadow; +}; + +export type Font = string; +export type Fonts = { + body: Font; + heading: Font; + monospace: Font; +}; + +export type Space = number[]; + +export type GraphiQLTheme = { + fonts: Fonts; + fontSizes: number[]; + colors: Colors; + transitions: string[]; + space: Space; + spaces: Spaces; + shadows: Shadows; +}; + +export type PanelSize = 'sidebar' | 'aside' | 'full-screen'; + +/* +Layout components are divided into 3 areas: +- the gql explorer itself, which has 3 panels (input, response, console) +- the side nav +- the nav panels, which are a potentially infinite stack, + they are wrapped in an object that specifies what size they + should render at + +TODO: For the nav we can probably just pass a list oflinks instead of a component +*/ +export type LayoutPropTypes = { + session?: { + input?: ReactNodeLike; + response?: ReactNodeLike; + console?: ReactNodeLike; + }; + nav: ReactNodeLike; + navPanels?: Maybe< + { component?: ReactNodeLike; key?: string | number; size?: PanelSize }[] + >; +}; diff --git a/packages/graphiql-2-rfc-context/src/i18n.ts b/packages/graphiql-2-rfc-context/src/i18n.ts new file mode 100644 index 00000000000..29166aed468 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/i18n.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +import enTranslations from './locales/en/translation.json'; +import enDocExplorer from './locales/en/DocExplorer.json'; +import enToolbar from './locales/en/Toolbar.json'; +import enEditor from './locales/en/Editor.json'; +import enErrors from './locales/en/Errors.json'; +import ruTranslations from './locales/ru/translation.json'; +import ruDocExplorer from './locales/ru/DocExplorer.json'; +import ruToolbar from './locales/ru/Toolbar.json'; +import ruEditor from './locales/ru/Editor.json'; +import ruErrors from './locales/ru/Errors.json'; + +const resources = { + en: { + translations: enTranslations, + DocExplorer: enDocExplorer, + Toolbar: enToolbar, + Editor: enEditor, + Errors: enErrors, + }, + ru: { + translations: ruTranslations, + DocExplorer: ruDocExplorer, + Toolbar: ruToolbar, + Editor: ruEditor, + Errors: ruErrors, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + // Language detector options + detection: { + // order and from where user language should be detected + order: [ + 'querystring', + 'localStorage', + 'navigator', + 'htmlTag', + 'path', + 'subdomain', + ], + + // keys or params to lookup language from + lookupQuerystring: 'lng', + lookupCookie: 'i18next', + lookupLocalStorage: 'i18nextLng', + lookupFromPathIndex: 0, + lookupFromSubdomainIndex: 0, + + // cache user language on + caches: ['localStorage'], + excludeCacheFor: ['cimode'], // languages to not persist (cookie, localStorage) + + // optional expire and domain for set cookie + cookieMinutes: 10, + cookieDomain: window.location.hostname, + + // optional htmlTag with lang attribute, the default is: + htmlTag: document.documentElement, + }, + + // we init with resources + resources, + fallbackLng: { + 'en-US': ['en'], + default: ['en'], + }, + whitelist: ['en', 'ru'], + // // have a common namespace used around the full app + // ns: ['translations'], + defaultNS: 'translation', + load: 'currentOnly', + preload: ['en', 'ru'], + keySeparator: '.', // we use content as keys + nsSeparator: ':', + interpolation: { + escapeValue: false, // not needed for react!! + }, + react: { + wait: true, + }, + }); + +export default i18n; diff --git a/packages/graphiql-2-rfc-context/src/index.ts b/packages/graphiql-2-rfc-context/src/index.ts new file mode 100644 index 00000000000..0631e7df1bd --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { GraphiQL } from './components/GraphiQL'; +import './i18n'; + +export * from './api'; +export * from './components/common'; + +export { GraphiQL }; +export default GraphiQL; diff --git a/packages/graphiql-2-rfc-context/src/locales/en/DocExplorer.json b/packages/graphiql-2-rfc-context/src/locales/en/DocExplorer.json new file mode 100644 index 00000000000..c734816021e --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/en/DocExplorer.json @@ -0,0 +1,16 @@ +{ + "Docs": "Docs", + "Search {{name}}": "Search {{name}}...", + "Schema": "Schema", + "root types": "root types", + "Documentation Explorer": "Documentation Explorer", + "No Schema Available": "No Schema Available", + "A GraphQL schema provides a root type for each kind of operation": "A GraphQL schema provides a root type for each kind of operation.", + "query": "query", + "mutation": "mutation", + "subscription": "subscription", + "Go back to {{value}}": "Go back to {{value}}", + "Close History": "Close History", + "Open Documentation Explorer": "Open Documentation Explorer", + "Close Documentation Explorer": "Close Documentation Explorer" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/en/Editor.json b/packages/graphiql-2-rfc-context/src/locales/en/Editor.json new file mode 100644 index 00000000000..7c6db9381f7 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/en/Editor.json @@ -0,0 +1,6 @@ +{ + "Welcome to GraphiQL": "# Welcome to GraphiQL\\r\\n#\\r\\n# GraphiQL is an in-browser tool for writing, validating, and\\r\\n# testing GraphQL queries.\\r\\n#\\r\\n# Type queries into this side of the screen, and you will see intelligent\\r\\n# typeaheads aware of the current GraphQL type schema and live syntax and\\r\\n# validation errors highlighted within the text.\\r\\n#\\r\\n# GraphQL queries typically start with a \\\"{\\\" character. Lines that starts\\r\\n# with a # are ignored.\\r\\n#\\r\\n# An example GraphQL query might look like:\\r\\n#\\r\\n# {\\r\\n# field(arg: \\\"value\\\") {\\r\\n# subField\\r\\n# }\\r\\n# }\\r\\n#\\r\\n# Keyboard shortcuts:\\r\\n#\\r\\n# Prettify Query: Shift-Ctrl-P (or press the prettify button above)\\r\\n#\\r\\n# Merge Query: Shift-Ctrl-M (or press the merge button above)\\r\\n#\\r\\n# Run Query: Ctrl-Enter (or press the play button above)\\r\\n#\\r\\n# Auto Complete: Ctrl-Space (or just start typing)\\r\\n#", + "Automatically added leaf fields": "Automatically added leaf fields", + "Query Variables": "Query Variables", + "Query Editor": "Query Editor" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/en/Errors.json b/packages/graphiql-2-rfc-context/src/locales/en/Errors.json new file mode 100644 index 00000000000..a2f650ecd16 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/en/Errors.json @@ -0,0 +1,7 @@ +{ + "Fetcher or uri property are required": "fetcher or uri property are required", + "Fetcher did not return a Promise for introspection": "Fetcher did not return a Promise for introspection.", + "Variables are invalid JSON": "Variables are invalid JSON.", + "Variables are not a JSON object": "Variables are not a JSON object.", + "no value resolved": "no value resolved" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/en/Toolbar.json b/packages/graphiql-2-rfc-context/src/locales/en/Toolbar.json new file mode 100644 index 00000000000..5c0fb397d23 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/en/Toolbar.json @@ -0,0 +1,17 @@ +{ + "Execute Query (Ctrl-Enter)": "Execute Query (Ctrl-Enter)", + "Prettify": "Prettify", + "Prettify Query (Shift-Ctrl-P)": "Prettify Query (Shift-Ctrl-P)", + "Merge": "Merge", + "Merge Query (Shift-Ctrl-M)": "Merge Query (Shift-Ctrl-M)", + "Copy": "Copy", + "Copy Query (Shift-Ctrl-C)": "Copy Query (Shift-Ctrl-C)", + "History": "History", + "Show History": "Show History", + "Editor Commands": "Editor Commands", + "Edit label": "Edit label", + "Add favorite": "Add favorite", + "Remove favorite": "Remove favorite", + "Type a label": "Type a label", + "Docs": "Docs" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/en/translation.json b/packages/graphiql-2-rfc-context/src/locales/en/translation.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/en/translation.json @@ -0,0 +1 @@ +{} diff --git a/packages/graphiql-2-rfc-context/src/locales/ru/DocExplorer.json b/packages/graphiql-2-rfc-context/src/locales/ru/DocExplorer.json new file mode 100644 index 00000000000..11a1711d46f --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/ru/DocExplorer.json @@ -0,0 +1,16 @@ +{ + "Docs": "Документация", + "Search {{name}}": "Поиск {{name}}...", + "Schema": "Схема", + "root types": "Корневые типы", + "Documentation Explorer": "Обозреватель документации", + "No Schema Available": "Схема недоступна", + "A GraphQL schema provides a root type for each kind of operation": "GraphQL схема предоставляет корневой тип для каждого вида операций.", + "query": "запрос", + "mutation": "мутация", + "subscription": "подписка", + "Go back to {{value}}": "Вернуться к {{value}}", + "Close History": "Закрыть Историю", + "Open Documentation Explorer": "Открыть обозреватель документации", + "Close Documentation Explorer": "Закрыть обозреватель документации" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/ru/Editor.json b/packages/graphiql-2-rfc-context/src/locales/ru/Editor.json new file mode 100644 index 00000000000..b7258ca23fb --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/ru/Editor.json @@ -0,0 +1,6 @@ +{ + "Welcome to GraphiQL": "# Добро пожаловать в GraphiQL...\n#\n# GraphiQL - это встроенный в браузер инструмент для написания, проверки и\n# тестирование запросов GraphQL.\n#\n# Введите запросы в эту сторону экрана, и вы увидите интеллектуальные заголовки типов\n#, осведомленные о текущей схеме типа GraphQL и текущем синтаксисе, и\n# ошибки валидации выделенные в тексте.\n#\n# Запросы GraphQL обычно начинаются с символа \"{\". Линии, которые начинаются\n# с # игнорируются.\n#\n# Пример запроса GraphQL может выглядеть так:\n#\n# {\n# field(arg: \"value\") {\n# subField\n# }\n# }\n#\n# Горячие клавиши:\n#\n# Форматировать Запрос: Shift-Ctrl-P (или нажмите кнопку форматировать выше)\n#\n# Соединить Запрос: Shift-Ctrl-M (или нажмите кнопку соединить выше)\n#\n# Выполнить Запрос: Ctrl-Enter (или нажмите кнопку выполнить выше)\n#\n# Автозаполнение: Ctrl-пробел (или просто начать печатать)\n#\n", + "Automatically added leaf fields": "Автоматически добавлены поля листа", + "Query Variables": "Переменные Запроса", + "Query Editor": "Редактор запросов" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/ru/Errors.json b/packages/graphiql-2-rfc-context/src/locales/ru/Errors.json new file mode 100644 index 00000000000..5c4eebde47e --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/ru/Errors.json @@ -0,0 +1,7 @@ +{ + "Fetcher or uri property are required": "требуется свойство Сборщик или uri", + "Fetcher did not return a Promise for introspection": "Сборщик не вернул Promise для самоанализа", + "Variables are invalid JSON": "Переменные несоответствуют JSON спецификации", + "Variables are not a JSON object": "Переменные не являются JSON объектом", + "no value resolved": "значение не разрешено." +} diff --git a/packages/graphiql-2-rfc-context/src/locales/ru/Toolbar.json b/packages/graphiql-2-rfc-context/src/locales/ru/Toolbar.json new file mode 100644 index 00000000000..2e685a9b1cd --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/ru/Toolbar.json @@ -0,0 +1,17 @@ +{ + "Execute Query (Ctrl-Enter)": "Выполнить Запрос (Ctrl-Enter)", + "Prettify": "Форматировать", + "Prettify Query (Shift-Ctrl-P)": "Форматировать Запрос (Shift-Ctrl-P)", + "Merge": "Соединить", + "Merge Query (Shift-Ctrl-M)": "Соединить Запрос (Shift-Ctrl-M)", + "Copy": "Копировать", + "Copy Query (Shift-Ctrl-C)": "Копировать Запрос (Shift-Ctrl-C)", + "History": "История", + "Show History": "Показать Историю", + "Editor Commands": "Редактор Команд", + "Edit label": "Изменить надпись", + "Add favorite": "Добавить в избранное", + "Remove favorite": "Удалить из избранного", + "Type a label": "Тип надписи", + "Docs": "Документация" +} diff --git a/packages/graphiql-2-rfc-context/src/locales/ru/translation.json b/packages/graphiql-2-rfc-context/src/locales/ru/translation.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/locales/ru/translation.json @@ -0,0 +1 @@ +{} diff --git a/packages/graphiql-2-rfc-context/src/types/global.ts b/packages/graphiql-2-rfc-context/src/types/global.ts new file mode 100644 index 00000000000..d15b9985d21 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/types/global.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * */ + +interface Window { + MonacoEnvironment: any; + GraphiQL: any; +} diff --git a/packages/graphiql-2-rfc-context/src/types/index.ts b/packages/graphiql-2-rfc-context/src/types/index.ts new file mode 100644 index 00000000000..2f826bd286a --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/types/index.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * */ + +// / eslint-disable-next-line spaced-comment +// / + +import { GraphQLType } from 'graphql'; + +export namespace GraphiQL { + export type GetDefaultFieldNamesFn = (type: GraphQLType) => string[]; +} + +export type Maybe = T | null | undefined; + +export type ReactComponentLike = + | string + | ((props: any, context?: any) => any) + | (new (props: any, context?: any) => any); + +export type ReactElementLike = { + type: ReactComponentLike; + props: any; + key: string | number | null; +}; +// These type just taken from https://github.com/ReactiveX/rxjs/blob/main/src/internal/types.ts#L41 +export type Unsubscribable = { + unsubscribe: () => void; +}; + +export type Observable = { + subscribe(opts: { + next: (value: T) => void; + error: (error: any) => void; + complete: () => void; + }): Unsubscribable; + subscribe( + next: (value: T) => void, + error: null | undefined, + complete: () => void, + ): Unsubscribable; + subscribe( + next?: (value: T) => void, + error?: (error: any) => void, + complete?: () => void, + ): Unsubscribable; +}; + +export type SchemaConfig = { + uri: string; + assumeValid?: boolean; +}; + +export type FetcherParams = { + query: string; + operationName?: string; + variables?: string; +}; + +export type FetcherResult = string; + +export type EditorOptions = monaco.editor.IEditorOptions & + monaco.editor.IGlobalEditorOptions; + +export type Fetcher = ( + graphQLParams: FetcherParams, +) => Promise | Observable; + +export type ReactNodeLike = + | {} + | ReactElementLike + | Array + | string + | number + | boolean + | null + | undefined; diff --git a/packages/graphiql-2-rfc-context/src/types/worker-loader.ts b/packages/graphiql-2-rfc-context/src/types/worker-loader.ts new file mode 100644 index 00000000000..89a96ba531d --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/types/worker-loader.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * */ + +declare module 'worker-loader!*' { + export default class WebpackWorker extends Worker { + constructor(); + } +} diff --git a/packages/graphiql-2-rfc-context/src/utility/QueryStore.ts b/packages/graphiql-2-rfc-context/src/utility/QueryStore.ts new file mode 100644 index 00000000000..3d1e67fbf3c --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/QueryStore.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import StorageAPI from './StorageAPI'; + +export type QueryStoreItem = { + query?: string; + variables?: string; + headers?: string; + operationName?: string; + label?: string; + favorite?: boolean; +}; + +export default class QueryStore { + items: Array; + + constructor( + private key: string, + private storage: StorageAPI, + private maxSize: number | null = null, + ) { + this.items = this.fetchAll(); + } + + get length() { + return this.items.length; + } + + contains(item: QueryStoreItem) { + return this.items.some( + x => + x.query === item.query && + x.variables === item.variables && + x.headers === item.headers && + x.operationName === item.operationName, + ); + } + + edit(item: QueryStoreItem) { + const itemIndex = this.items.findIndex( + x => + x.query === item.query && + x.variables === item.variables && + x.headers === item.headers && + x.operationName === item.operationName, + ); + if (itemIndex !== -1) { + this.items.splice(itemIndex, 1, item); + this.save(); + } + } + + delete(item: QueryStoreItem) { + const itemIndex = this.items.findIndex( + x => + x.query === item.query && + x.variables === item.variables && + x.headers === item.headers && + x.operationName === item.operationName, + ); + if (itemIndex !== -1) { + this.items.splice(itemIndex, 1); + this.save(); + } + } + + fetchRecent() { + return this.items[this.items.length - 1]; + } + + fetchAll() { + const raw = this.storage.get(this.key); + if (raw) { + return JSON.parse(raw)[this.key] as Array; + } + return []; + } + + push(item: QueryStoreItem) { + const items = [...this.items, item]; + + if (this.maxSize && items.length > this.maxSize) { + items.shift(); + } + + for (let attempts = 0; attempts < 5; attempts++) { + const response = this.storage.set( + this.key, + JSON.stringify({ [this.key]: items }), + ); + if (!response || !response.error) { + this.items = items; + } else if (response.isQuotaError && this.maxSize) { + // Only try to delete last items on LRU stores + items.shift(); + } else { + return; // We don't know what happened in this case, so just bailing out + } + } + } + + save() { + this.storage.set(this.key, JSON.stringify({ [this.key]: this.items })); + } +} diff --git a/packages/graphiql-2-rfc-context/src/utility/StorageAPI.ts b/packages/graphiql-2-rfc-context/src/utility/StorageAPI.ts new file mode 100644 index 00000000000..9427c3f1330 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/StorageAPI.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export interface Storage { + getItem: (key: string) => string | null; + removeItem: (key: string) => void; + setItem: (key: string, value: string) => void; + length: number; +} + +function isQuotaError(storage: Storage, e: Error) { + return ( + e instanceof DOMException && + // everything except Firefox + (e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === 'QuotaExceededError' || + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + // acknowledge QuotaExceededError only if there's something already stored + storage.length !== 0 + ); +} + +export default class StorageAPI { + storage: Storage | null; + + constructor(storage?: Storage) { + this.storage = + storage || (typeof window !== 'undefined' ? window.localStorage : null); + } + + get(name: string): string | null { + if (this.storage) { + const value = this.storage.getItem('graphiql:' + name); + // Clean up any inadvertently saved null/undefined values. + if (value === 'null' || value === 'undefined') { + this.storage.removeItem('graphiql:' + name); + return null; + } + + if (value) { + return value; + } + } + return null; + } + + set(name: string, value: string) { + let quotaError = false; + let error = null; + + if (this.storage) { + const key = `graphiql:${name}`; + if (value) { + try { + this.storage.setItem(key, value); + } catch (e) { + error = e; + quotaError = isQuotaError(this.storage, e); + } + } else { + // Clean up by removing the item if there's no value to set + this.storage.removeItem(key); + } + } + + return { + isQuotaError: quotaError, + error, + }; + } +} diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/QueryStore.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/QueryStore.spec.ts new file mode 100644 index 00000000000..5cf44f1de27 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/QueryStore.spec.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import QueryStore from '../QueryStore'; +import StorageAPI from '../StorageAPI'; + +class StorageMock { + shouldThrow: () => boolean; + count: number; + map: any; + storage: Storage; + constructor(shouldThrow: () => boolean) { + this.shouldThrow = shouldThrow; + this.map = {}; + } + + set(key: string, value: string) { + this.count++; + + if (this.shouldThrow()) { + return { + error: {}, + isQuotaError: true, + storageAvailable: true, + }; + } + + this.map[key] = value; + + return { + error: null, + isQuotaError: false, + storageAvailable: true, + }; + } + + get(key: string) { + return this.map[key] || null; + } +} + +describe('QueryStore', () => { + describe('with no max items', () => { + it('can push multiple items', () => { + const store = new QueryStore('normal', new StorageAPI()); + + for (let i = 0; i < 100; i++) { + store.push({ query: `item${i}` }); + } + + expect(store.items.length).toBe(100); + }); + + it('will fail silently on quota error', () => { + let i = 0; + const store = new QueryStore('normal', new StorageMock(() => i > 4)); + + for (; i < 10; i++) { + store.push({ query: `item${i}` }); + } + + expect(store.items.length).toBe(5); + expect(store.items[0].query).toBe('item0'); + expect(store.items[4].query).toBe('item4'); + }); + }); + + describe('with max items', () => { + it('can push a limited number of items', () => { + const store = new QueryStore('limited', new StorageAPI(), 20); + + for (let i = 0; i < 100; i++) { + store.push({ query: `item${i}` }); + } + + expect(store.items.length).toBe(20); + // keeps the more recent items + expect(store.items[0].query).toBe('item80'); + expect(store.items[19].query).toBe('item99'); + }); + + it('tries to remove on quota error until it succeeds', () => { + let shouldThrow: () => boolean; + let retryCounter = 0; + const store = new QueryStore( + 'normal', + new StorageMock(() => { + retryCounter++; + return shouldThrow(); + }), + 10, + ); + + for (let i = 0; i < 20; i++) { + shouldThrow = () => false; + store.push({ query: `item${i}` }); + } + + expect(store.items.length).toBe(10); + // keeps the more recent items + expect(store.items[0].query).toBe('item10'); + expect(store.items[9].query).toBe('item19'); + + // tries to add an item, succeeds on 3rd try + retryCounter = 0; + shouldThrow = () => retryCounter < 3; + store.push({ query: `finalItem` }); + + expect(store.items.length).toBe(8); + expect(store.items[0].query).toBe('item13'); + expect(store.items[7].query).toBe('finalItem'); + }); + + it('tries to remove a maximum of 5 times', () => { + let shouldThrow: () => boolean; + let retryCounter = 0; + const store = new QueryStore( + 'normal', + new StorageMock(() => { + retryCounter++; + return shouldThrow(); + }), + 10, + ); + + for (let i = 0; i < 20; i++) { + shouldThrow = () => false; + store.push({ query: `item${i}` }); + } + + expect(store.items.length).toBe(10); + // keeps the more recent items + expect(store.items[0].query).toBe('item10'); + expect(store.items[9].query).toBe('item19'); + + // tries to add an item, keeps failing + retryCounter = 0; + shouldThrow = () => true; + store.push({ query: `finalItem` }); + + expect(store.items.length).toBe(10); + // kept the items + expect(store.items[0].query).toBe('item10'); + expect(store.items[9].query).toBe('item19'); + // retried 5 times + expect(retryCounter).toBe(5); + }); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/StorageAPI.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/StorageAPI.spec.ts new file mode 100644 index 00000000000..9b58a6a4966 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/StorageAPI.spec.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import StorageAPI from '../StorageAPI'; + +describe('StorageAPI', () => { + const storage = new StorageAPI(); + + it('returns nothing if no value set', () => { + const result = storage.get('key1'); + expect(result).toBeNull(); + }); + + it('sets and gets a value correctly', () => { + const result = storage.set('key2', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false, + }); + + const newResult = storage.get('key2'); + expect(newResult).toEqual('value'); + }); + + it('sets and removes a value correctly', () => { + let result = storage.set('key3', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false, + }); + + result = storage.set('key3', ''); + expect(result).toEqual({ + error: null, + isQuotaError: false, + }); + + const getResult = storage.get('key3'); + expect(getResult).toBeNull(); + }); + + it('sets and overrides a value correctly', () => { + let result = storage.set('key4', 'value'); + expect(result).toEqual({ + error: null, + isQuotaError: false, + }); + + result = storage.set('key4', 'value2'); + expect(result).toEqual({ + error: null, + isQuotaError: false, + }); + + const getResult = storage.get('key4'); + expect(getResult).toEqual('value2'); + }); + + it('cleans up `null` value', () => { + storage.set('key5', 'null'); + const result = storage.get('key5'); + expect(result).toBeNull(); + }); + + it('cleans up `undefined` value', () => { + storage.set('key6', 'undefined'); + const result = storage.get('key6'); + expect(result).toBeNull(); + }); + + it('returns any error while setting a value', () => { + // @ts-ignore + const throwingStorage = new StorageAPI({ + setItem: () => { + throw new DOMException('Terrible Error'); + }, + length: 1, + }); + const result = throwingStorage.set('key', 'value'); + + expect(result.error.message).toEqual('Terrible Error'); + expect(result.isQuotaError).toBe(false); + }); + + it('returns isQuotaError to true if isQuotaError is thrown', () => { + // @ts-ignore + const throwingStorage = new StorageAPI({ + setItem: () => { + throw new DOMException('Terrible Error', 'QuotaExceededError'); + }, + length: 1, + }); + const result = throwingStorage.set('key', 'value'); + + expect(result.error.message).toEqual('Terrible Error'); + expect(result.isQuotaError).toBe(true); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/find.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/find.spec.ts new file mode 100644 index 00000000000..63ba942cf19 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/find.spec.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import find from '../find'; + +describe('Find', () => { + it('should return first array element which returns true for predicate', () => { + expect(find([1, 2, 3], num => num === 2)).toEqual(2); + }); + + it("should return undefined if element which returns true for predicate doesn't exist in the array", () => { + expect(find([1, 2, 3], num => num === 4)).toBeUndefined(); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/getQueryFacts.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/getQueryFacts.spec.ts new file mode 100644 index 00000000000..6cbd934aa55 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/getQueryFacts.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse, +} from 'graphql'; + +import { collectVariables } from '../getQueryFacts'; + +describe('collectVariables', () => { + const TestType = new GraphQLObjectType({ + name: 'Test', + fields: { + id: { type: GraphQLID }, + string: { type: GraphQLString }, + int: { type: GraphQLInt }, + float: { type: GraphQLFloat }, + boolean: { type: GraphQLBoolean }, + }, + }); + + const TestSchema = new GraphQLSchema({ + query: TestType, + }); + + it('returns an empty object if no variables exist', () => { + const variableToType = collectVariables(TestSchema, parse('{ id }')); + expect(variableToType).toEqual({}); + }); + + it('collects variable types from a schema and query', () => { + const variableToType = collectVariables( + TestSchema, + parse(` + query ($foo: Int, $bar: String) { id } + `), + ); + expect(Object.keys(variableToType)).toEqual(['foo', 'bar']); + expect(variableToType.foo).toEqual(GraphQLInt); + expect(variableToType.bar).toEqual(GraphQLString); + }); + + it('collects variable types from multiple queries', () => { + const variableToType = collectVariables( + TestSchema, + parse(` + query A($foo: Int, $bar: String) { id } + query B($foo: Int, $baz: Float) { id } + `), + ); + expect(Object.keys(variableToType)).toEqual(['foo', 'bar', 'baz']); + expect(variableToType.foo).toEqual(GraphQLInt); + expect(variableToType.bar).toEqual(GraphQLString); + expect(variableToType.baz).toEqual(GraphQLFloat); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst-fixture.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst-fixture.ts new file mode 100644 index 00000000000..3414db8b272 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst-fixture.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const fixtures = [ + { + desc: 'does not modify query with no fragments', + query: ` + query Test { + id + }`, + mergedQuery: ` + query Test { + id + }`, + mergedQueryWithSchema: ` + query Test { + id + }`, + }, + { + desc: 'inlines simple nested fragment', + query: ` + query Test { + ...Fragment1 + } + + fragment Fragment1 on Test { + id + }`, + mergedQuery: ` + query Test { + ...on Test { + id + } + }`, + mergedQueryWithSchema: ` + query Test { + id + }`, + }, + { + desc: 'inlines triple nested fragment', + query: ` + query Test { + ...Fragment1 + } + + fragment Fragment1 on Test { + ...Fragment2 + } + + fragment Fragment2 on Test { + ...Fragment3 + } + + fragment Fragment3 on Test { + id + }`, + mergedQuery: ` + query Test { + ...on Test { + ...on Test { + ...on Test { + id + } + } + } + }`, + mergedQueryWithSchema: ` + query Test { + id + }`, + }, + { + desc: 'inlines multiple fragments', + query: ` + query Test { + ...Fragment1 + ...Fragment2 + ...Fragment3 + } + + fragment Fragment1 on Test { + id + } + + fragment Fragment2 on Test { + id + } + + fragment Fragment3 on Test { + id + }`, + mergedQuery: ` + query Test { + ...on Test { + id + } + ...on Test { + id + } + ...on Test { + id + } + }`, + mergedQueryWithSchema: ` + query Test { + id + }`, + }, + { + desc: 'removes duplicate fragment spreads', + query: ` + query Test { + ...Fragment1 + ...Fragment1 + } + + fragment Fragment1 on Test { + id + }`, + mergedQuery: ` + query Test { + ...on Test { + id + } + }`, + mergedQueryWithSchema: ` + query Test { + id + }`, + }, +]; diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst.spec.ts new file mode 100644 index 00000000000..175f0a3f9bc --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/mergeAst.spec.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + parse, + print, + GraphQLSchema, + GraphQLObjectType, + GraphQLInt, +} from 'graphql'; + +import mergeAst from '../mergeAst'; + +import { fixtures } from './mergeAst-fixture'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Test', + fields: { + id: { + type: GraphQLInt, + }, + }, + }), +}); + +describe('MergeAst', () => { + fixtures.forEach(fixture => { + it(fixture.desc, () => { + const result = print(mergeAst(parse(fixture.query))).replace(/\s/g, ''); + const result2 = print(mergeAst(parse(fixture.query), schema)).replace( + /\s/g, + '', + ); + expect(result).toEqual(fixture.mergedQuery.replace(/\s/g, '')); + expect(result2).toEqual(fixture.mergedQueryWithSchema.replace(/\s/g, '')); + }); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/__tests__/normalizeWhitespace.spec.ts b/packages/graphiql-2-rfc-context/src/utility/__tests__/normalizeWhitespace.spec.ts new file mode 100644 index 00000000000..b3b189be52f --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/__tests__/normalizeWhitespace.spec.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { invalidCharacters, normalizeWhitespace } from '../normalizeWhitespace'; + +describe('QueryEditor', () => { + it('removes unicode characters', () => { + const result = normalizeWhitespace(invalidCharacters.join('')); + expect(result).toEqual(' '.repeat(invalidCharacters.length)); + }); +}); diff --git a/packages/graphiql-2-rfc-context/src/utility/commonKeys.ts b/packages/graphiql-2-rfc-context/src/utility/commonKeys.ts new file mode 100644 index 00000000000..deda96d2313 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/commonKeys.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +let isMacOs = false; + +if (typeof window === 'object') { + isMacOs = window.navigator.platform === 'MacIntel'; +} + +const commonKeys = { + // Persistent search box in Query Editor + [isMacOs ? 'Cmd-F' : 'Ctrl-F']: 'findPersistent', + 'Cmd-G': 'findPersistent', + 'Ctrl-G': 'findPersistent', + + // Editor improvements + 'Ctrl-Left': 'goSubwordLeft', + 'Ctrl-Right': 'goSubwordRight', + 'Alt-Left': 'goGroupLeft', + 'Alt-Right': 'goGroupRight', +}; + +export default commonKeys; diff --git a/packages/graphiql-2-rfc-context/src/utility/debounce.ts b/packages/graphiql-2-rfc-context/src/utility/debounce.ts new file mode 100644 index 00000000000..323d6c13bc4 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/debounce.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Provided a duration and a function, returns a new function which is called + * `duration` milliseconds after the last call. + */ +export default function debounce any>( + duration: number, + fn: F, +) { + let timeout: number | null; + return function (this: any, ...args: Parameters) { + if (timeout) { + window.clearTimeout(timeout); + } + timeout = window.setTimeout(() => { + timeout = null; + fn.apply(this, args); + }, duration); + }; +} diff --git a/packages/graphiql-2-rfc-context/src/utility/elementPosition.ts b/packages/graphiql-2-rfc-context/src/utility/elementPosition.ts new file mode 100644 index 00000000000..ca24d8d5594 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/elementPosition.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Utility functions to get a pixel distance from left/top of the window. + */ + +export function getLeft(initialElem: HTMLElement) { + let pt = 0; + let elem = initialElem; + while (elem.offsetParent) { + pt += elem.offsetLeft; + elem = elem.offsetParent as HTMLElement; + } + return pt; +} + +export function getTop(initialElem: HTMLElement) { + let pt = 0; + let elem = initialElem; + while (elem.offsetParent) { + pt += elem.offsetTop; + elem = elem.offsetParent as HTMLElement; + } + return pt; +} diff --git a/packages/graphiql-2-rfc-context/src/utility/fillLeafs.ts b/packages/graphiql-2-rfc-context/src/utility/fillLeafs.ts new file mode 100644 index 00000000000..bd4c4a32cca --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/fillLeafs.ts @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + getNamedType, + isLeafType, + parse, + print, + TypeInfo, + visit, + GraphQLSchema, + DocumentNode, + GraphQLOutputType, + GraphQLType, + SelectionSetNode, +} from 'graphql'; + +import { Maybe } from '../components/GraphiQL'; + +type Insertion = { + index: number; + string: string; +}; + +export type GetDefaultFieldNamesFn = (type: GraphQLType) => string[]; + +/** + * Given a document string which may not be valid due to terminal fields not + * representing leaf values (Spec Section: "Leaf Field Selections"), and a + * function which provides reasonable default field names for a given type, + * this function will attempt to produce a schema which is valid after filling + * in selection sets for the invalid fields. + * + * Note that there is no guarantee that the result will be a valid query, this + * utility represents a "best effort" which may be useful within IDE tools. + */ +export function fillLeafs( + schema?: GraphQLSchema, + docString?: string, + getDefaultFieldNames?: GetDefaultFieldNamesFn, +) { + const insertions: Insertion[] = []; + + if (!schema || !docString) { + return { insertions, result: docString }; + } + + let ast: DocumentNode; + try { + ast = parse(docString); + } catch (error) { + return { insertions, result: docString }; + } + + const fieldNameFn = getDefaultFieldNames || defaultGetDefaultFieldNames; + const typeInfo = new TypeInfo(schema); + visit(ast, { + leave(node) { + typeInfo.leave(node); + }, + enter(node) { + typeInfo.enter(node); + if (node.kind === 'Field' && !node.selectionSet) { + const fieldType = typeInfo.getType(); + const selectionSet = buildSelectionSet( + isFieldType(fieldType) as GraphQLOutputType, + fieldNameFn, + ); + if (selectionSet && node.loc) { + const indent = getIndentation(docString, node.loc.start); + insertions.push({ + index: node.loc.end, + string: ' ' + print(selectionSet).replace(/\n/g, '\n' + indent), + }); + } + } + }, + }); + + // Apply the insertions, but also return the insertions metadata. + return { + insertions, + result: withInsertions(docString, insertions), + }; +} + +// The default function to use for producing the default fields from a type. +// This function first looks for some common patterns, and falls back to +// including all leaf-type fields. +function defaultGetDefaultFieldNames(type: GraphQLType) { + // If this type cannot access fields, then return an empty set. + // if (!type.getFields) { + if (!('getFields' in type)) { + return []; + } + + const fields = type.getFields(); + + // Is there an `id` field? + if (fields.id) { + return ['id']; + } + + // Is there an `edges` field? + if (fields.edges) { + return ['edges']; + } + + // Is there an `node` field? + if (fields.node) { + return ['node']; + } + + // Include all leaf-type fields. + const leafFieldNames: Array = []; + Object.keys(fields).forEach(fieldName => { + if (isLeafType(fields[fieldName].type)) { + leafFieldNames.push(fieldName); + } + }); + return leafFieldNames; +} + +// Given a GraphQL type, and a function which produces field names, recursively +// generate a SelectionSet which includes default fields. +function buildSelectionSet( + type: GraphQLOutputType, + getDefaultFieldNames: GetDefaultFieldNamesFn, +): SelectionSetNode | undefined { + // Unwrap any non-null or list types. + const namedType = getNamedType(type); + + // Unknown types and leaf types do not have selection sets. + if (!type || isLeafType(type)) { + return; + } + + // Get an array of field names to use. + const fieldNames = getDefaultFieldNames(namedType); + + // If there are no field names to use, return no selection set. + if ( + !Array.isArray(fieldNames) || + fieldNames.length === 0 || + !('getFields' in namedType) + ) { + return; + } + + // Build a selection set of each field, calling buildSelectionSet recursively. + return { + kind: 'SelectionSet', + selections: fieldNames.map(fieldName => { + const fieldDef = namedType.getFields()[fieldName]; + const fieldType = fieldDef ? fieldDef.type : null; + return { + kind: 'Field', + name: { + kind: 'Name', + value: fieldName, + }, + // we can use as here, because we already know that fieldType + // comes from an origin parameter + selectionSet: buildSelectionSet( + fieldType as GraphQLOutputType, + getDefaultFieldNames, + ), + }; + }), + }; +} + +// Given an initial string, and a list of "insertion" { index, string } objects, +// return a new string with these insertions applied. +function withInsertions(initial: string, insertions: Insertion[]) { + if (insertions.length === 0) { + return initial; + } + let edited = ''; + let prevIndex = 0; + insertions.forEach(({ index, string }) => { + edited += initial.slice(prevIndex, index) + string; + prevIndex = index; + }); + edited += initial.slice(prevIndex); + return edited; +} + +// Given a string and an index, look backwards to find the string of whitespace +// following the next previous line break. +function getIndentation(str: string, index: number) { + let indentStart = index; + let indentEnd = index; + while (indentStart) { + const c = str.charCodeAt(indentStart - 1); + // line break + if (c === 10 || c === 13 || c === 0x2028 || c === 0x2029) { + break; + } + indentStart--; + // not white space + if (c !== 9 && c !== 11 && c !== 12 && c !== 32 && c !== 160) { + indentEnd = indentStart; + } + } + return str.substring(indentStart, indentEnd); +} + +function isFieldType( + fieldType: Maybe, +): GraphQLOutputType | void { + if (fieldType) { + return fieldType; + } +} diff --git a/packages/graphiql-2-rfc-context/src/utility/find.ts b/packages/graphiql-2-rfc-context/src/utility/find.ts new file mode 100644 index 00000000000..f70b0451886 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/find.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default function find( + list: Array, + predicate: (item: T) => boolean, +): T | void { + for (let i = 0; i < list.length; i++) { + if (predicate(list[i])) { + return list[i]; + } + } +} diff --git a/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts b/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts new file mode 100644 index 00000000000..edf7bd74817 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/getQueryFacts.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + parse, + typeFromAST, + GraphQLSchema, + DocumentNode, + OperationDefinitionNode, + NamedTypeNode, + GraphQLNamedType, +} from 'graphql'; + +export type VariableToType = { + [variable: string]: GraphQLNamedType; +}; + +export type QueryFacts = { + variableToType?: VariableToType; + operations?: OperationDefinitionNode[]; +}; + +/** + * Provided previous "queryFacts", a GraphQL schema, and a query document + * string, return a set of facts about that query useful for GraphiQL features. + * + * If the query cannot be parsed, returns undefined. + */ +export default function getQueryFacts( + schema?: GraphQLSchema, + documentStr?: string | null, +): QueryFacts | undefined { + if (!documentStr) { + return; + } + + let documentAST: DocumentNode; + try { + documentAST = parse(documentStr); + } catch { + return; + } + + const variableToType = schema + ? collectVariables(schema, documentAST) + : undefined; + + // Collect operations by their names. + const operations: OperationDefinitionNode[] = []; + documentAST.definitions.forEach(def => { + if (def.kind === 'OperationDefinition') { + operations.push(def); + } + }); + + return { variableToType, operations }; +} + +/** + * Provided a schema and a document, produces a `variableToType` Object. + */ +export function collectVariables( + schema: GraphQLSchema, + documentAST: DocumentNode, +): VariableToType { + const variableToType: { + [variable: string]: GraphQLNamedType; + } = Object.create(null); + documentAST.definitions.forEach(definition => { + if (definition.kind === 'OperationDefinition') { + const variableDefinitions = definition.variableDefinitions; + if (variableDefinitions) { + variableDefinitions.forEach(({ variable, type }) => { + const inputType = typeFromAST(schema, type as NamedTypeNode); + if (inputType) { + variableToType[variable.name.value] = inputType; + } + }); + } + } + }); + return variableToType; +} diff --git a/packages/graphiql-2-rfc-context/src/utility/getSelectedOperationName.ts b/packages/graphiql-2-rfc-context/src/utility/getSelectedOperationName.ts new file mode 100644 index 00000000000..00fb3450017 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/getSelectedOperationName.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { OperationDefinitionNode } from 'graphql'; + +/** + * Provided optional previous operations and selected name, and a next list of + * operations, determine what the next selected operation should be. + */ +export default function getSelectedOperationName( + prevOperations?: OperationDefinitionNode[] | undefined, + prevSelectedOperationName?: string, + operations?: OperationDefinitionNode[], +) { + // If there are not enough operations to bother with, return nothing. + if (!operations || operations.length < 1) { + return; + } + + // If a previous selection still exists, continue to use it. + const names = operations.map(op => op.name && op.name.value); + if ( + prevSelectedOperationName && + names.indexOf(prevSelectedOperationName) !== -1 + ) { + return prevSelectedOperationName; + } + + // If a previous selection was the Nth operation, use the same Nth. + if (prevSelectedOperationName && prevOperations) { + const prevNames = prevOperations.map(op => op.name && op.name.value); + const prevIndex = prevNames.indexOf(prevSelectedOperationName); + if (prevIndex !== -1 && prevIndex < names.length) { + return names[prevIndex]; + } + } + + // Use the first operation. + return names[0]; +} diff --git a/packages/graphiql-2-rfc-context/src/utility/introspectionQueries.ts b/packages/graphiql-2-rfc-context/src/utility/introspectionQueries.ts new file mode 100644 index 00000000000..3bdeb634421 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/introspectionQueries.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { getIntrospectionQuery } from 'graphql'; + +export const introspectionQuery = getIntrospectionQuery(); + +export const staticName = 'IntrospectionQuery'; + +export const introspectionQueryName = staticName; + +// Some GraphQL services do not support subscriptions and fail an introspection +// query which includes the `subscriptionType` field as the stock introspection +// query does. This backup query removes that field. +export const introspectionQuerySansSubscriptions = introspectionQuery.replace( + 'subscriptionType { name }', + '', +); diff --git a/packages/graphiql-2-rfc-context/src/utility/mergeAst.ts b/packages/graphiql-2-rfc-context/src/utility/mergeAst.ts new file mode 100644 index 00000000000..8fb729e1538 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/mergeAst.ts @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + DocumentNode, + FieldNode, + FragmentDefinitionNode, + GraphQLOutputType, + GraphQLSchema, + SelectionNode, + TypeInfo, + getNamedType, + visit, + visitWithTypeInfo, + ASTKindToNode, + Visitor, + ASTNode, +} from 'graphql'; +import Maybe from 'graphql/tsutils/Maybe'; + +export function uniqueBy( + array: readonly SelectionNode[], + iteratee: (item: FieldNode) => T, +) { + const FilteredMap = new Map(); + const result: SelectionNode[] = []; + for (const item of array) { + if (item.kind === 'Field') { + const uniqueValue = iteratee(item); + const existing = FilteredMap.get(uniqueValue); + if (item.directives && item.directives.length) { + // Cannot inline fields with directives (yet) + const itemClone = { ...item }; + result.push(itemClone); + } else if (existing && existing.selectionSet && item.selectionSet) { + // Merge the selection sets + existing.selectionSet.selections = [ + ...existing.selectionSet.selections, + ...item.selectionSet.selections, + ]; + } else if (!existing) { + const itemClone = { ...item }; + FilteredMap.set(uniqueValue, itemClone); + result.push(itemClone); + } + } else { + result.push(item); + } + } + return result; +} + +export function inlineRelevantFragmentSpreads( + fragmentDefinitions: { + [key: string]: FragmentDefinitionNode | undefined; + }, + selections: readonly SelectionNode[], + selectionSetType?: Maybe, +): readonly SelectionNode[] { + const selectionSetTypeName = selectionSetType + ? getNamedType(selectionSetType).name + : null; + const outputSelections = []; + const seenSpreads = []; + for (let selection of selections) { + if (selection.kind === 'FragmentSpread') { + const fragmentName = selection.name.value; + if (!selection.directives || selection.directives.length === 0) { + if (seenSpreads.indexOf(fragmentName) >= 0) { + /* It's a duplicate - skip it! */ + continue; + } else { + seenSpreads.push(fragmentName); + } + } + const fragmentDefinition = fragmentDefinitions[selection.name.value]; + if (fragmentDefinition) { + const { typeCondition, directives, selectionSet } = fragmentDefinition; + selection = { + kind: 'InlineFragment', + typeCondition, + directives, + selectionSet, + }; + } + } + if ( + selection.kind === 'InlineFragment' && + // Cannot inline if there are directives + (!selection.directives || selection.directives?.length === 0) + ) { + const fragmentTypeName = selection.typeCondition + ? selection.typeCondition.name.value + : null; + if (!fragmentTypeName || fragmentTypeName === selectionSetTypeName) { + outputSelections.push( + ...inlineRelevantFragmentSpreads( + fragmentDefinitions, + selection.selectionSet.selections, + selectionSetType, + ), + ); + continue; + } + } + outputSelections.push(selection); + } + return outputSelections; +} + +/** + * Given a document AST, inline all named fragment definitions. + */ +export default function mergeAST( + documentAST: DocumentNode, + schema?: GraphQLSchema, +): DocumentNode { + // If we're given the schema, we can simplify even further by resolving object + // types vs unions/interfaces + const typeInfo = schema ? new TypeInfo(schema) : null; + + const fragmentDefinitions: { + [key: string]: FragmentDefinitionNode | undefined; + } = Object.create(null); + + for (const definition of documentAST.definitions) { + if (definition.kind === 'FragmentDefinition') { + fragmentDefinitions[definition.name.value] = definition; + } + } + + const visitors: Visitor = { + SelectionSet(node) { + const selectionSetType = typeInfo ? typeInfo.getParentType() : null; + let { selections } = node; + + selections = inlineRelevantFragmentSpreads( + fragmentDefinitions, + selections, + selectionSetType, + ); + + selections = uniqueBy(selections, selection => + selection.alias ? selection.alias.value : selection.name.value, + ); + + return { + ...node, + selections, + }; + }, + FragmentDefinition() { + return null; + }, + }; + + return visit( + documentAST, + typeInfo ? visitWithTypeInfo(typeInfo, visitors) : visitors, + ); +} diff --git a/packages/graphiql-2-rfc-context/src/utility/normalizeWhitespace.ts b/packages/graphiql-2-rfc-context/src/utility/normalizeWhitespace.ts new file mode 100644 index 00000000000..f88aeef9575 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/normalizeWhitespace.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Unicode whitespace characters that break the interface. +export const invalidCharacters = Array.from({ length: 11 }, (_, i) => { + // \u2000 -> \u200a + return String.fromCharCode(0x2000 + i); +}).concat(['\u2028', '\u2029', '\u202f', '\u00a0']); + +const sanitizeRegex = new RegExp('[' + invalidCharacters.join('') + ']', 'g'); + +export function normalizeWhitespace(line: string) { + return line.replace(sanitizeRegex, ' '); +} diff --git a/packages/graphiql-2-rfc-context/src/utility/observableToPromise.ts b/packages/graphiql-2-rfc-context/src/utility/observableToPromise.ts new file mode 100644 index 00000000000..ac341d6c4c5 --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/observableToPromise.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Observable } from '../types'; + +export function observableToPromise( + observable: Observable | Promise, +): Promise { + if (!isObservable(observable)) { + return observable; + } + return new Promise((resolve, reject) => { + const subscription = observable.subscribe( + v => { + resolve(v); + subscription.unsubscribe(); + }, + reject, + () => { + reject(new Error('no value resolved')); + }, + ); + }); +} + +// Duck-type observable detection. +export function isObservable(value: any): value is Observable { + return ( + typeof value === 'object' && + 'subscribe' in value && + typeof value.subscribe === 'function' + ); +} diff --git a/packages/graphiql-2-rfc-context/src/utility/onHasCompletion.ts b/packages/graphiql-2-rfc-context/src/utility/onHasCompletion.ts new file mode 100644 index 00000000000..92e818568cf --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/utility/onHasCompletion.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type * as CM from 'codemirror'; + +import { + GraphQLNonNull, + GraphQLList, + GraphQLType, + GraphQLField, +} from 'graphql'; +import MD from 'markdown-it'; + +const md = new MD(); + +/** + * Render a custom UI for CodeMirror's hint which includes additional info + * about the type and description for the selected context. + */ +export default function onHasCompletion( + _cm: CM.Editor, + data: CM.EditorChangeLinkedList | undefined, + onHintInformationRender: (el: HTMLDivElement) => void, +) { + const CodeMirror = require('codemirror'); + + let information: HTMLDivElement | null; + let deprecation: HTMLDivElement | null; + + // When a hint result is selected, we augment the UI with information. + CodeMirror.on( + data, + 'select', + (ctx: GraphQLField<{}, {}, {}>, el: HTMLDivElement) => { + // Only the first time (usually when the hint UI is first displayed) + // do we create the information nodes. + if (!information) { + const hintsUl = el.parentNode as Node & ParentNode; + + // This "information" node will contain the additional info about the + // highlighted typeahead option. + information = document.createElement('div'); + information.className = 'CodeMirror-hint-information'; + hintsUl.appendChild(information); + + // This "deprecation" node will contain info about deprecated usage. + deprecation = document.createElement('div'); + deprecation.className = 'CodeMirror-hint-deprecation'; + hintsUl.appendChild(deprecation); + + // When CodeMirror attempts to remove the hint UI, we detect that it was + // removed and in turn remove the information nodes. + let onRemoveFn: EventListener | null; + hintsUl.addEventListener( + 'DOMNodeRemoved', + (onRemoveFn = (event: Event) => { + if (event.target === hintsUl) { + hintsUl.removeEventListener('DOMNodeRemoved', onRemoveFn); + information = null; + deprecation = null; + onRemoveFn = null; + } + }), + ); + } + + // Now that the UI has been set up, add info to information. + const description = ctx.description + ? md.render(ctx.description) + : 'Self descriptive.'; + const type = ctx.type + ? '' + renderType(ctx.type) + '' + : ''; + + information.innerHTML = + '
    ' + + (description.slice(0, 3) === '

    ' + ? '

    ' + type + description.slice(3) + : type + description) + + '

    '; + + if (ctx && deprecation && ctx.isDeprecated) { + const reason = ctx.deprecationReason + ? md.render(ctx.deprecationReason) + : ''; + deprecation.innerHTML = + 'Deprecated' + reason; + deprecation.style.display = 'block'; + } else if (deprecation) { + deprecation.style.display = 'none'; + } + + // Additional rendering? + if (onHintInformationRender) { + onHintInformationRender(information); + } + }, + ); +} + +function renderType(type: GraphQLType): string { + if (type instanceof GraphQLNonNull) { + return `${renderType(type.ofType)}!`; + } + if (type instanceof GraphQLList) { + return `[${renderType(type.ofType)}]`; + } + return `${type.name}`; +} diff --git a/packages/graphiql-2-rfc-context/src/workers/graphql.worker.ts b/packages/graphiql-2-rfc-context/src/workers/graphql.worker.ts new file mode 100644 index 00000000000..22971ce18fb --- /dev/null +++ b/packages/graphiql-2-rfc-context/src/workers/graphql.worker.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { worker as WorkerNamespace } from 'monaco-editor'; + +// @ts-ignore +import * as worker from 'monaco-editor/esm/vs/editor/editor.worker'; + +import type { ICreateData } from 'monaco-graphql/esm/typings'; + +import { GraphQLWorker } from 'monaco-graphql/esm/GraphQLWorker'; + +self.onmessage = () => { + try { + // ignore the first message + worker.initialize( + (ctx: WorkerNamespace.IWorkerContext, createData: ICreateData) => { + return new GraphQLWorker(ctx, createData); + }, + ); + } catch (err) { + throw err; + } +}; diff --git a/packages/graphiql-2-rfc-context/test/README.md b/packages/graphiql-2-rfc-context/test/README.md new file mode 100644 index 00000000000..b14f801737f --- /dev/null +++ b/packages/graphiql-2-rfc-context/test/README.md @@ -0,0 +1,8 @@ +# Test GraphiQL Application + +This test folder serves as a basis for testing in-development changes +and offers watching/compiling of the `src` folder. To utilize this, simply: + +1. Run `npm install` +2. Run `npm run dev` +3. Open your browser to the address listed in your console. e.g. `Started on http://localhost:49811/` diff --git a/packages/graphiql-2-rfc-context/test/beforeDevServer.js b/packages/graphiql-2-rfc-context/test/beforeDevServer.js new file mode 100644 index 00000000000..90f2e781670 --- /dev/null +++ b/packages/graphiql-2-rfc-context/test/beforeDevServer.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +const express = require('express'); +const path = require('path'); +const graphqlHTTP = require('express-graphql'); +const schema = require('./schema'); + +module.exports = function beforeDevServer(app, _server, _compiler) { + // GraphQL Server + app.post('/graphql', graphqlHTTP({ schema })); + + app.get( + '/graphql', + graphqlHTTP({ + schema, + }), + ); + + app.use('/images', express.static(path.join(__dirname, 'images'))); + + app.use( + '/renderExample.js', + express.static(path.join(__dirname, '../resources/renderExample.js')), + ); +}; diff --git a/packages/graphiql-2-rfc-context/test/e2e-server.js b/packages/graphiql-2-rfc-context/test/e2e-server.js new file mode 100644 index 00000000000..694472df52c --- /dev/null +++ b/packages/graphiql-2-rfc-context/test/e2e-server.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable no-console */ +const express = require('express'); +const path = require('path'); +const graphqlHTTP = require('express-graphql'); +const schema = require('./schema'); + +const app = express(); + +// Server +app.post('/graphql', graphqlHTTP({ schema })); + +app.get( + '/graphql', + graphqlHTTP({ + schema, + }), +); + +app.use(express.static(path.resolve(__dirname, '../'))); + +app.listen(process.env.PORT || 0, function () { + const port = this.address().port; + + console.log(`Started on http://localhost:${port}/`); + console.log('PID', process.pid); + + process.once('SIGINT', () => { + process.exit(); + }); + process.once('SIGTERM', () => { + process.exit(); + }); +}); diff --git a/packages/graphiql-2-rfc-context/test/images/logo.svg b/packages/graphiql-2-rfc-context/test/images/logo.svg new file mode 100644 index 00000000000..337843aca18 --- /dev/null +++ b/packages/graphiql-2-rfc-context/test/images/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/graphiql-2-rfc-context/test/schema.js b/packages/graphiql-2-rfc-context/test/schema.js new file mode 100644 index 00000000000..578895df72a --- /dev/null +++ b/packages/graphiql-2-rfc-context/test/schema.js @@ -0,0 +1,237 @@ +/** + * Copyright (c) 2020 GraphQL Contributors. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +const { + GraphQLSchema, + GraphQLObjectType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLBoolean, + GraphQLInt, + GraphQLFloat, + GraphQLString, + GraphQLID, + GraphQLList, +} = require('graphql'); + +// Test Schema +const TestEnum = new GraphQLEnumType({ + name: 'TestEnum', + description: 'An enum of super cool colors.', + values: { + RED: { description: 'A rosy color' }, + GREEN: { description: 'The color of martians and slime' }, + BLUE: { description: "A feeling you might have if you can't use GraphQL" }, + GRAY: { + description: 'A really dull color', + deprecationReason: 'Colors are available now.', + }, + }, +}); + +const TestInputObject = new GraphQLInputObjectType({ + name: 'TestInput', + description: 'Test all sorts of inputs in this input object type.', + fields: () => ({ + string: { + type: GraphQLString, + description: 'Repeats back this string', + }, + int: { type: GraphQLInt }, + float: { type: GraphQLFloat }, + boolean: { type: GraphQLBoolean }, + id: { type: GraphQLID }, + enum: { type: TestEnum }, + object: { type: TestInputObject }, + defaultValueString: { + type: GraphQLString, + defaultValue: 'test default value', + }, + defaultValueBoolean: { + type: GraphQLBoolean, + defaultValue: false, + }, + defaultValueInt: { + type: GraphQLInt, + defaultValue: 5, + }, + // List + listString: { type: new GraphQLList(GraphQLString) }, + listInt: { type: new GraphQLList(GraphQLInt) }, + listFloat: { type: new GraphQLList(GraphQLFloat) }, + listBoolean: { type: new GraphQLList(GraphQLBoolean) }, + listID: { type: new GraphQLList(GraphQLID) }, + listEnum: { type: new GraphQLList(TestEnum) }, + listObject: { type: new GraphQLList(TestInputObject) }, + }), +}); + +const TestInterface = new GraphQLInterfaceType({ + name: 'TestInterface', + description: 'Test interface.', + fields: () => ({ + name: { + type: GraphQLString, + description: 'Common name string.', + }, + }), + resolveType: check => { + return check ? UnionFirst : UnionSecond; + }, +}); + +const UnionFirst = new GraphQLObjectType({ + name: 'First', + fields: () => ({ + name: { + type: GraphQLString, + description: 'Common name string for UnionFirst.', + }, + first: { + type: new GraphQLList(TestInterface), + resolve: () => { + return true; + }, + }, + }), + interfaces: [TestInterface], +}); + +const UnionSecond = new GraphQLObjectType({ + name: 'Second', + fields: () => ({ + name: { + type: GraphQLString, + description: 'Common name string for UnionFirst.', + }, + second: { + type: TestInterface, + resolve: () => { + return false; + }, + }, + }), + interfaces: [TestInterface], +}); + +const TestUnion = new GraphQLUnionType({ + name: 'TestUnion', + types: [UnionFirst, UnionSecond], + resolveType() { + return UnionFirst; + }, +}); + +const TestType = new GraphQLObjectType({ + name: 'Test', + fields: () => ({ + test: { + type: TestType, + description: '`test` field from `Test` type.', + resolve: () => ({}), + }, + longDescriptionType: { + type: TestType, + description: + '`longDescriptionType` field from `Test` type, which ' + + 'has a long, verbose, description to test inline field docs', + resolve: () => ({}), + }, + union: { + type: TestUnion, + description: '> union field from Test type, block-quoted.', + resolve: () => ({}), + }, + id: { + type: GraphQLID, + description: 'id field from Test type.', + resolve: () => 'abc123', + }, + isTest: { + type: GraphQLBoolean, + description: 'Is this a test schema? Sure it is.', + resolve: () => { + return true; + }, + }, + image: { + type: GraphQLString, + description: 'field that returns an image URI.', + resolve: () => '/images/logo.svg', + }, + deprecatedField: { + type: TestType, + description: 'This field is an example of a deprecated field', + deprecationReason: 'No longer in use, try `test` instead.', + }, + hasArgs: { + type: GraphQLString, + resolve(value, args) { + return JSON.stringify(args); + }, + args: { + string: { type: GraphQLString }, + int: { type: GraphQLInt }, + float: { type: GraphQLFloat }, + boolean: { type: GraphQLBoolean }, + id: { type: GraphQLID }, + enum: { type: TestEnum }, + object: { type: TestInputObject }, + defaultValue: { + type: GraphQLString, + defaultValue: 'test default value', + }, + // List + listString: { type: new GraphQLList(GraphQLString) }, + listInt: { type: new GraphQLList(GraphQLInt) }, + listFloat: { type: new GraphQLList(GraphQLFloat) }, + listBoolean: { type: new GraphQLList(GraphQLBoolean) }, + listID: { type: new GraphQLList(GraphQLID) }, + listEnum: { type: new GraphQLList(TestEnum) }, + listObject: { type: new GraphQLList(TestInputObject) }, + }, + }, + }), +}); + +const TestMutationType = new GraphQLObjectType({ + name: 'MutationType', + description: 'This is a simple mutation type', + fields: { + setString: { + type: GraphQLString, + description: 'Set the string field', + args: { + value: { type: GraphQLString }, + }, + }, + }, +}); + +const TestSubscriptionType = new GraphQLObjectType({ + name: 'SubscriptionType', + description: 'This is a simple subscription type', + fields: { + subscribeToTest: { + type: TestType, + description: 'Subscribe to the test type', + args: { + id: { type: GraphQLString }, + }, + }, + }, +}); + +const myTestSchema = new GraphQLSchema({ + query: TestType, + mutation: TestMutationType, + subscription: TestSubscriptionType, +}); + +module.exports = myTestSchema; diff --git a/packages/graphiql-2-rfc-context/tsconfig.esm.json b/packages/graphiql-2-rfc-context/tsconfig.esm.json new file mode 100644 index 00000000000..14d84ec5af8 --- /dev/null +++ b/packages/graphiql-2-rfc-context/tsconfig.esm.json @@ -0,0 +1,33 @@ +{ + "extends": "../../resources/tsconfig.base.esm.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./esm", + "composite": false, + "jsx": "react", + "baseUrl": ".", + "target": "es5", + "paths": { + "src/*": ["*"] + } + }, + "include": ["src", "src/**/*.json"], + "exclude": [ + "**/__tests__/**", + "**/dist/**.*", + "**/*.spec.ts", + "**/*.spec.js", + "**/*-test.ts", + "**/*-test.js", + "**/*.stories.js", + "**/*.stories.ts" + ], + "references": [ + { + "path": "../monaco-graphql/tsconfig.esm.json" + }, + { + "path": "../graphql-language-service/tsconfig.esm.json" + } + ] +} diff --git a/packages/graphiql-2-rfc-context/tsconfig.json b/packages/graphiql-2-rfc-context/tsconfig.json new file mode 100644 index 00000000000..470a7d86d97 --- /dev/null +++ b/packages/graphiql-2-rfc-context/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../resources/tsconfig.base.cjs.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true, + "jsx": "react", + "target": "es5", + "strictPropertyInitialization": false, + "paths": { + "src/*": ["*"] + } + }, + "include": ["src/**/*.{js,jsx,tsx,ts,json}", ".storybook"], + "exclude": [ + "**/__tests__/**", + "**/dist/**.*", + "**/*.spec.ts", + "**/*.spec.js", + "**/*-test.ts", + "**/*-test.js", + "**/*.stories.js", + "**/*.stories.ts", + "**/*.stories.tsx" + ], + "references": [ + { + "path": "../monaco-graphql" + }, + { + "path": "../graphql-language-service" + } + ] +} diff --git a/packages/graphiql/LICENSE b/packages/graphiql/LICENSE index cd2262e3a31..cb1d3c5c3a8 100644 --- a/packages/graphiql/LICENSE +++ b/packages/graphiql/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 GraphQL Contributors +Copyright (c) 2020 GraphQL Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/graphiql/resources/index.html.ejs b/packages/graphiql/resources/index.html.ejs index 69ab6504eea..2472d53f2fe 100644 --- a/packages/graphiql/resources/index.html.ejs +++ b/packages/graphiql/resources/index.html.ejs @@ -1,5 +1,5 @@