From 4fada3b008b669bf58ad6ba3ca811ca3f6341648 Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Mon, 2 Nov 2020 10:04:24 -0800 Subject: [PATCH 1/6] Add schema to type defs --- src/CodeExporter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeExporter.js b/src/CodeExporter.js index 32bdf77..debb6fc 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -79,6 +79,7 @@ export type GenerateOptions = { context: Object, operationDataList: Array, options: OptionValues, + schema: ?GraphQLSchema, }; export type CodesandboxFile = { @@ -520,7 +521,6 @@ class CodeExporter extends Component { } = computeOperationDataList({query, variables}); const optionValues: Array = this.getOptionValues(snippet); - const codeSnippet = operationDefinitions.length ? generate( this._collectOptions(snippet, operationDataList, this.props.schema), From 9c027ad3b77bcd1faad7f59495c309cfd6efe2d8 Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Mon, 2 Nov 2020 10:04:49 -0800 Subject: [PATCH 2/6] Bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f4f07a..fa98c33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphiql-code-exporter", - "version": "3.0.2", + "version": "3.0.3", "description": "Export working code snippets from GraphiQL queries", "main": "lib/index.js", "module": "es/index.js", From c29d4868b4cc224af7c2ac06d8911b64921cb263 Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Tue, 17 Nov 2020 09:28:19 -0800 Subject: [PATCH 3/6] Add lots of helpers --- src/CodeExporter.js | 838 +++++++++++++++++++++++++++++++++++++++++++- src/index.js | 26 +- src/toposort.js | 1 - 3 files changed, 847 insertions(+), 18 deletions(-) diff --git a/src/CodeExporter.js b/src/CodeExporter.js index debb6fc..2b954d0 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -1,16 +1,29 @@ // @flow import React, {Component} from 'react'; import copy from 'copy-to-clipboard'; -import {parse, print} from 'graphql'; +import { + BREAK, + getNamedType, + GraphQLInputObjectType, + GraphQLObjectType, + parse, + print, + visit, + visitWithTypeInfo, + TypeInfo, +} from 'graphql'; // $FlowFixMe: can't find module import CodeMirror from 'codemirror'; import toposort from './toposort.js'; import type { GraphQLSchema, + FieldNode, FragmentDefinitionNode, OperationDefinitionNode, VariableDefinitionNode, + VariableNode, + NameNode, OperationTypeNode, SelectionSetNode, } from 'graphql'; @@ -55,6 +68,11 @@ const codesandboxIcon = ( ); +type ShallowFragmentVariables = { + [string]: { + variables: ?Array<{name: string, type: string}>, + }, +}; export type Variables = {[key: string]: ?mixed}; // TODO: Need clearer separation between option defs and option values @@ -99,12 +117,146 @@ export type Snippet = { generateCodesandboxFiles?: ?(options: GenerateOptions) => CodesandboxFiles, }; +type NamedPath = Array; + +export const namedPathOfAncestors = ( + ancestors: ?$ReadOnlyArray>, +): namedPath => + (ancestors || []).reduce((acc, next) => { + if (Array.isArray(next)) { + return acc; + } + switch (next.kind) { + case 'Field': + return [...acc, next.name.value]; + case 'Argument': + return [...acc, `$arg.${next.name.value}`]; + default: + return acc; + } + }, []); + +const findVariableTypeFromAncestorPath = ( + schema: GraphQLSchema, + definitionNode: FragmentDefinitionNode, + variable: VariableNode, + ancestors: ?$ReadOnlyArray>, +): ?{name: string, type: any} => { + const namePath = namedPathOfAncestors(ancestors); + + // $FlowFixMe: Optional chaining + const usageAst = ancestors.slice(-1)?.[0]?.find(argAst => { + return argAst.value?.name?.value === variable.name.value; + }); + + if (!usageAst) { + return; + } + + const argObjectValueHelper = ( + inputObj: GraphQLInputObjectType, + path: Array, + parentField: ?any, + ): ?{name: string, type: any} => { + if (path.length === 0) { + const finalInputField = inputObj.getFields()[usageAst.name.value]; + return { + name: variable.name.value, + type: finalInputField.type, + }; + } + + const [next, ...rest] = path; + const field = inputObj.getFields()[next]; + const namedType = getNamedType(field.type); + if (!!namedType && namedType instanceof GraphQLInputObjectType) { + return argObjectValueHelper(namedType, rest, field); + } + }; + + const argByName = (field, name) => + field && field.args.find(arg => arg.name === name); + + const helper = ( + obj: GraphQLObjectType, + path: Array, + parentField: ?any, + ): ?{name: string, type: any} => { + if ((path || []).length === 0) { + const arg = argByName(parentField, usageAst.name.value); + if (!!arg) { + return {name: variable.name.value, type: arg.type}; + } + } + + const [next, ...rest] = path; + if (!next) { + console.warn( + 'Next is null before finding target in ', + variable, + namePath, + definitionNode, + ); + return; + } + const nextIsArg = next.startsWith('$arg.'); + if (nextIsArg) { + const argName = next.replace('$arg.', ''); + const arg = argByName(parentField, argName); + if (!arg) { + console.warn('Failed to find arg: ', argName); + return; + } + const inputObj = getNamedType(arg.type); + + if (!!inputObj) { + return argObjectValueHelper(inputObj, rest); + } + } else { + const field = obj.getFields()[next]; + const namedType = getNamedType(field.type); + + // TODO: Clean up this mess + if ((rest || []).length === 0) { + // Dummy use of `obj` since I botched the recursion base case + return helper(obj, rest, field); + } else { + if (!!namedType && !!namedType.getFields) { + // $FlowFixMe: Not sure how to type a "GraphQL object that has getFields" + return helper(namedType, rest, field); + } + } + } + }; + + const isDefinitionNode = [ + 'OperationDefinition', + 'FragmentDefinition', + ].includes(definitionNode.kind); + + if (!isDefinitionNode) { + return; + } + + const rootType = + definitionNode.kind === 'FragmentDefinition' + ? schema.getType(definitionNode.typeCondition.name.value) + : null; + + if (!!rootType && !!rootType.getFields) { + // $FlowFixMe: Not sure how to type a "GraphQL object that has getFields" + return helper(rootType, namePath); + } +}; + export const computeOperationDataList = ({ query, variables, + schema, }: { query: string, variables: Variables, + schema: ?GraphQLSchema, }) => { const operationDefinitions = getOperationNodes(query); @@ -129,20 +281,40 @@ export const computeOperationDataList = ({ variables: getUsedVariables(variables, operationDefinition), operationDefinition, fragmentDependencies: findFragmentDependencies( + schema, fragmentDefinitions, operationDefinition, ), + paginationSites: + schema && findPaginationSites(schema, operationDefinition), }), ); const operationDataList = toposort(rawOperationDataList); - return { + let fragmentVariables; + if (!!schema) { + const shallowFragmentVariables = collectFragmentVariables( + schema, + fragmentDefinitions, + ); + + fragmentVariables = computeDeepFragmentVariables( + schema, + operationDataList, + shallowFragmentVariables, + ); + } + + const result = { operationDefinitions: operationDefinitions, fragmentDefinitions: fragmentDefinitions, rawOperationDataList: rawOperationDataList, operationDataList: operationDataList, + fragmentVariables: fragmentVariables, }; + + return result; }; async function createCodesandbox( @@ -167,7 +339,538 @@ async function createCodesandbox( } } +const findPaginationSites = ( + schema: GraphQLSchema, + operationDefinition: OperationDefinitionNode | FragmentDefinitionNode, +) => { + var typeInfo = new TypeInfo(schema); + var paginationSites = []; + let previousNode; + + const hasArgByNameAndTypeName = (field, argName, typeName) => { + return field.args.some( + arg => arg.name === argName && arg.type.name === typeName, + ); + }; + + visit( + operationDefinition, + visitWithTypeInfo(typeInfo, { + Field: { + enter: (node, key, parent, path, ancestors) => { + const namedType = getNamedType(typeInfo.getType()); + const typeName = namedType?.name; + const parentType = typeInfo.getParentType(); + const parentNamedType = parentType && getNamedType(parentType); + const parentTypeName = parentNamedType?.name; + + const isConnectionCandidate = + !!parentTypeName?.endsWith('Connection') && + !!typeName.endsWith('Edge') && + !!previousNode; + + if (typeName.endsWith('Connection')) { + previousNode = { + node, + namedType: getNamedType(typeInfo.getType()), + parent: parentNamedType, + field: parentNamedType?.getFields()?.[node.name.value], + }; + } else if (isConnectionCandidate) { + const parentField = previousNode?.field; + const parentHasConnectionArgs = + hasArgByNameAndTypeName(parentField, 'first', 'Int') && + hasArgByNameAndTypeName(parentField, 'after', 'String'); + + const hasConnectionSelection = + node.name?.value === 'edges' && + node.selectionSet?.selections?.some( + sel => sel.name?.value === 'node', + ); + + const hasPageInfoType = !!getNamedType( + parentNamedType?.getFields()?.['pageInfo']?.type, + )?.name?.endsWith('PageInfo'); + + const conformsToConnectionSpec = + parentHasConnectionArgs && + hasConnectionSelection && + hasPageInfoType; + + if (conformsToConnectionSpec) { + paginationSites.push([node, [...ancestors]]); + } + + previousNode = null; + } else { + previousNode = null; + } + }, + }, + }), + ); + + return paginationSites; +}; + +const baseFragmentDefinition = { + kind: 'FragmentDefinition', + typeCondition: { + kind: 'NamedType', + name: { + kind: 'Name', + value: 'TypeName', + }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [], + }, + name: { + kind: 'Name', + value: 'PlaceholderFragment', + }, + directives: [], +}; + +export const extractNodeToConnectionFragment = ({ + schema, + node, + moduleName, + propName, + typeConditionName, +}: { + schema: GraphQLSchema, + node: FieldNode, + moduleName: string, + propName: string, + typeConditionName: string, +}) => { + const fragmentName = `${moduleName}_${propName}`; + + const canonicalArgumentNameMapping = { + first: 'count', + after: 'cursor', + last: 'last', + before: 'cursor', + }; + const connectionFirstArgument = { + kind: 'Argument', + name: { + kind: 'Name', + value: 'first', + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: 'count', + }, + }, + }; + + const connectionAfterArgument = { + kind: 'Argument', + name: { + kind: 'Name', + value: 'after', + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: 'cursor', + }, + }, + }; + + const hasFirstArgument = (node.arguments || []).some( + arg => arg.name.value === 'first', + ); + const hasAfterArgument = (node.arguments || []).some( + arg => arg.name.value === 'after', + ); + + const namedType = schema.getType(typeConditionName); + + const namedTypeHasId = !!(namedType && namedType.getFields().id); + + const args = node?.arguments?.map(arg => { + const variableName = canonicalArgumentNameMapping[arg.name.value]; + + return !!variableName + ? { + ...arg, + value: { + ...arg.value, + kind: 'Variable', + name: {kind: 'Name', value: variableName}, + }, + } + : arg; + }); + + if (!hasFirstArgument) { + args.push(connectionFirstArgument); + } + + if (!hasAfterArgument) { + args.push(connectionAfterArgument); + } + + const tempFragmentDefinition = { + ...baseFragmentDefinition, + name: {...baseFragmentDefinition.name, value: fragmentName}, + typeCondition: { + ...baseFragmentDefinition.typeCondition, + name: { + ...baseFragmentDefinition.typeCondition.name, + value: typeConditionName, + }, + }, + directives: [], + selectionSet: { + ...baseFragmentDefinition.selectionSet, + selections: [ + // Add id field automatically for store-based connections like Relay + ...(namedTypeHasId + ? [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'id', + }, + directives: [], + }, + ] + : []), + { + ...node, + directives: [ + { + kind: 'Directive', + name: { + kind: 'Name', + value: 'connection', + }, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'key'}, + value: { + kind: 'StringValue', + value: `${fragmentName}_${node.name.value}`, + block: false, + }, + }, + ], + }, + ], + arguments: [...(args || [])], + }, + ], + }, + }; + + const allFragmentVariables = findFragmentVariables( + schema, + tempFragmentDefinition, + ); + + const fragmentVariables = + allFragmentVariables[tempFragmentDefinition.name.value] || []; + + const usedArgumentDefinition = fragmentVariables + .filter(Boolean) + .map(({name, type}) => { + if (!['count', 'first', 'after', 'cursor'].includes(name)) { + return {name: name, type: type.toString()}; + } + + return null; + }) + .filter(Boolean); + + const hasCountArgumentDefinition = usedArgumentDefinition.some( + argDef => argDef.name === 'count', + ); + const hasCursorArgumentDefinition = usedArgumentDefinition.some( + argDef => argDef.name === 'cursor', + ); + + const baseArgumentDefinitions = [ + hasCountArgumentDefinition + ? null + : { + name: 'count', + type: 'Int', + defaultValue: {kind: 'IntValue', value: '10'}, + }, + hasCursorArgumentDefinition ? null : {name: 'cursor', type: 'String'}, + ].filter(Boolean); + + const argumentDefinitions = makeArgumentsDefinitionsDirective([ + ...baseArgumentDefinitions, + ...usedArgumentDefinition, + ]); + + return {...tempFragmentDefinition, directives: [argumentDefinitions]}; +}; + +export const astByNamedPath = (ast, namedPath, customVisitor) => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + let target; + let baseVisitor = { + Field: (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if (remaining?.length === 1 && isNextTargetNode) { + target = {node, key, parent, path, ancestors: [...ancestors]}; + return BREAK; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + }, + }; + + let visitor = customVisitor ? customVisitor(baseVisitor) : baseVisitor; + + visit(ast, visitor); + return target; +}; + +export const findUnusedOperationVariables = ( + operationDefinition: OperationDefinitionNode, +) => { + const variableNames = (operationDefinition.variableDefinitions || []).map( + def => { + return def.variable.name.value; + }, + ); + + let unusedVariables = new Set(variableNames); + + let baseVisitor = { + Variable: (node, key, parent, path, ancestors) => { + if (variableNames.includes(node.name.value)) { + unusedVariables.delete(node.name.value); + } + }, + }; + + let visitor = baseVisitor; + + visit(operationDefinition.selectionSet, visitor); + return unusedVariables; +}; + +export const pruneOperationToNamedPath = ( + operationDefinition: OperationDefinitionNode, + namedPath: NamedPath, +): OperationDefinitionNode => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + + const processNode = (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if (remaining?.length === 1 && isNextTargetNode) { + return false; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + return; + } else { + return null; + } + }; + + const result = visit(operationDefinition, { + Field: processNode, + FragmentSpread: processNode, + }); + + return result; +}; + +export const updateAstAtPath = (ast, namedPath, updater, customVisitor) => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + + let baseVisitor = { + Field: (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if ((remaining || []).length === 1 && isNextTargetNode) { + return updater(node, key, parent, path, ancestors); + } else if ((remaining || []).length === 0) { + return false; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + }, + }; + + let visitor = customVisitor ? customVisitor(baseVisitor) : baseVisitor; + + return visit(ast, visitor); +}; + +export const renameOperation = ( + operationDefinition: OperationDefinitionNode, + name: string, +): OperationDefinitionNode => { + const nameNode: NameNode = !!operationDefinition.name?.value + ? {...operationDefinition.name, value: name} + : {kind: 'Name', value: name}; + return {...operationDefinition, name: nameNode}; +}; + +export const makeAstDirective = ({ + name, + args, +}: { + name: string, + args: Array, +}) => { + return { + kind: 'Directive', + name: { + kind: 'Name', + value: name, + }, + arguments: args, + }; +}; + +export const makeArgumentsDefinitionsDirective = ( + defs: Array<{ + name: string, + type: string, + defaultValue?: { + kind: string, + value: any, + }, + }>, +) => { + const astDirective = makeAstDirective({ + name: 'argumentDefinitions', + args: defs.map(def => { + const defaultValueField = !!def.defaultValue + ? [ + { + kind: 'ObjectField', + name: { + kind: 'Name', + value: 'defaultValue', + }, + value: { + kind: def.defaultValue.kind, + value: def.defaultValue.value, + }, + }, + ] + : []; + + return { + kind: 'Argument', + name: { + kind: 'Name', + value: def.name, + }, + value: { + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { + kind: 'Name', + value: 'type', + }, + value: { + kind: 'StringValue', + value: def.type, + block: false, + }, + }, + ...defaultValueField, + ], + }, + }; + }), + }); + + return astDirective; +}; + +export const makeArgumentsDirective = ( + defs: Array<{ + argName: string, + variableName: string, + }>, +) => { + return makeAstDirective({ + name: 'arguments', + args: defs.map(def => { + return { + kind: 'Argument', + name: { + kind: 'Name', + value: def.name, + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: def.variableName, + }, + }, + }; + }), + }); +}; + +export const findFragmentVariables = ( + schema: GraphQLSchema, + def: FragmentDefinitionNode, +) => { + if (!schema) { + return {}; + } + + const typeInfo = new TypeInfo(schema); + + let fragmentVariables = {}; + + visit( + def, + visitWithTypeInfo(typeInfo, { + Variable: function(node, key, parent, path, ancestors) { + const usedVariables = findVariableTypeFromAncestorPath( + schema, + def, + node, + ancestors, + ); + const existingVariables = fragmentVariables[def.name.value] || []; + const alreadyHasVariable = + // TODO: Don't filter boolean, fix findVariableTypeFromAncestorPath + existingVariables + .filter(Boolean) + .some(existingDef => existingDef.name === def.name.value); + fragmentVariables[def.name.value] = alreadyHasVariable + ? existingVariables + : [...existingVariables, usedVariables]; + }, + }), + ); + + return fragmentVariables; +}; + let findFragmentDependencies = ( + schema: ?GraphQLSchema, operationDefinitions: Array, def: OperationDefinitionNode | FragmentDefinitionNode, ): Array => { @@ -183,7 +886,9 @@ let findFragmentDependencies = ( const namedFragments = selections .map(selection => { if (selection.kind === 'FragmentSpread') { - return fragmentByName(selection.name.value); + const fragmentDef = fragmentByName(selection.name.value); + + return fragmentDef; } else { return null; } @@ -214,6 +919,84 @@ let findFragmentDependencies = ( return findReferencedFragments(selectionSet); }; +let collectFragmentVariables = ( + schema: ?GraphQLSchema, + operationDefinitions: Array, +): ShallowFragmentVariables => { + const entries = operationDefinitions + .map(fragmentDefinition => { + let usedVariables = {}; + + if (!!schema && fragmentDefinition.kind === 'FragmentDefinition') { + usedVariables = findFragmentVariables(schema, fragmentDefinition); + } + + return usedVariables; + }) + .reduce((acc, next) => Object.assign(acc, next), {}); + + return entries; +}; + +const computeDeepFragmentVariables = ( + schema: GraphQLSchema, + operationDataList: Array, + shallowFragmentVariables: ShallowFragmentVariables, +) => { + const fragmentByName = (name: string) => { + return operationDataList.find( + operationData => operationData.operationDefinition.name?.value === name, + ); + }; + + const entries = operationDataList + .map(operationData => { + const operation = operationData.operationDefinition; + if (operation.kind === 'FragmentDefinition' && !!operation.name) { + const localVariables = + shallowFragmentVariables[operation.name.value] || []; + const visitedFragments = new Set(); + + const helper = deps => { + return deps.reduce((acc, dep) => { + const depName = dep.name.value; + if (visitedFragments.has(depName)) { + return acc; + } else { + visitedFragments.add(depName); + const depLocalVariables = shallowFragmentVariables[depName] || []; + const subDep = fragmentByName(depName); + if (subDep) { + const subDeps = helper(subDep.fragmentDependencies); + return {...acc, [depName]: depLocalVariables, ...subDeps}; + } else { + return {...acc, [depName]: depLocalVariables}; + } + } + }, []); + }; + + let deepFragmentVariables = helper(operationData.fragmentDependencies); + + return [ + operation.name.value, + { + shallow: localVariables, + deep: { + [operation.name.value]: localVariables, + ...deepFragmentVariables, + }, + }, + ]; + } else { + return null; + } + }) + .filter(Boolean); + + return Object.fromEntries(entries); +}; + let operationNodesMemo: [ ?string, ?Array, @@ -446,7 +1229,10 @@ class CodeExporter extends Component { }; }; - _generateCodesandbox = async (operationDataList: Array) => { + _generateCodesandbox = async ( + operationDataList: Array, + fragmentVariables, + ) => { this.setState({codesandboxResult: {type: 'loading'}}); const snippet = this._activeSnippet(); if (!snippet) { @@ -468,11 +1254,15 @@ class CodeExporter extends Component { return; } try { - const sandboxResult = await createCodesandbox( - generateFiles( - this._collectOptions(snippet, operationDataList, this.props.schema), - ), + const generateOptions = this._collectOptions( + snippet, + operationDataList, + this.props.schema, + fragmentVariables, ); + const files = generateFiles(generateOptions); + + const sandboxResult = await createCodesandbox(files); this.setState({ codesandboxResult: {type: 'success', ...sandboxResult}, }); @@ -493,9 +1283,11 @@ class CodeExporter extends Component { snippet: Snippet, operationDataList: Array, schema: ?GraphQLSchema, + fragmentVariables, ): GenerateOptions => { const {serverUrl, context = {}, headers = {}} = this.props; const optionValues = this.getOptionValues(snippet); + return { serverUrl, headers, @@ -503,6 +1295,7 @@ class CodeExporter extends Component { operationDataList, options: optionValues, schema, + fragmentVariables, }; }; @@ -514,16 +1307,24 @@ class CodeExporter extends Component { const {name, language, generate} = snippet; const { - operationDefinitions: operationDefinitions, - fragmentDefinitions: fragmentDefinitions, - rawOperationDataList: rawOperationDataList, - operationDataList: operationDataList, - } = computeOperationDataList({query, variables}); + fragmentVariables, + operationDefinitions, + operationDataList, + } = computeOperationDataList({ + query, + variables, + schema: this.props.schema, + }); const optionValues: Array = this.getOptionValues(snippet); const codeSnippet = operationDefinitions.length ? generate( - this._collectOptions(snippet, operationDataList, this.props.schema), + this._collectOptions( + snippet, + operationDataList, + this.props.schema, + fragmentVariables, + ), ) : null; @@ -534,7 +1335,7 @@ class CodeExporter extends Component { ].sort((a, b) => a.localeCompare(b)); return ( -
+
{ }} type="button" disabled={!codeSnippet} - onClick={() => this._generateCodesandbox(operationDataList)}> + onClick={() => + this._generateCodesandbox( + operationDataList, + fragmentVariables, + ) + }> {codesandboxIcon}{' '} Create CodeSandbox diff --git a/src/index.js b/src/index.js index 8a34458..9a2bbab 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,19 @@ // @flow -import CodeExporter, {computeOperationDataList} from './CodeExporter'; +import CodeExporter, { + computeOperationDataList, + astByNamedPath, + extractNodeToConnectionFragment, + namedPathOfAncestors, + pruneOperationToNamedPath, + renameOperation, + makeAstDirective, + makeArgumentsDefinitionsDirective, + makeArgumentsDirective, + updateAstAtPath, + findUnusedOperationVariables, + findFragmentVariables, +} from './CodeExporter'; import capitalizeFirstLetter from './utils/capitalizeFirstLetter'; import jsCommentsFactory from './utils/jsCommentsFactory'; import snippets from './snippets/index'; @@ -20,7 +33,18 @@ export { computeOperationDataList, capitalizeFirstLetter, jsCommentsFactory, + findUnusedOperationVariables, snippets, + astByNamedPath, + findFragmentVariables, + extractNodeToConnectionFragment, + namedPathOfAncestors, + pruneOperationToNamedPath, + renameOperation, + makeAstDirective, + updateAstAtPath, + makeArgumentsDefinitionsDirective, + makeArgumentsDirective, }; export default CodeExporter; diff --git a/src/toposort.js b/src/toposort.js index 882e0fc..4cae898 100644 --- a/src/toposort.js +++ b/src/toposort.js @@ -1,5 +1,4 @@ // @flow -import type {FragmentDefinitionNode, OperationDefinitionNode} from 'graphql'; import type {OperationData} from './CodeExporter.js'; type stringBoolMap = {[string]: boolean}; From a9be9cf4aee310698a1164508630964e0461fdbc Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Tue, 24 Nov 2020 16:56:25 -0800 Subject: [PATCH 4/6] Initial GitHub support --- src/CodeExporter.css | 53 +++ src/CodeExporter.js | 699 ++++++++++++++++++++++++++++++++++++-- src/Modal.js | 34 ++ src/OneGraphOperations.js | 641 ++++++++++++++++++++++++++++++++++ 4 files changed, 1393 insertions(+), 34 deletions(-) create mode 100644 src/Modal.js create mode 100644 src/OneGraphOperations.js diff --git a/src/CodeExporter.css b/src/CodeExporter.css index 9740fb3..6d5f037 100644 --- a/src/CodeExporter.css +++ b/src/CodeExporter.css @@ -10,3 +10,56 @@ .graphiql-code-exporter .CodeMirror-cursors { display: none; } + +.flex-wrapper { + min-height: 100vh; + background: #ccc; + display: flex; + flex-direction: column; +} +.flex-wrapper .header, +.footer { + height: 50px; + background: #666; + color: #fff; +} +.flex-wrapper .content { + display: flex; + flex: 1; + background: #999; + color: #000; +} +.flex-wrapper .columns { + display: flex; + flex: 1; + flex-direction: column; +} +.flex-wrapper .changed { + flex: 1; + order: 1; + background: #eee; + padding: 6px; + max-height: 33%; + overflow-y: scroll; +} +.flex-wrapper .sidebar-new { + background: #ccc; + flex: 1 1; + order: 2; + padding: 6px; + max-height: 33%; + overflow-y: scroll; +} +.flex-wrapper .sidebar-unchanged { + order: 3; + flex: 1 1; + max-height: 33%; + overflow-y: scroll; + background: #ddd; + padding: 6px; +} + +.flex-wrapper h1 { + color: white; + padding: 6px; +} diff --git a/src/CodeExporter.js b/src/CodeExporter.js index 2b954d0..d4f684e 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -8,6 +8,7 @@ import { GraphQLObjectType, parse, print, + printSchema, visit, visitWithTypeInfo, TypeInfo, @@ -15,6 +16,9 @@ import { // $FlowFixMe: can't find module import CodeMirror from 'codemirror'; import toposort from './toposort.js'; +import Modal from './Modal'; + +import * as OG from './OneGraphOperations'; import type { GraphQLSchema, @@ -28,6 +32,8 @@ import type { SelectionSetNode, } from 'graphql'; +import CSS from './CodeExporter.css'; + function formatVariableName(name: string) { var uppercasePattern = /[A-Z]/g; @@ -68,6 +74,53 @@ const codesandboxIcon = ( ); +const gitHubIcon = ( + + {'GitHub icon'} + + +); + +const gearIcon = ( + + + +); + +const downArrow = props => ( + + + +); + +const gitPushIcon = ( + + + +); + +const gitNewRepoIcon = ( + + + +); + type ShallowFragmentVariables = { [string]: { variables: ?Array<{name: string, type: string}>, @@ -119,6 +172,10 @@ export type Snippet = { type NamedPath = Array; +const snippetStorageName = snippet => { + return `${snippet.language}:${snippet.name}`; +}; + export const namedPathOfAncestors = ( ancestors: ?$ReadOnlyArray>, ): namedPath => @@ -129,6 +186,8 @@ export const namedPathOfAncestors = ( switch (next.kind) { case 'Field': return [...acc, next.name.value]; + case 'InlineFragment': + return [...acc, `$inlineFragment.${next.typeCondition.name.value}`]; case 'Argument': return [...acc, `$arg.${next.name.value}`]; default: @@ -189,10 +248,12 @@ const findVariableTypeFromAncestorPath = ( } } + window.getNamedType = getNamedType; + const [next, ...rest] = path; if (!next) { console.warn( - 'Next is null before finding target in ', + 'Next is null before finding target (this may be normal) ', variable, namePath, definitionNode, @@ -200,6 +261,8 @@ const findVariableTypeFromAncestorPath = ( return; } const nextIsArg = next.startsWith('$arg.'); + const nextIsInlineFragment = next.startsWith('$inlineFragment.'); + if (nextIsArg) { const argName = next.replace('$arg.', ''); const arg = argByName(parentField, argName); @@ -212,6 +275,13 @@ const findVariableTypeFromAncestorPath = ( if (!!inputObj) { return argObjectValueHelper(inputObj, rest); } + } else if (nextIsInlineFragment) { + const typeName = next.replace('$inlineFragment.', ''); + const type = schema.getType(typeName); + const namedType = type && getNamedType(type); + const field = namedType.getFields()[next]; + + return helper(namedType, rest, field); } else { const field = obj.getFields()[next]; const namedType = getNamedType(field.type); @@ -518,6 +588,10 @@ export const extractNodeToConnectionFragment = ({ args.push(connectionAfterArgument); } + const connectionKeyName = `${fragmentName}_${(node.alias && + node.alias.value) || + node.name.value}`; + const tempFragmentDefinition = { ...baseFragmentDefinition, name: {...baseFragmentDefinition.name, value: fragmentName}, @@ -560,7 +634,7 @@ export const extractNodeToConnectionFragment = ({ name: {kind: 'Name', value: 'key'}, value: { kind: 'StringValue', - value: `${fragmentName}_${node.name.value}`, + value: connectionKeyName, block: false, }, }, @@ -623,6 +697,23 @@ export const astByNamedPath = (ast, namedPath, customVisitor) => { let nextName = remaining[0]; let target; let baseVisitor = { + InlineFragment: (node, key, parent, path, ancestors) => { + const nextIsInlineFragment = nextName.startsWith('$inlineFragment.'); + if (nextIsInlineFragment) { + const typeName = nextName.replace('$inlineFragment.', ''); + const isNextTargetNode = node.typeCondition.name.value === typeName; + + if (isNextTargetNode) { + if (remaining?.length === 1 && isNextTargetNode) { + target = {node, key, parent, path, ancestors: [...ancestors]}; + return BREAK; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + } + } + }, Field: (node, key, parent, path, ancestors) => { const isNextTargetNode = node.name.value === nextName; if (remaining?.length === 1 && isNextTargetNode) { @@ -1169,6 +1260,26 @@ type Props = {| onGenerateCodesandbox?: ?({sandboxId: string}) => void, schema: ?GraphQLSchema, |}; + +type GitHubRepositoryIdentifier = {| + owner: string, + name: string, + branch: string, +|}; + +const descriptionOfGitHubRepositoryIdentifier = ( + repoIdentifier: GitHubRepositoryIdentifier, +): string => { + return `${repoIdentifier.owner}/${repoIdentifier.name}:"${repoIdentifier.branch}"`; +}; +type GitHubInfo = {| + login: ?string, + targetRepo: ?GitHubRepositoryIdentifier, + availableRepositories: ?Array, + repoSearch: ?string, + searchOpen: boolean, +|}; + type State = {| showCopiedTooltip: boolean, optionValuesBySnippet: Map, @@ -1178,6 +1289,13 @@ type State = {| | {type: 'loading'} | {type: 'success', sandboxId: string} | {type: 'error', error: string}, + gitHubPushResult: + | null + | {type: 'loading'} + | {type: 'success'} + | {type: 'error', error: string}, + gitHubInfo: ?GitHubInfo, + forceOverwriteFiles: {[string]: boolean}, |}; class CodeExporter extends Component { @@ -1187,6 +1305,9 @@ class CodeExporter extends Component { optionValuesBySnippet: new Map(), snippet: null, codesandboxResult: null, + gitHubPushResult: null, + gitHubInfo: {searchOpen: false}, + snippetStorage: null, }; _activeSnippet = (): Snippet => @@ -1194,7 +1315,12 @@ class CodeExporter extends Component { setSnippet = (snippet: Snippet) => { this.props.onSelectSnippet && this.props.onSelectSnippet(snippet); - this.setState({snippet, codesandboxResult: null}); + + const snippetStorage = JSON.parse( + localStorage.getItem(snippetStorageName(snippet)) || '{}', + ); + + this.setState({snippet, snippetStorage, codesandboxResult: null}); }; setLanguage = (language: string) => { @@ -1229,11 +1355,10 @@ class CodeExporter extends Component { }; }; - _generateCodesandbox = async ( + _generateFiles = async ( operationDataList: Array, fragmentVariables, ) => { - this.setState({codesandboxResult: {type: 'loading'}}); const snippet = this._activeSnippet(); if (!snippet) { // Shouldn't be able to get in this state, but just in case... @@ -1254,14 +1379,43 @@ class CodeExporter extends Component { return; } try { + const {query, variables = {}} = this.props; + + const {fragmentVariables, operationDataList} = computeOperationDataList({ + query, + variables, + schema: this.props.schema, + }); + const generateOptions = this._collectOptions( snippet, operationDataList, this.props.schema, fragmentVariables, ); + const files = generateFiles(generateOptions); + return files; + } catch (e) { + console.error('Error generating files', e); + } + }; + + _generateCodesandbox = async ( + operationDataList: Array, + fragmentVariables, + ) => { + this.setState({codesandboxResult: {type: 'loading'}}); + + try { + const files = this._generateFiles(operationDataList, fragmentVariables); + + if (!files) { + console.warn('No files generated'); + return; + } + const sandboxResult = await createCodesandbox(files); this.setState({ codesandboxResult: {type: 'success', ...sandboxResult}, @@ -1279,6 +1433,110 @@ class CodeExporter extends Component { } }; + _pushChangesToGitHub = async ( + operationDataList: Array, + fragmentVariables, + fileOverwriteList: {[string]: boolean}, + ) => { + this.setState({gitHubPushResult: {type: 'loading'}}); + + const targetRepo = this.state.gitHubInfo?.targetRepo; + + if (!targetRepo) { + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'You must select a target GitHub repository to sync with', + }, + }); + + return; + } + + try { + const files = await this._generateFiles( + operationDataList, + fragmentVariables, + ); + + if (!files) { + console.warn('No files generated'); + return; + } + + const transformedFiles = Object.entries(files) + .filter(([path, details]) => { + return typeof fileOverwriteList[path] === 'undefined' + ? true + : fileOverwriteList[path]; + }) + .map(([path, details]) => { + const fileContent = + typeof details.content === 'object' + ? JSON.stringify(details.content, null, 2) + : details.content; + return { + path: path, + mode: '100644', + content: fileContent, + }; + }); + + const acceptOverrides = Object.entries(fileOverwriteList).length > 0; + + const pushResults = await OG.pushFilesToBranch({ + owner: targetRepo.owner, + name: targetRepo.name, + branch: targetRepo.branch, + message: 'Updates from OneGraph code generator', + treeFiles: transformedFiles, + acceptOverrides, + }); + + if (!!pushResults) { + if (typeof pushResults.error === 'string') { + this.setState({ + gitHubPushResult: { + type: 'error', + error: pushResults.error, + }, + }); + } else if (!!pushResults.confirmationNeeded) { + this.setState(oldState => { + return { + gitHubPushResult: { + type: 'error', + error: 'Confirm file overwrite', + }, + gitHubConfirmationModal: { + changeset: pushResults.changeset, + }, + }; + }); + } else if (!!pushResults.ok) { + this.setState({ + gitHubPushResult: { + type: 'success', + }, + }); + setTimeout(() => { + this.setState({ + gitHubPushResult: null, + }); + }, 2500); + } + } + } catch (e) { + console.error('Error in GitHub synch', e); + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'Failed to sync with GitHub', + }, + }); + } + }; + _collectOptions = ( snippet: Snippet, operationDataList: Array, @@ -1299,13 +1557,99 @@ class CodeExporter extends Component { }; }; + setOverwritePath({path, overwrite}: {path: string, overwrite: boolean}) { + const newSnippetStorage = { + ...this.state.snippetStorage, + overwriteSettings: { + ...(this.state.snippetStorage?.overwriteSettings || {}), + [path]: overwrite, + }, + }; + localStorage.setItem( + snippetStorageName(this._activeSnippet()), + JSON.stringify(newSnippetStorage), + ); + this.setState({snippetStorage: newSnippetStorage}); + } + + setTargetRepo(owner: string, name: string, branch: string) { + const newSnippetStorage = { + ...this.state.snippetStorage, + targetRepo: { + owner: owner, + name: name, + branch: branch, + }, + }; + + localStorage.setItem( + snippetStorageName(this._activeSnippet()), + JSON.stringify(newSnippetStorage), + ); + + this.setState(oldState => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + searchOpen: false, + targetRepo: { + owner: owner, + name: name, + branch: branch, + }, + }, + }; + }); + } + + componentDidMount() { + const snippetStorage = JSON.parse( + localStorage.getItem(snippetStorageName(this._activeSnippet())) || '{}', + ); + + this.setState({snippetStorage: snippetStorage}); + + OG.fetchFindMeOnGitHub().then(result => { + const rawGitHubInfo = result.data?.me?.github; + if (!!rawGitHubInfo) { + window.rawGitHubInfo = rawGitHubInfo; + const availableRepositories = rawGitHubInfo.repositories.edges.map( + edge => { + const [owner, name] = edge.node.nameWithOwner.split('/'); + return { + owner: owner, + name: name, + branch: 'main', + }; + }, + ); + + const defaultRepo = snippetStorage?.targetRepo; + + const gitHubInfo = { + login: rawGitHubInfo.login, + availableRepositories: availableRepositories, + targetRepo: defaultRepo || availableRepositories[0], + }; + + this.setState(oldState => ({ + ...oldState, + gitHubInfo: {...oldState.gitHubInfo, ...gitHubInfo}, + })); + } + }); + } + render() { const {query, snippets, variables = {}} = this.props; - const {showCopiedTooltip, codesandboxResult} = this.state; + const {showCopiedTooltip, codesandboxResult, gitHubPushResult} = this.state; const snippet = this._activeSnippet(); const {name, language, generate} = snippet; + const gitHubSearchOpen = this.state.gitHubInfo?.searchOpen; + const { fragmentVariables, operationDefinitions, @@ -1400,33 +1744,183 @@ class CodeExporter extends Component { )} {supportsCodesandbox ? (
- +
+ {!!this.state.gitHubInfo ? ( + +
  • { + this._pushChangesToGitHub( + operationDataList, + fragmentVariables, + {}, + ); + }}> + {gitPushIcon} Push changes +
  • +
  • { + const repoName = prompt( + 'Name of new GitHub repository', + ); + if (!repoName.trim()) { + return; + } + + const result = await OG.executeCreateRepo(repoName); + const repo = + result?.data?.gitHub?.makeRestCall?.post?.jsonBody; + + if (!repo) { + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'Failed to create GitHub repository', + }, + }); + return; + } + + const defaultRepo = { + name: repo?.name, + owner: repo?.owner?.login, + branch: 'main', + }; + + this.setState(oldState => ({ + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + searchOpen: false, + targetRepo: defaultRepo, + }, + })); + }}> + {gitNewRepoIcon} New repository +
  • +
  • { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + this.setState(oldState => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + searchOpen: !gitHubSearchOpen, + }, + }; + }); + }} + onMouseDown={event => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + return false; + }}> + Change repository{' '} + {downArrow( + gitHubSearchOpen + ? null + : {style: {transform: 'rotate(-90deg)'}}, + )} +
  • + {gitHubSearchOpen ? ( +
  • + { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + }} + onMouseDown={event => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + return false; + }} + onChange={event => { + const value = event.target.value; + let repoSearch = value; + if (value.trim() === '') { + repoSearch = null; + } + + this.setState(oldState => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + repoSearch, + }, + }; + }); + }} + type="text" + /> +
  • + ) : null} + {gitHubSearchOpen + ? (this.state.gitHubInfo?.availableRepositories || []) + .map((repoInfo: GitHubRepositoryIdentifier) => { + const nameWithOwner = `${repoInfo.owner}/${repoInfo.name}`; + + if ( + this.state.gitHubInfo?.repoSearch && + !nameWithOwner.match( + this.state.gitHubInfo?.repoSearch, + ) + ) { + return null; + } + + return ( +
  • { + const branchName = prompt( + 'Which branch should we push to?', + ); + + if (!branchName.trim()) { + return; + } + + this.setTargetRepo( + repoInfo.owner, + repoInfo.name, + branchName, + ); + }}> + {nameWithOwner} +
  • + ); + }) + .filter(Boolean) + : null} +
    + ) : null} +
    {codesandboxResult ? (
    {codesandboxResult.type === 'loading' ? ( @@ -1443,6 +1937,22 @@ class CodeExporter extends Component { )}
    ) : null} + {gitHubPushResult ? ( +
    + {gitHubPushResult.type === 'loading' ? ( + 'Loading...' + ) : gitHubPushResult.type === 'error' ? ( + `Error: ${gitHubPushResult.error}` + ) : ( + + Files synchronized + + )} +
    + ) : null}
    ) : null}
    @@ -1511,6 +2021,127 @@ class CodeExporter extends Component {
    )}
    + {!!this.state.gitHubConfirmationModal ? ( + +
    +
    +

    File change summary

    +
    +
    +
    +
    +

    + These files have changed in{' '} + + {this.state.gitHubInfo.targetRepo.owner}/ + {this.state.gitHubInfo.targetRepo.name} + {' '} + on the{' '} + {this.state.gitHubInfo.targetRepo.branch}{' '} + branch and will be overwritten +

    +
      + {( + this.state.gitHubConfirmationModal?.changeset + ?.changed || [] + ).map(file => { + const checked = + this.state.snippetStorage?.overwriteSettings?.[ + file.path + ] ?? true; + + return ( +
    • + { + this.setOverwritePath({ + path: file.path, + overwrite: !checked, + }); + }} + />{' '} + {' '} + - {checked ? 'overwrite' : 'skip'} +
    • + ); + })} +
    +
    + {(this.state.gitHubConfirmationModal?.changeset.new || []) + .length > 0 ? ( + + ) : null}{' '} + {( + this.state.gitHubConfirmationModal?.changeset.unchanged || + [] + ).length > 0 ? ( + + ) : null} +
    +
    +
    + + +
    +
    +
    + ) : null} ); } @@ -1591,7 +2222,7 @@ export default function CodeExporterWrapper({
    diff --git a/src/Modal.js b/src/Modal.js new file mode 100644 index 0000000..dc5f348 --- /dev/null +++ b/src/Modal.js @@ -0,0 +1,34 @@ +import React from 'react'; + +const css = { + modal: { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + background: 'rgba(0, 0, 0, 0.6)', + zIndex: '99', + }, + modalMain: { + position: 'fixed', + background: 'white', + width: '80%', + height: 'auto', + top: '50%', + left: '50%', + transform: 'translate(-50%,-50%)', + }, + displayBlock: {display: 'block'}, + displayNone: {display: 'none'}, +}; + +const Modal = ({show, children}) => { + return ( +
    +
    {children}
    +
    + ); +}; + +export default Modal; diff --git a/src/OneGraphOperations.js b/src/OneGraphOperations.js new file mode 100644 index 0000000..2887e94 --- /dev/null +++ b/src/OneGraphOperations.js @@ -0,0 +1,641 @@ +// @flow +import sha1 from 'js-sha1'; + +const encoder = new TextEncoder(); + +const computeGitHash = source => + sha1('blob ' + encoder.encode(source).length + '\0' + source); + +window.computeGitHash = computeGitHash; + +// This setup is only needed once per application +async function fetchOneGraph(operationsDoc, operationName, variables) { + const result = await fetch( + 'https://serve.onegraph.io/graphql?app_id=af3246eb-92a6-4dc6-ac78-e1b3d0c31212', + { + method: 'POST', + headers: { + Authorization: 'Bearer _7_g7CP7XMws-Wy5IbGmIu7RmI3jo5E6lpEmtYItVW0', + }, + body: JSON.stringify({ + query: operationsDoc, + variables: variables, + operationName: operationName, + }), + }, + ); + + return await result.json(); +} + +export function fetchFindMeOnGitHub() { + return fetchOneGraph( + ` +query FindMeOnGitHub { + me { + github { + id + login + repositories( + first: 100 + orderBy: { field: CREATED_AT, direction: DESC } + affiliations: [ + OWNER + COLLABORATOR + ORGANIZATION_MEMBER + ] + ownerAffiliations: [ + OWNER + COLLABORATOR + ORGANIZATION_MEMBER + ] + ) { + edges { + node { + id + nameWithOwner + } + } + totalCount + } + } + } +}`, + 'FindMeOnGitHub', + {}, + ); +} + +const operationsDoc = ` + mutation CreateTree($path: String!, $treeJson: JSON!) { + gitHub { + makeRestCall { + post( + path: $path + jsonBody: $treeJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + } + jsonBody + } + } + } +} + +fragment GitHubRefFragment on GitHubRef { + id + name + target { + id + oid + ... on GitHubCommit { + history(first: 1) { + edges { + node { + tree { + entries { + name + path + oid + object { + ... on GitHubTree { + id + entries { + name + path + oid + } + } + } + } + } + } + } + } + tree { + id + oid + } + } + } +} + +query DefaultBranchRef($owner: String!, $name: String!) { + gitHub { + repository(name: $name, owner: $owner) { + id + defaultBranchRef { + ...GitHubRefFragment + } + } + } +} + +query FilesOnRef($owner: String!, $name: String!, $fullyQualifiedRefName: String!) { + gitHub { + repository(name: $name, owner: $owner) { + id + ref(qualifiedName: $fullyQualifiedRefName) { + ...GitHubRefFragment + } + } + } +} + +mutation CreateRepo($repoJson: JSON!) { + gitHub { + makeRestCall { + post( + path: "/user/repos" + jsonBody: $repoJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + } + jsonBody + } + } + } +} + +mutation CreateCommit($path: String!, $commitJson: JSON!) { + gitHub { + makeRestCall { + post(path: $path, jsonBody: $commitJson) { + response { + statusCode + } + jsonBody + } + } + } +} + +mutation CreateRef( + $repositoryId: ID! + $name: String! + $oid: GitHubGitObjectID! +) { + gitHub { + createRef( + input: { + repositoryId: $repositoryId + name: $name + oid: $oid + } + ) { + ref { + ...GitHubRefFragment + } + } + } +} + +mutation UpdateRef($refId: ID!, $sha: GitHubGitObjectID!) { + gitHub { + updateRef(input: { refId: $refId, oid: $sha }) { + clientMutationId + ref { + name + id + target { + oid + id + } + } + } + } +} +`; + +export function executeCreateTree(owner, name, treeJson) { + const path = `/repos/${owner}/${name}/git/trees`; + return fetchOneGraph(operationsDoc, 'CreateTree', { + path: path, + treeJson: treeJson, + }); +} + +window.executeCreateTree = executeCreateTree; + +export function fetchDefaultBranchRef(owner, name) { + return fetchOneGraph(operationsDoc, 'DefaultBranchRef', { + owner: owner, + name: name, + }); +} + +window.fetchDefaultBranchRef = fetchDefaultBranchRef; + +export function fetchFilesOnRef(owner, name, fullyQualifiedRefName) { + return fetchOneGraph(operationsDoc, 'FilesOnRef', { + owner: owner, + name: name, + fullyQualifiedRefName: fullyQualifiedRefName, + }); +} + +window.fetchDefaultBranchRef = fetchDefaultBranchRef; + +export function executeCreateRepo(name: string) { + return fetchOneGraph(operationsDoc, 'CreateRepo', { + repoJson: {name: name, auto_init: true}, + }); +} + +window.executeCreateRepo = executeCreateRepo; + +export function executeCreateCommit(owner, name, commitJson) { + const path = `/repos/${owner}/${name}/git/commits`; + return fetchOneGraph(operationsDoc, 'CreateCommit', { + path: path, + commitJson: commitJson, + }); +} + +window.executeCreateCommit = executeCreateCommit; + +export function executeUpdateRef(refId, sha) { + return fetchOneGraph(operationsDoc, 'UpdateRef', {refId, sha}); +} + +export function executeCreateRef({repositoryId, name, oid}) { + return fetchOneGraph(operationsDoc, 'CreateRef', {repositoryId, name, oid}); +} + +window.executeUpdateRef = executeUpdateRef; + +window.sha1 = sha1; + +function download(filename, text) { + var element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:text/plain;charset=utf-8,' + encodeURIComponent(text), + ); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + +type TreeFiles = { + [string]: {| + content: string | Object, + |}, +}; + +export const pushFilesToBranch = async function({ + owner, + name, + message, + branch, + treeFiles: rawTreeFiles, + acceptOverrides, +}: { + owner: string, + name: string, + branch: string, + message: string, + treeFiles: TreeFiles, + acceptOverrides: ?boolean, +}): Promise< + | {ok: 'empty-changeset'} + | { + ok: 'success', + result: any, + treeJson: any, + treeResults: any, + commitJson: any, + commitResult: any, + updateRefResult: any, + } + | {confirmationNeeded: string, changeset: any, originalTreeFiles: TreeFiles} + | {error: string} + | null, +> { + const fileExample = { + path: 'another.js', + mode: '100644', + content: 'let isJs = true', + }; + + const fileHashes = rawTreeFiles.reduce((acc, next) => { + acc[next.path] = computeGitHash(next.content); + return acc; + }, {}); + + window.fileHashes = fileHashes; + const result = await fetchDefaultBranchRef(owner, name); + const repositoryId = result?.data?.gitHub?.repository?.id; + const defaultBranchRef = result?.data?.gitHub?.repository?.defaultBranchRef; + const defaultBranchRefName = + result?.data?.gitHub?.repository?.defaultBranchRef?.name; + + // By default we're going to base our commit off of the latest default branch head + let headRefNodeId = defaultBranchRef?.id; + let headRefCommitSha = defaultBranchRef?.target?.oid; + let headRefTreeSha = defaultBranchRef?.target?.tree?.oid; + let headRefTreeNodeId = defaultBranchRef?.target?.tree?.id; + + let existingFiles = + defaultBranchRef?.target?.history?.edges?.[0]?.node?.tree?.entries || []; + + const pushingToNonDefaultBranch = branch !== defaultBranchRefName; + + const fullyQualifiedRefName = `refs/heads/${branch}`; + + // But if we're pushing to a non-default branch, we should check to see if the branch exists, and if so, update our assumptions + if (pushingToNonDefaultBranch) { + let filesOnRefResult = await fetchFilesOnRef( + owner, + name, + fullyQualifiedRefName, + ); + let branchRef = filesOnRefResult?.data?.gitHub?.repository?.ref; + let branchRefName = branchRef?.name; + + if (!branchRef) { + // If the branchRef doesn't exist, then we create a new ref pointing to the head default ref + const createRefResult = await executeCreateRef({ + repositoryId, + name: fullyQualifiedRefName, + oid: headRefCommitSha, + }); + + branchRef = createRefResult?.data?.gitHub?.createRef?.ref; + if (!branchRef) { + return {error: `Failed to create branch '${branch}'`}; + } + } + + branchRefName = branchRef?.name; + existingFiles = + branchRef?.target?.history?.edges?.[0]?.node?.tree?.entries || []; + + headRefNodeId = branchRef?.id; + headRefCommitSha = branchRef?.target?.oid; + headRefTreeSha = branchRef?.target?.tree?.oid; + headRefTreeNodeId = branchRef?.target?.tree?.id; + } + + const findExistingFileByPath = path => { + const parts = path.split('/'); + let candidates = existingFiles; + + const helper = parts => { + const next = parts[0]; + const remainingParts = parts.slice(1); + const nextFile = candidates.find(gitFile => gitFile.name === next); + + if (!nextFile) return null; + + if (remainingParts.length === 0) { + return nextFile; + } + + candidates = nextFile.object?.entries || []; + return helper(remainingParts); + }; + + return helper(parts); + }; + + // Try to calculate the minimum number of files we can upload + const changeset = rawTreeFiles.reduce( + (acc, file) => { + // This will only look two levels down since that's the limit of our GraphQL query + const existingFile = findExistingFileByPath(file.path); + if (!existingFile) { + acc['new'] = [...acc.new, file]; + return acc; + } + + // This file already exists, so check if the hash is the same + if (fileHashes[file.path] === existingFile.oid) { + const tempFile = { + ...file, + }; + + delete tempFile['content']; + + acc['unchanged'] = [ + ...acc.unchanged, + {...tempFile, sha: fileHashes[file.path]}, + ]; + return acc; + } + + // The file exists, but its hash has changed; + acc['changed'] = [...acc.changed, file]; + return acc; + }, + {unchanged: [], new: [], changed: []}, + ); + + // Don't bother uploading files with unchanged hashes (Git will filter these out of a changeset anyway) + const treeFiles = [...changeset.new, ...changeset.changed]; + + if (treeFiles.length === 0) { + return {ok: 'empty-changeset'}; + } + + if ((changeset.changed || []).length > 0 && !acceptOverrides) { + return { + confirmationNeeded: 'Some files have changed and will be overwritten', + changeset, + originalTreeFiles: rawTreeFiles, + }; + } + + const treeJson = { + base_tree: headRefTreeSha, + tree: treeFiles, + }; + + window.changeset = changeset; + // return treeJson; + + if (!headRefTreeSha) return {error: 'Failed to find sha of head ref tree'}; + + const treeResults = await executeCreateTree(owner, name, treeJson); + const newTreeSha = + treeResults?.data?.gitHub?.makeRestCall?.post?.jsonBody?.sha; + + const commitJson = { + message: message, + tree: newTreeSha, + parents: [headRefCommitSha], + }; + if (!newTreeSha || !headRefCommitSha) { + return { + error: 'Failed to find git tree sha', + }; + } + + const commitResult = await executeCreateCommit(owner, name, commitJson); + const commitRefId = + commitResult?.data?.gitHub?.makeRestCall?.post?.jsonBody?.node_id; + const commitSha = + commitResult?.data?.gitHub?.makeRestCall?.post?.jsonBody?.sha; + + if (!commitRefId || !commitSha) + return {error: 'Failed to find appropriate commit sha'}; + + const updateRefResult = await executeUpdateRef(headRefNodeId, commitSha); + + return { + ok: 'success', + result, + treeJson, + treeResults, + commitJson, + commitResult, + updateRefResult, + }; +}; + +window.pushFilesToBranch = pushFilesToBranch; + +/** +1. Check if repo exists +2. If not, create via rest with auto_init:true +3. Get the defaultRef head +4. Get the head commit, store sha (oid) +5. Create a tree with the file +6. Create a commit with the tree +7. Point the default head ref to commit + + + +*/ + +/** + +# 1 +query DefaultBranchRef( + $owner: String! + $name: String! +) { + gitHub { + repository(name: $name, owner: $owner) { + defaultBranchRef { + id + name + target { + id + oid + ... on GitHubCommit { + tree { + id + oid + } + } + } + } + } + } +} + +# 2 +mutation RestCreateRepo($repoJson: JSON!) { + gitHub { + makeRestCall { + post( + path: "/user/repos" + jsonBody: $repoJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + headers + } + jsonBody + } + } + } +} + +# 3 (same as #1) +# 4 (same as #1) + +# 5 +mutation CreateTree($treeJson: JSON!) { + gitHub { + makeRestCall { + post( + path: "/repos/sgrove/made-from-rest/git/trees" + jsonBody: $treeJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + headers + } + jsonBody + } + } + } +} +{"treeJson": { + "baseTree":"ffd168b9423afc5e9fdf519297c5bb0688ac6c31", + "tree": [ + { + "type": "commit", + "path": "second-file", + "mode": "100644", + "content": "Got some content here" + } + ] +}} + +# 6 +mutation CreateCommit($commitJson: JSON = "") { + gitHub { + makeRestCall { + post( + path: "/repos/sgrove/made-from-rest/git/commits" + jsonBody: $commitJson + ) { + response { + statusCode + } + jsonBody + } + } + } +} + +#7 +mutation UpdateRef { + gitHub { + updateRef( + input: { + refId: "MDM6UmVmMzE0NjU3NTY2OnJlZnMvaGVhZHMvbWFpbg==" + oid: "db478fb4e5ac88341c0498d8d257b77c304be3da" + } + ) { + clientMutationId + ref { + name + id + target { + oid + id + } + } + } + } +} + */ From 48e5e6090ecc247c2bb0a34c6befb46cf1c4f9cb Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Tue, 24 Nov 2020 17:03:45 -0800 Subject: [PATCH 5/6] Cleanup warnings --- src/CodeExporter.js | 15 +++------------ src/OneGraphOperations.js | 28 ++-------------------------- 2 files changed, 5 insertions(+), 38 deletions(-) diff --git a/src/CodeExporter.js b/src/CodeExporter.js index d4f684e..f18d1de 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -8,7 +8,6 @@ import { GraphQLObjectType, parse, print, - printSchema, visit, visitWithTypeInfo, TypeInfo, @@ -32,7 +31,7 @@ import type { SelectionSetNode, } from 'graphql'; -import CSS from './CodeExporter.css'; +import './CodeExporter.css'; function formatVariableName(name: string) { var uppercasePattern = /[A-Z]/g; @@ -57,6 +56,7 @@ const copyIcon = ( ); +// eslint-disable-next-line const codesandboxIcon = ( ); +// eslint-disable-next-line const gitHubIcon = ( ); -const gearIcon = ( - - - -); - const downArrow = props => ( diff --git a/src/OneGraphOperations.js b/src/OneGraphOperations.js index 2887e94..99fee90 100644 --- a/src/OneGraphOperations.js +++ b/src/OneGraphOperations.js @@ -268,26 +268,6 @@ export function executeCreateRef({repositoryId, name, oid}) { return fetchOneGraph(operationsDoc, 'CreateRef', {repositoryId, name, oid}); } -window.executeUpdateRef = executeUpdateRef; - -window.sha1 = sha1; - -function download(filename, text) { - var element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:text/plain;charset=utf-8,' + encodeURIComponent(text), - ); - element.setAttribute('download', filename); - - element.style.display = 'none'; - document.body.appendChild(element); - - element.click(); - - document.body.removeChild(element); -} - type TreeFiles = { [string]: {| content: string | Object, @@ -323,12 +303,6 @@ export const pushFilesToBranch = async function({ | {error: string} | null, > { - const fileExample = { - path: 'another.js', - mode: '100644', - content: 'let isJs = true', - }; - const fileHashes = rawTreeFiles.reduce((acc, next) => { acc[next.path] = computeGitHash(next.content); return acc; @@ -345,6 +319,7 @@ export const pushFilesToBranch = async function({ let headRefNodeId = defaultBranchRef?.id; let headRefCommitSha = defaultBranchRef?.target?.oid; let headRefTreeSha = defaultBranchRef?.target?.tree?.oid; + // eslint-disable-next-line let headRefTreeNodeId = defaultBranchRef?.target?.tree?.id; let existingFiles = @@ -362,6 +337,7 @@ export const pushFilesToBranch = async function({ fullyQualifiedRefName, ); let branchRef = filesOnRefResult?.data?.gitHub?.repository?.ref; + // eslint-disable-next-line let branchRefName = branchRef?.name; if (!branchRef) { From 77f5fae4bee3e5b3b7e5ecac150b1888dc88914e Mon Sep 17 00:00:00 2001 From: Sean Grove Date: Tue, 1 Dec 2020 21:44:54 -0800 Subject: [PATCH 6/6] Initial GitHub integration --- src/CodeExporter.css | 6 +- src/CodeExporter.js | 306 +++++++++++++++++++------------------- src/Modal.js | 13 +- src/OneGraphOperations.js | 164 +------------------- 4 files changed, 166 insertions(+), 323 deletions(-) diff --git a/src/CodeExporter.css b/src/CodeExporter.css index 6d5f037..98f1d53 100644 --- a/src/CodeExporter.css +++ b/src/CodeExporter.css @@ -12,16 +12,17 @@ } .flex-wrapper { - min-height: 100vh; + max-height: 60vh; background: #ccc; display: flex; flex-direction: column; } .flex-wrapper .header, .footer { - height: 50px; + height: unset; background: #666; color: #fff; + padding: 4px; } .flex-wrapper .content { display: flex; @@ -39,7 +40,6 @@ order: 1; background: #eee; padding: 6px; - max-height: 33%; overflow-y: scroll; } .flex-wrapper .sidebar-new { diff --git a/src/CodeExporter.js b/src/CodeExporter.js index f18d1de..8e50cb9 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -11,6 +11,7 @@ import { visit, visitWithTypeInfo, TypeInfo, + printSchema, } from 'graphql'; // $FlowFixMe: can't find module import CodeMirror from 'codemirror'; @@ -38,10 +39,7 @@ function formatVariableName(name: string) { return ( name.charAt(0).toUpperCase() + - name - .slice(1) - .replace(uppercasePattern, '_$&') - .toUpperCase() + name.slice(1).replace(uppercasePattern, '_$&').toUpperCase() ); } @@ -86,7 +84,7 @@ const gitHubIcon = ( ); -const downArrow = props => ( +const downArrow = (props) => ( @@ -163,7 +161,7 @@ export type Snippet = { type NamedPath = Array; -const snippetStorageName = snippet => { +const snippetStorageName = (snippet) => { return `${snippet.language}:${snippet.name}`; }; @@ -195,7 +193,7 @@ const findVariableTypeFromAncestorPath = ( const namePath = namedPathOfAncestors(ancestors); // $FlowFixMe: Optional chaining - const usageAst = ancestors.slice(-1)?.[0]?.find(argAst => { + const usageAst = ancestors.slice(-1)?.[0]?.find((argAst) => { return argAst.value?.name?.value === variable.name.value; }); @@ -225,7 +223,7 @@ const findVariableTypeFromAncestorPath = ( }; const argByName = (field, name) => - field && field.args.find(arg => arg.name === name); + field && field.args.find((arg) => arg.name === name); const helper = ( obj: GraphQLObjectType, @@ -239,8 +237,6 @@ const findVariableTypeFromAncestorPath = ( } } - window.getNamedType = getNamedType; - const [next, ...rest] = path; if (!next) { console.warn( @@ -410,7 +406,7 @@ const findPaginationSites = ( const hasArgByNameAndTypeName = (field, argName, typeName) => { return field.args.some( - arg => arg.name === argName && arg.type.name === typeName, + (arg) => arg.name === argName && arg.type.name === typeName, ); }; @@ -446,7 +442,7 @@ const findPaginationSites = ( const hasConnectionSelection = node.name?.value === 'edges' && node.selectionSet?.selections?.some( - sel => sel.name?.value === 'node', + (sel) => sel.name?.value === 'node', ); const hasPageInfoType = !!getNamedType( @@ -546,17 +542,17 @@ export const extractNodeToConnectionFragment = ({ }; const hasFirstArgument = (node.arguments || []).some( - arg => arg.name.value === 'first', + (arg) => arg.name.value === 'first', ); const hasAfterArgument = (node.arguments || []).some( - arg => arg.name.value === 'after', + (arg) => arg.name.value === 'after', ); const namedType = schema.getType(typeConditionName); const namedTypeHasId = !!(namedType && namedType.getFields().id); - const args = node?.arguments?.map(arg => { + const args = node?.arguments?.map((arg) => { const variableName = canonicalArgumentNameMapping[arg.name.value]; return !!variableName @@ -579,9 +575,9 @@ export const extractNodeToConnectionFragment = ({ args.push(connectionAfterArgument); } - const connectionKeyName = `${fragmentName}_${(node.alias && - node.alias.value) || - node.name.value}`; + const connectionKeyName = `${fragmentName}_${ + (node.alias && node.alias.value) || node.name.value + }`; const tempFragmentDefinition = { ...baseFragmentDefinition, @@ -658,10 +654,10 @@ export const extractNodeToConnectionFragment = ({ .filter(Boolean); const hasCountArgumentDefinition = usedArgumentDefinition.some( - argDef => argDef.name === 'count', + (argDef) => argDef.name === 'count', ); const hasCursorArgumentDefinition = usedArgumentDefinition.some( - argDef => argDef.name === 'cursor', + (argDef) => argDef.name === 'cursor', ); const baseArgumentDefinitions = [ @@ -727,7 +723,7 @@ export const findUnusedOperationVariables = ( operationDefinition: OperationDefinitionNode, ) => { const variableNames = (operationDefinition.variableDefinitions || []).map( - def => { + (def) => { return def.variable.name.value; }, ); @@ -838,7 +834,7 @@ export const makeArgumentsDefinitionsDirective = ( ) => { const astDirective = makeAstDirective({ name: 'argumentDefinitions', - args: defs.map(def => { + args: defs.map((def) => { const defaultValueField = !!def.defaultValue ? [ { @@ -894,7 +890,7 @@ export const makeArgumentsDirective = ( ) => { return makeAstDirective({ name: 'arguments', - args: defs.map(def => { + args: defs.map((def) => { return { kind: 'Argument', name: { @@ -928,7 +924,7 @@ export const findFragmentVariables = ( visit( def, visitWithTypeInfo(typeInfo, { - Variable: function(node, key, parent, path, ancestors) { + Variable: function (node, key, parent, path, ancestors) { const usedVariables = findVariableTypeFromAncestorPath( schema, def, @@ -940,7 +936,7 @@ export const findFragmentVariables = ( // TODO: Don't filter boolean, fix findVariableTypeFromAncestorPath existingVariables .filter(Boolean) - .some(existingDef => existingDef.name === def.name.value); + .some((existingDef) => existingDef.name === def.name.value); fragmentVariables[def.name.value] = alreadyHasVariable ? existingVariables : [...existingVariables, usedVariables]; @@ -957,7 +953,7 @@ let findFragmentDependencies = ( def: OperationDefinitionNode | FragmentDefinitionNode, ): Array => { const fragmentByName = (name: string) => { - return operationDefinitions.find(def => def.name.value === name); + return operationDefinitions.find((def) => def.name.value === name); }; const findReferencedFragments = ( @@ -966,7 +962,7 @@ let findFragmentDependencies = ( const selections = selectionSet.selections; const namedFragments = selections - .map(selection => { + .map((selection) => { if (selection.kind === 'FragmentSpread') { const fragmentDef = fragmentByName(selection.name.value); @@ -1006,7 +1002,7 @@ let collectFragmentVariables = ( operationDefinitions: Array, ): ShallowFragmentVariables => { const entries = operationDefinitions - .map(fragmentDefinition => { + .map((fragmentDefinition) => { let usedVariables = {}; if (!!schema && fragmentDefinition.kind === 'FragmentDefinition') { @@ -1027,19 +1023,19 @@ const computeDeepFragmentVariables = ( ) => { const fragmentByName = (name: string) => { return operationDataList.find( - operationData => operationData.operationDefinition.name?.value === name, + (operationData) => operationData.operationDefinition.name?.value === name, ); }; const entries = operationDataList - .map(operationData => { + .map((operationData) => { const operation = operationData.operationDefinition; if (operation.kind === 'FragmentDefinition' && !!operation.name) { const localVariables = shallowFragmentVariables[operation.name.value] || []; const visitedFragments = new Set(); - const helper = deps => { + const helper = (deps) => { return deps.reduce((acc, dep) => { const depName = dep.name.value; if (visitedFragments.has(depName)) { @@ -1158,8 +1154,8 @@ export class ToolbarMenu extends Component< e.preventDefault()} - ref={node => { + onMouseDown={(e) => e.preventDefault()} + ref={(node) => { this._node = node; }} title={this.props.title}> @@ -1231,7 +1227,7 @@ class CodeDisplay extends React.PureComponent { } render() { - return
    (this._node = ref)} />; + return
    (this._node = ref)} />; } } @@ -1316,7 +1312,7 @@ class CodeExporter extends Component { setLanguage = (language: string) => { const snippet = this.props.snippets.find( - snippet => snippet.language === language, + (snippet) => snippet.language === language, ); if (snippet) { @@ -1385,7 +1381,7 @@ class CodeExporter extends Component { fragmentVariables, ); - const files = generateFiles(generateOptions); + const files = await generateFiles(generateOptions); return files; } catch (e) { @@ -1400,7 +1396,10 @@ class CodeExporter extends Component { this.setState({codesandboxResult: {type: 'loading'}}); try { - const files = this._generateFiles(operationDataList, fragmentVariables); + const files = await this._generateFiles( + operationDataList, + fragmentVariables, + ); if (!files) { console.warn('No files generated'); @@ -1466,6 +1465,7 @@ class CodeExporter extends Component { typeof details.content === 'object' ? JSON.stringify(details.content, null, 2) : details.content; + return { path: path, mode: '100644', @@ -1493,7 +1493,7 @@ class CodeExporter extends Component { }, }); } else if (!!pushResults.confirmationNeeded) { - this.setState(oldState => { + this.setState((oldState) => { return { gitHubPushResult: { type: 'error', @@ -1578,7 +1578,7 @@ class CodeExporter extends Component { JSON.stringify(newSnippetStorage), ); - this.setState(oldState => { + this.setState((oldState) => { return { ...oldState, gitHubInfo: { @@ -1601,12 +1601,11 @@ class CodeExporter extends Component { this.setState({snippetStorage: snippetStorage}); - OG.fetchFindMeOnGitHub().then(result => { + OG.fetchFindMeOnGitHub().then((result) => { const rawGitHubInfo = result.data?.me?.github; if (!!rawGitHubInfo) { - window.rawGitHubInfo = rawGitHubInfo; const availableRepositories = rawGitHubInfo.repositories.edges.map( - edge => { + (edge) => { const [owner, name] = edge.node.nameWithOwner.split('/'); return { owner: owner, @@ -1624,7 +1623,7 @@ class CodeExporter extends Component { targetRepo: defaultRepo || availableRepositories[0], }; - this.setState(oldState => ({ + this.setState((oldState) => ({ ...oldState, gitHubInfo: {...oldState.gitHubInfo, ...gitHubInfo}, })); @@ -1666,7 +1665,7 @@ class CodeExporter extends Component { const supportsCodesandbox = snippet.generateCodesandboxFiles; const languages = [ - ...new Set(snippets.map(snippet => snippet.language)), + ...new Set(snippets.map((snippet) => snippet.language)), ].sort((a, b) => a.localeCompare(b)); return ( @@ -1686,8 +1685,8 @@ class CodeExporter extends Component { {snippets - .filter(snippet => snippet.language === language) - .map(snippet => ( + .filter((snippet) => snippet.language === language) + .map((snippet) => (
  • this.setSnippet(snippet)}> @@ -1707,7 +1706,7 @@ class CodeExporter extends Component { }}> Options
  • - {snippet.options.map(option => ( + {snippet.options.map((option) => (
    { {supportsCodesandbox ? (
    - {!!this.state.gitHubInfo ? ( + {(this.state.gitHubInfo?.availableRepositories || []).length > + 0 ? ( { fragmentVariables, {}, ); + + return true; }}> {gitPushIcon} Push changes @@ -1761,7 +1763,7 @@ class CodeExporter extends Component { const repoName = prompt( 'Name of new GitHub repository', ); - if (!repoName.trim()) { + if (!repoName?.trim()) { return; } @@ -1779,20 +1781,11 @@ class CodeExporter extends Component { return; } - const defaultRepo = { - name: repo?.name, - owner: repo?.owner?.login, - branch: 'main', - }; - - this.setState(oldState => ({ - ...oldState, - gitHubInfo: { - ...oldState.gitHubInfo, - searchOpen: false, - targetRepo: defaultRepo, - }, - })); + this.setTargetRepo( + repo?.owner?.login, + repo?.name, + 'main', + ); }}> {gitNewRepoIcon} New repository @@ -1805,10 +1798,10 @@ class CodeExporter extends Component { } : null } - onClick={event => { + onClick={(event) => { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); - this.setState(oldState => { + this.setState((oldState) => { return { ...oldState, gitHubInfo: { @@ -1818,7 +1811,7 @@ class CodeExporter extends Component { }; }); }} - onMouseDown={event => { + onMouseDown={(event) => { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); return false; @@ -1841,23 +1834,23 @@ class CodeExporter extends Component { margin: '2px', paddingLeft: '6px', }} - onClick={event => { + onClick={(event) => { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); }} - onMouseDown={event => { + onMouseDown={(event) => { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); return false; }} - onChange={event => { + onChange={(event) => { const value = event.target.value; let repoSearch = value; if (value.trim() === '') { repoSearch = null; } - this.setState(oldState => { + this.setState((oldState) => { return { ...oldState, gitHubInfo: { @@ -1891,9 +1884,10 @@ class CodeExporter extends Component { onClick={() => { const branchName = prompt( 'Which branch should we push to?', + 'main', ); - if (!branchName.trim()) { + if (!branchName?.trim()) { return; } @@ -1997,30 +1991,30 @@ class CodeExporter extends Component { borderTop: '1px solid rgb(220, 220, 220)', fontSize: 12, }}> - {codeSnippet ? ( - - ) : ( -
    - The query is invalid. -
    - The generated code will appear here once the errors in the query - editor are resolved. -
    - )} + {!this.state.gitHubConfirmationModal ? ( + codeSnippet ? ( + + ) : ( +
    + The query is invalid. +
    + The generated code will appear here once the errors in the query + editor are resolved. +
    + ) + ) : null}
    {!!this.state.gitHubConfirmationModal ? ( -
    -
    -

    File change summary

    -
    -
    -
    -
    +
    +
    File change summary
    +
    +
    +

    These files have changed in{' '} @@ -2031,78 +2025,86 @@ class CodeExporter extends Component { {this.state.gitHubInfo.targetRepo.branch}{' '} branch and will be overwritten

    -
      +
      {( this.state.gitHubConfirmationModal?.changeset ?.changed || [] - ).map(file => { + ).map((file) => { const checked = this.state.snippetStorage?.overwriteSettings?.[ file.path ] ?? true; return ( -
    • - { - this.setOverwritePath({ - path: file.path, - overwrite: !checked, - }); - }} - />{' '} - {' '} - - {checked ? 'overwrite' : 'skip'} -
    • + <> + + - {checked ? 'overwrite' : 'skip'} + ); })} -
    + {(this.state.gitHubConfirmationModal?.changeset.new || []) + .length > 0 ? ( + <> +

    New files

    + + {( + this.state.gitHubConfirmationModal?.changeset + ?.new || [] + ).map((file) => { + return ( + <> + + {file.path} + + + + ); + })} + + ) : null} + {( + this.state.gitHubConfirmationModal?.changeset + .unchanged || [] + ).length > 0 ? ( + <> +

    Unchanged files

    + + {( + this.state.gitHubConfirmationModal?.changeset + ?.unchanged || [] + ).map((file) => { + return ( + <> + + {file.path} + + + + ); + })} + + ) : null} +
    - {(this.state.gitHubConfirmationModal?.changeset.new || []) - .length > 0 ? ( - - ) : null}{' '} - {( - this.state.gitHubConfirmationModal?.changeset.unchanged || - [] - ).length > 0 ? ( - - ) : null}