diff --git a/.changeset/tame-comics-buy.md b/.changeset/tame-comics-buy.md new file mode 100644 index 000000000..c5ea06a00 --- /dev/null +++ b/.changeset/tame-comics-buy.md @@ -0,0 +1,5 @@ +--- +'@theguild/editor': minor +--- + +feat: :tada: add `` component diff --git a/packages/components/package.json b/packages/components/package.json index 8468acdd2..36c1f0f78 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -28,5 +28,9 @@ }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "@types/dedent": "^0.7.0", + "dedent": "0.7.0" } } diff --git a/packages/components/src/components/SchemaType.tsx b/packages/components/src/components/SchemaType.tsx index d7ab34f66..bc0b23b65 100644 --- a/packages/components/src/components/SchemaType.tsx +++ b/packages/components/src/components/SchemaType.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { FC, useState } from 'react'; +import { buildSchema } from 'graphql'; import { ISchemaPageProps, IEditorProps } from '../types/components'; import { useThemeContext } from '../helpers/theme'; @@ -16,24 +17,24 @@ import { Frameworks, } from './SchemaTypes.styles'; import { Tag, TagsContainer } from './Tag'; -import { SchemaEditor } from '../../../monaco-graphql-editor/src/editor/SchemaEditor'; +import { SchemaEditor, ExecutableDocumentEditor } from '../../../monaco-graphql-editor/src'; -const FrameworkList = ({ options }: { options: any }): JSX.Element => { +const FrameworkList = ({ options }: { options: any[] }): JSX.Element => { const list = options.reduce((prev: string, curr: string) => [ prev, - , + , curr, ]); return {list}; }; -const Editor: React.FC = ({ +const Editor: FC> = ({ title, - frameworks, - schema, + frameworks = [], icon, image, + children, }) => ( @@ -41,26 +42,27 @@ const Editor: React.FC = ({ {image && logo} {title &&

{title}

} - {frameworks && frameworks.length > 0 && ( - - )} + {frameworks.length > 0 && }
- + {children}
); -export const SchemaPage: React.FC = ({ +export const SchemaPage: FC = ({ schemaName, - tags, + tags = [], editorData, }) => { const { isDarkTheme } = useThemeContext(); const marketplaceAssets = marketplaceThemedAssets(isDarkTheme || false); + const [schemaObj, setSchemaObj] = useState(() => + buildSchema(editorData[0].schema!) + ); return ( @@ -68,9 +70,9 @@ export const SchemaPage: React.FC = ({
{schemaName} - {tags && - tags.length > 0 && - tags.map((tagName) => {tagName})} + {tags.map((tagName) => ( + {tagName} + ))}
@@ -83,15 +85,30 @@ export const SchemaPage: React.FC = ({ - {(editorData as Array).map((data) => ( + + { + setSchemaObj(newSchemaObject); + }} + /> + + + + + + + {editorData.slice(2).map((data) => ( + > + + ))}
diff --git a/packages/components/src/helpers/dummy.tsx b/packages/components/src/helpers/dummy.tsx index 38d94d5af..f977f3ed7 100644 --- a/packages/components/src/helpers/dummy.tsx +++ b/packages/components/src/helpers/dummy.tsx @@ -1,3 +1,4 @@ +import dedent from 'dedent'; import featureListImage1 from '../static/dummy/envelop/features-pluggable.png'; import featureListImage2 from '../static/dummy/envelop/features-performant.png'; import featureListImage3 from '../static/dummy/envelop/features-modern.png'; @@ -24,8 +25,8 @@ export const dummyFeatureList = { title: 'Learn more', target: '_blank', rel: 'noopener norefereer', - href: 'https://github.com/the-guild-org' - } + href: 'https://github.com/the-guild-org', + }, }, { image: { @@ -127,7 +128,7 @@ export const dummyHeroGradient = { href: '#', }, version: '1.0.7', - colors: ['#FF34AE', '#1CC8EE'], + colors: ['#ff34ae', '#1cc8ee'], image: { src: heroGradientImage, alt: 'Illustration', @@ -201,7 +202,7 @@ export const dummyCardsColorful = { title: 'Learn more', href: '#', }, - color: '#3547E5', + color: '#3547e5', }, { title: 'Clean up your code!', @@ -211,7 +212,7 @@ export const dummyCardsColorful = { title: 'Learn more', href: '#', }, - color: '#0B0D11', + color: '#0b0d11', }, ], }; @@ -563,7 +564,7 @@ export const dummyMarketplaceSearch = { 'relay', 'jsdoc', 'plugin', - 'preset' + 'preset', ], placeholder: 'Search...', primaryList: { @@ -586,26 +587,47 @@ export const dummyMarketplaceSearch = { }, }; -const dummySchema = `type Query { - ping: Boolean - me: User! -} +const dummySchema = dedent(/* GraphQL */ ` + type Query { + ping: Boolean + me: User! + } + + " represents a valid email " + scalar Email -" represents a valid email " -scalar Email + """ + Represents a simple user + """ + type User { + id: ID! + email: Email! + profile: Profile! + } -""" Represents a simple user """ -type User { - id: ID! - email: Email! - profile: Profile! -} + type Profile { + name: String + age: Int + } +`); -type Profile { - name: String - age: Int -} -`; +const dummyOperations = dedent(/* GraphQL */ ` + query Me { + me { + id + profile { + name + } + } + ping + } + + fragment UserFields on User { + profile { + name + } + } +`); export const dummySchemaPage = { schemaName: 'Schema Type 1', @@ -620,7 +642,7 @@ export const dummySchemaPage = { { title: 'operation.graphql', frameworks: [], - schema: dummySchema, + operations: dummyOperations, image: marketplaceListImage, }, { diff --git a/packages/components/src/types/components.ts b/packages/components/src/types/components.ts index 90fea5cd3..7bf0349ae 100644 --- a/packages/components/src/types/components.ts +++ b/packages/components/src/types/components.ts @@ -75,6 +75,7 @@ interface IHeaderModalRestProps { categoryTitleProps?: React.ComponentProps<'h3'>; modalProps?: IModalRestProps; } + export interface IHeaderModalProps extends IHeaderModalRestProps { title: string | React.ReactNode; modalOpen: boolean; @@ -118,6 +119,7 @@ interface IModalRestProps { headerLinkProps?: React.ComponentProps<'a'>; headerImageProps?: React.ComponentProps<'img'>; } + export interface IModalProps extends IModalRestProps { title: string | React.ReactNode; description?: string | ILink; @@ -155,6 +157,7 @@ export interface IFeatureListProps { itemDescriptionProps?: React.ComponentProps<'p'>; itemImageProps?: React.ComponentProps<'img'>; } + export interface IInfoListProps { title?: string | React.ReactNode; items: { @@ -170,6 +173,7 @@ export interface IInfoListProps { itemDescriptionProps?: React.ComponentProps<'p'>; itemLinkProps?: React.ComponentProps<'a'>; } + export interface IHeroVideoProps { title: string | React.ReactNode; description: string | React.ReactNode; @@ -184,6 +188,7 @@ export interface IHeroVideoProps { linkProps?: React.ComponentProps<'a'>; videoProps?: ReactPlayerProps; } + export interface IHeroIllustrationProps { title: string | React.ReactNode; description: string | React.ReactNode; @@ -198,6 +203,7 @@ export interface IHeroIllustrationProps { linkProps?: React.ComponentProps<'a'>; imageProps?: React.ComponentProps<'img'>; } + export interface IHeroGradientProps { title: string | React.ReactNode; description: string | React.ReactNode; @@ -269,6 +275,7 @@ interface IMarketplaceItemRestProps { dateProps?: React.ComponentProps<'span'>; linkProps?: React.ComponentProps<'a'>; } + export interface IMarketplaceItemsProps extends IMarketplaceItemRestProps { icon: string; items: IMarketplaceItemProps[]; @@ -311,13 +318,14 @@ export interface INewsletterProps { export interface ISchemaPageProps { schemaName: string; tags?: string[]; - editorData: string[] | React.ReactNode[]; + editorData: Omit[]; } export interface IEditorProps { title?: string; frameworks?: string[]; - schema: string; + schema?: string; icon: string; image?: string; + operations?: string } diff --git a/packages/monaco-graphql-editor/src/editor/ExecutableDocumentEditor.tsx b/packages/monaco-graphql-editor/src/editor/ExecutableDocumentEditor.tsx new file mode 100644 index 000000000..b8138a8e6 --- /dev/null +++ b/packages/monaco-graphql-editor/src/editor/ExecutableDocumentEditor.tsx @@ -0,0 +1,193 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import MonacoEditor, { useMonaco, EditorProps } from '@monaco-editor/react'; +import { + getAutocompleteSuggestions, + // CompletionItemKind as lsCIK, +} from 'graphql-language-service'; +import type { GraphQLSchema } from 'graphql'; +import type * as monaco from 'monaco-editor'; +import type { IRange, CompletionItem } from 'graphql-language-service'; +import * as languages from './enums'; +import { toGraphQLPosition, toMonacoRange } from './utils'; + +// This enum `CompletionItemKind` exist on graphql-language-service v4 +enum lsCIK { + Text = 1, + Method = 2, + Function = 3, + Constructor = 4, + Field = 5, + Variable = 6, + Class = 7, + Interface = 8, + Module = 9, + Property = 10, + Unit = 11, + Value = 12, + Enum = 13, + Keyword = 14, + Snippet = 15, + Color = 16, + File = 17, + Reference = 18, + Folder = 19, + EnumMember = 20, + Constant = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, +} + +type GraphQLWorkerCompletionItem = CompletionItem & { + range?: monaco.IRange; + command?: monaco.languages.CompletionItem['command']; +}; + +const toCompletionItemKind = (kind: lsCIK): languages.CompletionItemKind => { + const CIK = languages.CompletionItemKind; + const map = { + [lsCIK.Text]: CIK.Text, + [lsCIK.Method]: CIK.Method, + [lsCIK.Function]: CIK.Function, + [lsCIK.Constructor]: CIK.Constructor, + [lsCIK.Field]: CIK.Field, + [lsCIK.Variable]: CIK.Variable, + [lsCIK.Class]: CIK.Class, + [lsCIK.Interface]: CIK.Interface, + [lsCIK.Module]: CIK.Module, + [lsCIK.Property]: CIK.Property, + [lsCIK.Unit]: CIK.Unit, + [lsCIK.Value]: CIK.Value, + [lsCIK.Enum]: CIK.Enum, + [lsCIK.Keyword]: CIK.Keyword, + [lsCIK.Snippet]: CIK.Snippet, + [lsCIK.Color]: CIK.Color, + [lsCIK.File]: CIK.File, + [lsCIK.Reference]: CIK.Reference, + [lsCIK.Folder]: CIK.Folder, + [lsCIK.EnumMember]: CIK.EnumMember, + [lsCIK.Constant]: CIK.Constant, + [lsCIK.Struct]: CIK.Struct, + [lsCIK.Event]: CIK.Event, + [lsCIK.Operator]: CIK.Operator, + [lsCIK.TypeParameter]: CIK.TypeParameter, + }; + return map[kind] || CIK.Text; +}; + +const toCompletion = ( + entry: GraphQLWorkerCompletionItem +): monaco.languages.CompletionItem => { + return { + range: entry.range as monaco.IRange, + kind: toCompletionItemKind(entry.kind as lsCIK), + label: entry.label, + insertText: entry.insertText ?? (entry.label as string), + insertTextRules: entry.insertText + ? languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + sortText: entry.sortText, + filterText: entry.filterText, + documentation: entry.documentation, + detail: entry.detail, + command: entry.command, + }; +}; + +class GraphQLWorker { + doComplete( + documentModel: monaco.editor.IReadOnlyModel, + position: monaco.Position, + schema: GraphQLSchema + ): GraphQLWorkerCompletionItem[] { + const document = documentModel.getValue(); + if (!document) { + console.log('no document'); + return []; + } + if (!schema) { + console.log('no schema'); + } + const graphQLPosition = toGraphQLPosition(position); + const suggestions = getAutocompleteSuggestions( + schema, + document, + graphQLPosition + ); + return suggestions.map((suggestion) => this.toCompletion(suggestion)); + } + + toCompletion( + entry: CompletionItem, + range?: IRange + ): GraphQLWorkerCompletionItem { + return { + label: entry.label, + insertText: entry.insertText, + insertTextFormat: entry.insertTextFormat, + sortText: entry.sortText, + filterText: entry.filterText, + documentation: entry.documentation, + detail: entry.detail, + range: range ? toMonacoRange(range) : undefined, + kind: entry.kind, + command: entry.command + ? { ...entry.command, id: entry.command.command } + : undefined, + }; + } +} + +export const ExecutableDocumentEditor: FC< + { schema: GraphQLSchema } & Omit +> = ({ schema, ...editorProps }) => { + const monaco = useMonaco(); + const [completionProvider, setCompletionProvider] = + useState(null); + const editorUriRef = useRef(); + + useEffect(() => { + if (!monaco || !schema) { + return; + } + if (completionProvider) { + // dispose instance to prevent having duplicated field + completionProvider.dispose(); + } + + const newProvider = monaco.languages.registerCompletionItemProvider( + 'graphql', + { + triggerCharacters: [':', '$', '\n', ' ', '(', '@'], + provideCompletionItems( + model: monaco.editor.IReadOnlyModel, + position: monaco.Position + ): monaco.languages.CompletionList { + const isUriEquals = model.uri.path === editorUriRef.current!.path + if (!isUriEquals) { + return { suggestions: [] }; + } + const worker = new GraphQLWorker(); + const completionItems = worker.doComplete(model, position, schema); + return { + incomplete: true, + suggestions: completionItems.map((item) => toCompletion(item)), + }; + }, + } + ); + setCompletionProvider(newProvider); + }, [monaco, schema]); + + return ( + { + editorUriRef.current = editor.getModel()!.uri; + }} + /> + ); +}; diff --git a/packages/monaco-graphql-editor/src/editor/SchemaEditor.tsx b/packages/monaco-graphql-editor/src/editor/SchemaEditor.tsx index cfa83d6d2..41be42f24 100644 --- a/packages/monaco-graphql-editor/src/editor/SchemaEditor.tsx +++ b/packages/monaco-graphql-editor/src/editor/SchemaEditor.tsx @@ -1,8 +1,20 @@ -import * as React from 'react'; -import MonacoEditor, { EditorProps } from '@monaco-editor/react'; -import type * as monaco from 'monaco-editor'; -import { EnrichedLanguageService } from './EnrichedLanguageService'; +import React, { + ForwardedRef, + useImperativeHandle, + useEffect, + useState, + forwardRef, + useCallback, +} from 'react'; +import MonacoEditor, { + EditorProps, + BeforeMount, + OnMount, + OnChange, +} from '@monaco-editor/react'; +import type { IDisposable } from 'monaco-editor'; import { GraphQLError, GraphQLSchema } from 'graphql'; +import type { EnrichedLanguageService } from './EnrichedLanguageService'; import { SchemaEditorApi, SchemaServicesOptions, @@ -22,7 +34,7 @@ export type SchemaEditorProps = SchemaServicesOptions & { function BaseSchemaEditor( props: SchemaEditorProps, - ref: React.ForwardedRef + ref: ForwardedRef ) { const { languageService, @@ -32,80 +44,89 @@ function BaseSchemaEditor( editorRef, setSchema, } = useSchemaServices(props); - React.useImperativeHandle(ref, () => editorApi, [editorRef, languageService]); + useImperativeHandle(ref, () => editorApi, [editorRef, languageService]); - React.useEffect(() => { + useEffect(() => { if (languageService && props.onLanguageServiceReady) { props.onLanguageServiceReady(languageService); } }, [languageService, props.onLanguageServiceReady]); - const [onBlurHandler, setOnBlurSubscription] = - React.useState(); + const [onBlurHandler, setOnBlurSubscription] = useState(); - React.useEffect(() => { + useEffect(() => { if (editorRef && props.onBlur) { onBlurHandler?.dispose(); const subscription = editorRef.onDidBlurEditorText(() => { - props.onBlur && props.onBlur(editorRef.getValue() || ''); + props.onBlur?.(editorRef.getValue() || ''); }); setOnBlurSubscription(subscription); } }, [props.onBlur, editorRef]); + const handleBeforeMount = useCallback( + (monaco) => { + setMonaco(monaco); + props.beforeMount?.(monaco); + }, + [props.beforeMount] + ); + + const handleMount = useCallback( + (editor, monaco) => { + setEditor(editor); + props.onMount?.(editor, monaco); + }, + [props.onMount] + ); + + const handleChange = useCallback( + async (newValue, ev) => { + props.onChange?.(newValue, ev); + if (!newValue) { + return; + } + + try { + const schema = await setSchema(newValue); + if (schema) { + props.onSchemaChange?.(schema, newValue); + } + } catch (e) { + if (!props.onSchemaError) { + return; + } + const error = + e instanceof GraphQLError + ? e + : new GraphQLError( + (e as Error).message, + undefined, + undefined, + undefined, + undefined, + e as Error + ); + props.onSchemaError([error], newValue, languageService); + } + }, + [props.onChange, props.onSchemaChange, props.onSchemaError] + ); + return ( { - setMonaco(monaco); - props.beforeMount && props.beforeMount(monaco); - }} - onMount={(editor, monaco) => { - setEditor(editor); - props.onMount && props.onMount(editor, monaco); - }} - onChange={(newValue, ev) => { - props.onChange && props.onChange(newValue, ev); - - if (newValue) { - setSchema(newValue) - .then((schema) => { - if (schema) { - props.onSchemaChange && props.onSchemaChange(schema, newValue); - } - }) - .catch((e: Error | GraphQLError) => { - if (props.onSchemaError) { - if (e instanceof GraphQLError) { - props.onSchemaError([e], newValue, languageService); - } else { - props.onSchemaError( - [ - new GraphQLError( - e.message, - undefined, - undefined, - undefined, - undefined, - e - ), - ], - newValue, - languageService - ); - } - } - }); - } - }} - options={{ glyphMargin: true, ...(props.options || {}) }} + beforeMount={handleBeforeMount} + onMount={handleMount} + onChange={handleChange} + options={{ glyphMargin: true, ...props.options }} language="graphql" defaultValue={props.defaultValue || props.schema} /> ); } -export const SchemaEditor = React.forwardRef(BaseSchemaEditor); +export const SchemaEditor = forwardRef(BaseSchemaEditor); diff --git a/packages/monaco-graphql-editor/src/editor/enums.ts b/packages/monaco-graphql-editor/src/editor/enums.ts new file mode 100644 index 000000000..8824dca45 --- /dev/null +++ b/packages/monaco-graphql-editor/src/editor/enums.ts @@ -0,0 +1,52 @@ +/* + * `monaco-editor` throws an error while importing languages enums - Error: PostCSS plugin autoprefixer requires PostCSS 8 + * + * import { languages } from 'monaco-editor' + * languages.CompletionItemKind + * languages.CompletionItemInsertTextRule + * + * So I hardcoded them inside this file + */ + +export enum CompletionItemKind { + Method = 0, + Function = 1, + Constructor = 2, + Field = 3, + Variable = 4, + Class = 5, + Struct = 6, + Interface = 7, + Module = 8, + Property = 9, + Event = 10, + Operator = 11, + Unit = 12, + Value = 13, + Constant = 14, + Enum = 15, + EnumMember = 16, + Keyword = 17, + Text = 18, + Color = 19, + File = 20, + Reference = 21, + Customcolor = 22, + Folder = 23, + TypeParameter = 24, + User = 25, + Issue = 26, + Snippet = 27, +} + +export enum CompletionItemInsertTextRule { + /** + * Adjust whitespace/indentation of multiline insert texts to + * match the current line indentation. + */ + KeepWhitespace = 1, + /** + * `insertText` is a snippet. + */ + InsertAsSnippet = 4, +} diff --git a/packages/monaco-graphql-editor/src/index.tsx b/packages/monaco-graphql-editor/src/index.tsx index e194c3e69..a5e7e46f0 100644 --- a/packages/monaco-graphql-editor/src/index.tsx +++ b/packages/monaco-graphql-editor/src/index.tsx @@ -1,3 +1,4 @@ export * from './editor/SchemaEditor'; +export * from './editor/ExecutableDocumentEditor'; export * from './editor/utils'; export * from './editor/EnrichedLanguageService'; diff --git a/yarn.lock b/yarn.lock index ec1488948..3dd685fe1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2994,6 +2994,11 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/dedent@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" + integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -5472,7 +5477,7 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= -dedent@^0.7.0: +dedent@0.7.0, dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=