diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 4bd4ed92..399ae8d4 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -343,6 +343,7 @@ "collection": "Collection is empty", "collections": "Collections are empty", "documentation": "Connect to a GraphQL endpoint to view documentation", + "empty_schema": "No schema found", "endpoint": "Endpoint cannot be empty", "environments": "Environments are empty", "folder": "Folder is empty", @@ -487,11 +488,16 @@ "connection_error_http": "Failed to fetch GraphQL Schema due to network error.", "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", + "deprecated": "Deprecated", + "mutation": "Mutation", "mutations": "Mutations", "schema": "Schema", + "show_depricated_values": "Show deprecated values", + "subscription": "Subscription", "subscriptions": "Subscriptions", "switch_connection": "Switch connection", - "url_placeholder": "Enter a GraphQL endpoint URL" + "url_placeholder": "Enter a GraphQL endpoint URL", + "query": "Query" }, "graphql_collections": { "title": "GraphQL Collections" diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index 5f9513d4..2c852d56 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -44,6 +44,7 @@ "@noble/curves": "1.6.0", "@scure/base": "1.1.9", "@shopify/lang-jsonc": "1.0.0", + "@types/markdown-it": "14.1.2", "@unhead/vue": "1.11.10", "@urql/core": "5.0.6", "@urql/devtools": "2.0.3", @@ -70,6 +71,7 @@ "jsonpath-plus": "10.0.0", "lodash-es": "4.17.21", "lossless-json": "4.0.2", + "markdown-it": "14.1.0", "minisearch": "7.1.0", "nprogress": "0.2.0", "paho-mqtt": "1.1.0", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 109b1ced..9caf4159 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -29,6 +29,7 @@ declare module 'vue' { AppInspection: typeof import('./components/app/Inspection.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default'] + AppMarkdown: typeof import('./components/app/Markdown.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default'] AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default'] AppShare: typeof import('./components/app/Share.vue')['default'] @@ -95,19 +96,40 @@ declare module 'vue' { EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default'] FirebaseLogin: typeof import('./components/firebase/Login.vue')['default'] FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default'] + GraphqlArgument: typeof import('./components/graphql/Argument.vue')['default'] + GraphqlArguments: typeof import('./components/graphql/Arguments.vue')['default'] GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default'] + GraphqlDefaultValue: typeof import('./components/graphql/DefaultValue.vue')['default'] + GraphqlDeprecationReason: typeof import('./components/graphql/DeprecationReason.vue')['default'] + GraphqlDirective: typeof import('./components/graphql/Directive.vue')['default'] + GraphqlDirectives: typeof import('./components/graphql/Directives.vue')['default'] + GraphqlDoc: typeof import('./components/graphql/Doc.vue')['default'] + GraphqlDocExplorer: typeof import('./components/graphql/DocExplorer.vue')['default'] + GraphqlEnumValues: typeof import('./components/graphql/EnumValues.vue')['default'] + GraphqlExplorerSection: typeof import('./components/graphql/ExplorerSection.vue')['default'] GraphqlField: typeof import('./components/graphql/Field.vue')['default'] + GraphqlFieldDocumentation: typeof import('./components/graphql/FieldDocumentation.vue')['default'] + GraphqlFieldLink: typeof import('./components/graphql/FieldLink.vue')['default'] + GraphqlFieldOld: typeof import('./components/graphql/FieldOld.vue')['default'] + GraphqlFields: typeof import('./components/graphql/Fields.vue')['default'] GraphqlHeaders: typeof import('./components/graphql/Headers.vue')['default'] + GraphqlImplementsInterfaces: typeof import('./components/graphql/ImplementsInterfaces.vue')['default'] GraphqlQuery: typeof import('./components/graphql/Query.vue')['default'] GraphqlRequest: typeof import('./components/graphql/Request.vue')['default'] GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default'] GraphqlRequestTab: typeof import('./components/graphql/RequestTab.vue')['default'] GraphqlResponse: typeof import('./components/graphql/Response.vue')['default'] + GraphqlSchemaBrowser: typeof import('./components/graphql/SchemaBrowser.vue')['default'] + GraphqlSchemaDoc: typeof import('./components/graphql/SchemaDoc.vue')['default'] + GraphqlSchemaDocumentation: typeof import('./components/graphql/SchemaDocumentation.vue')['default'] + GraphqlSchemaSearch: typeof import('./components/graphql/SchemaSearch.vue')['default'] GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default'] GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default'] GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default'] GraphqlType: typeof import('./components/graphql/Type.vue')['default'] + GraphqlTypeDocumentation: typeof import('./components/graphql/TypeDocumentation.vue')['default'] GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default'] + GraphqlTypeLinkNew: typeof import('./components/graphql/TypeLink.vue')['default'] GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default'] History: typeof import('./components/history/index.vue')['default'] HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default'] @@ -208,7 +230,9 @@ declare module 'vue' { IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] @@ -218,8 +242,10 @@ declare module 'vue' { IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucidePlay: (typeof import("~icons/lucide/play"))["default"] IconLucidePlaySquare: (typeof import("~icons/lucide/play-square"))["default"] - IconLucideRss: (typeof import("~icons/lucide/rss"))["default"] + IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] + IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideVerified: typeof import('~icons/lucide/verified')['default'] IconLucideX: typeof import('~icons/lucide/x')['default'] diff --git a/packages/hoppscotch-common/src/components/app/Markdown.vue b/packages/hoppscotch-common/src/components/app/Markdown.vue new file mode 100644 index 00000000..27d867f0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/Markdown.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Argument.vue b/packages/hoppscotch-common/src/components/graphql/Argument.vue new file mode 100644 index 00000000..f94b82d9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Argument.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Arguments.vue b/packages/hoppscotch-common/src/components/graphql/Arguments.vue new file mode 100644 index 00000000..781596ed --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Arguments.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue b/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue new file mode 100644 index 00000000..b19f688c --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/DefaultValue.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Directives.vue b/packages/hoppscotch-common/src/components/graphql/Directives.vue new file mode 100644 index 00000000..02748641 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Directives.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue b/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue new file mode 100644 index 00000000..562a095a --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/DocExplorer.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/EnumValues.vue b/packages/hoppscotch-common/src/components/graphql/EnumValues.vue new file mode 100644 index 00000000..bc3d6864 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/EnumValues.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue b/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue new file mode 100644 index 00000000..9ca18fc0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/ExplorerSection.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Field.vue b/packages/hoppscotch-common/src/components/graphql/Field.vue index 5ba3d765..17b258d3 100644 --- a/packages/hoppscotch-common/src/components/graphql/Field.vue +++ b/packages/hoppscotch-common/src/components/graphql/Field.vue @@ -1,102 +1,90 @@ - diff --git a/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue new file mode 100644 index 00000000..6bf18cad --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/FieldLink.vue b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue new file mode 100644 index 00000000..54934355 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Fields.vue b/packages/hoppscotch-common/src/components/graphql/Fields.vue new file mode 100644 index 00000000..c6c3f80d --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/Fields.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue new file mode 100644 index 00000000..20c29663 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Query.vue b/packages/hoppscotch-common/src/components/graphql/Query.vue index ea42bfb7..14d88b32 100644 --- a/packages/hoppscotch-common/src/components/graphql/Query.vue +++ b/packages/hoppscotch-common/src/components/graphql/Query.vue @@ -93,14 +93,14 @@ import IconCheck from "~icons/lucide/check" import IconInfo from "~icons/lucide/info" import IconWand from "~icons/lucide/wand" import IconWrapText from "~icons/lucide/wrap-text" -import { onMounted, reactive, ref, markRaw } from "vue" +import { onMounted, reactive, ref, markRaw, watch, nextTick } from "vue" import { copyToClipboard } from "@helpers/utils/clipboard" import { useCodemirror } from "@composables/codemirror" import { useI18n } from "@composables/i18n" import { refAutoReset, useVModel } from "@vueuse/core" import { useToast } from "~/composables/toast" import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils" -import * as gql from "graphql" +import { OperationDefinitionNode, parse, print } from "graphql" import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery" import queryCompleter from "~/helpers/editor/completion/gqlQuery" import { selectedGQLOpHighlight } from "~/helpers/editor/gql/operation" @@ -114,6 +114,7 @@ import { } from "~/helpers/graphql/connection" import { useNestedSetting } from "~/composables/settings" import { toggleNestedSetting } from "~/newstore/settings" +import { useQuery } from "~/helpers/graphql/query" // Template refs const queryEditor = ref(null) @@ -128,7 +129,8 @@ const props = defineProps<{ const emit = defineEmits<{ (e: "save-request"): void (e: "update:modelValue", val: string): void - (e: "run-query", definition: gql.OperationDefinitionNode | null): void + (e: "cursor-position", val: number): void + (e: "run-query", definition: OperationDefinitionNode | null): void }>() const copyQueryIcon = refAutoReset( @@ -141,45 +143,60 @@ const prettifyQueryIcon = refAutoReset< const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlQuery") -const selectedOperation = ref(null) +const selectedOperation = ref(null) const gqlQueryString = useVModel(props, "modelValue", emit) +// Add useQuery +const { updatedQuery, cursorPosition, operationDefinitions } = useQuery() + const debouncedOnUpdateQueryState = debounce((update: ViewUpdate) => { const selectedPos = update.state.selection.main.head + emit("cursor-position", selectedPos) + selectedOperation.value = null const queryString = update.state.doc.toJSON().join(update.state.lineBreak) try { - const operations = gql.parse(queryString) - if (operations.definitions.length === 1) { - selectedOperation.value = operations - .definitions[0] as gql.OperationDefinitionNode + const ast = parse(queryString) + + operationDefinitions.value = ast.definitions.filter( + (def) => def.kind === "OperationDefinition" + ) as OperationDefinitionNode[] + + if (ast.definitions.length === 1) { + selectedOperation.value = ast.definitions[0] as OperationDefinitionNode return } selectedOperation.value = - (operations.definitions.find((def) => { + (ast.definitions.find((def) => { if (def.kind !== "OperationDefinition") return false const { start, end } = def.loc! return selectedPos >= start && selectedPos <= end - }) as gql.OperationDefinitionNode) ?? null + }) as OperationDefinitionNode) ?? null } catch (error) { - // console.error(error) + if (queryString.trim() === "") { + operationDefinitions.value = [] + } } -}, 300) +}, 150) onMounted(() => { try { - const operations = gql.parse(gqlQueryString.value) - if (operations.definitions.length) { - selectedOperation.value = operations - .definitions[0] as gql.OperationDefinitionNode + const ast = parse(gqlQueryString.value) + + operationDefinitions.value = ast.definitions.filter( + (def) => def.kind === "OperationDefinition" + ) as OperationDefinitionNode[] + + if (ast.definitions.length) { + selectedOperation.value = ast.definitions[0] as OperationDefinitionNode return } } catch (error) {} }) -useCodemirror( +const cmQueryEditor = useCodemirror( queryEditor, gqlQueryString, reactive({ @@ -196,13 +213,27 @@ useCodemirror( }) ) +// Add watcher for query updates +watch(updatedQuery, async (newQuery) => { + if (newQuery) { + gqlQueryString.value = newQuery + + await nextTick() + + // Update cursor position + if (cursorPosition.value) { + cmQueryEditor.cursor.value = cursorPosition.value + } + } +}) + // operations on graphql query string // const operations = useReadonlyStream(props.request.operations$, []) const prettifyQuery = () => { try { - gqlQueryString.value = gql.print( - gql.parse(gqlQueryString.value, { + gqlQueryString.value = print( + parse(gqlQueryString.value, { allowLegacyFragmentVariables: true, }) ) @@ -223,7 +254,7 @@ const clearGQLQuery = () => { gqlQueryString.value = "" } -const runQuery = (definition: gql.OperationDefinitionNode | null = null) => { +const runQuery = (definition: OperationDefinitionNode | null = null) => { emit("run-query", definition) } const unsubscribe = () => { diff --git a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue index a224174f..cf5c5190 100644 --- a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue @@ -13,6 +13,7 @@ v-model="request.query" @run-query="runQuery" @save-request="saveRequest" + @cursor-position="updateCursorPos" /> { + tabs.currentActiveTab.value.document.cursorPosition = pos +} + const hideRequestModal = () => { showSaveRequestModal.value = false } diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue new file mode 100644 index 00000000..26a74e0c --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue new file mode 100644 index 00000000..c7207a18 --- /dev/null +++ b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Sidebar.vue b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue index 0bc34cd6..e31e0632 100644 --- a/packages/hoppscotch-common/src/components/graphql/Sidebar.vue +++ b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue @@ -10,103 +10,7 @@ :icon="IconBookOpen" :label="`${t('tab.documentation')}`" > - -
-
- -
- -
-
- - - - - - - - - - - - - - -
+
diff --git a/packages/hoppscotch-common/src/components/graphql/TypeLink.vue b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue index 8404dd04..f352b579 100644 --- a/packages/hoppscotch-common/src/components/graphql/TypeLink.vue +++ b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue @@ -1,31 +1,42 @@ diff --git a/packages/hoppscotch-common/src/components/graphql/Variable.vue b/packages/hoppscotch-common/src/components/graphql/Variable.vue index 0547edcd..71b5da07 100644 --- a/packages/hoppscotch-common/src/components/graphql/Variable.vue +++ b/packages/hoppscotch-common/src/components/graphql/Variable.vue @@ -68,7 +68,7 @@
-
+
diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index 6241a553..7aa5b1f4 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -532,7 +532,8 @@ export function useCodemirror( cachedCursor.value.ch !== newPos.ch ) { const line = view.value.state.doc.line(newPos.line + 1) - const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1) + const ch = newPos.ch === -1 ? line.length : newPos.ch + const selUpdate = EditorSelection.cursor(line.from + ch) view.value?.focus() diff --git a/packages/hoppscotch-common/src/helpers/graphql/connection.ts b/packages/hoppscotch-common/src/helpers/graphql/connection.ts index abf79730..7a963e53 100644 --- a/packages/hoppscotch-common/src/helpers/graphql/connection.ts +++ b/packages/hoppscotch-common/src/helpers/graphql/connection.ts @@ -207,6 +207,7 @@ export const disconnect = () => { clearTimeout(timeoutSubscription) connection.state = "DISCONNECTED" + connection.schema = null } export const reset = () => { diff --git a/packages/hoppscotch-common/src/helpers/graphql/document.ts b/packages/hoppscotch-common/src/helpers/graphql/document.ts index 15edda05..f501e069 100644 --- a/packages/hoppscotch-common/src/helpers/graphql/document.ts +++ b/packages/hoppscotch-common/src/helpers/graphql/document.ts @@ -53,6 +53,11 @@ export type HoppGQLDocument = { */ isDirty: boolean + /** + * The cursor position in the document + */ + cursorPosition: number + /** * Info about where this request should be saved. * This contains where the request is originated from basically. diff --git a/packages/hoppscotch-common/src/helpers/graphql/explorer.ts b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts new file mode 100644 index 00000000..e453ddd5 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts @@ -0,0 +1,224 @@ +import { ref, computed, h, VNode } from "vue" +import type { + GraphQLSchema, + GraphQLNamedType, + GraphQLField, + GraphQLInputField, + GraphQLArgument, + GraphQLType, +} from "graphql" +import { + isNamedType, + isObjectType, + isInputObjectType, + isScalarType, + isEnumType, + isInterfaceType, + isUnionType, + isNonNullType, + isListType, +} from "graphql" + +/** + * Represents a field definition in the GraphQL explorer + * Can be a field, input field, or argument + */ +export type ExplorerFieldDef = + | GraphQLField + | GraphQLInputField + | GraphQLArgument + +/** + * Represents a single item in the explorer navigation stack + */ +export type ExplorerNavStackItem = { + name: string + def?: GraphQLNamedType | ExplorerFieldDef +} + +/** + * Represents the complete navigation stack for the explorer + * Must contain at least one item + */ +export type ExplorerNavStack = [ExplorerNavStackItem, ...ExplorerNavStackItem[]] + +const initialNavStackItem: ExplorerNavStackItem = { name: "Root" } + +const navStack = ref([initialNavStackItem]) +const schema = ref() +const validationErrors = ref([]) + +/** + * Hook to manage the GraphQL schema explorer state and navigation + * @param initialSchema - Optional initial GraphQL schema + * @returns Object containing explorer state and methods + */ +export function useExplorer(initialSchema?: GraphQLSchema) { + schema.value = initialSchema ?? null + + const currentNavItem = computed(() => { + const lastItem = navStack.value[navStack.value.length - 1] + return lastItem + }) + + /** + * Adds a new item to the navigation stack + * @param item - The navigation stack item to add + */ + function push(item: ExplorerNavStackItem) { + const lastItem = navStack.value[navStack.value.length - 1] + + // Avoid pushing duplicate items + if (lastItem.def === item.def) return + + navStack.value.push(item) + } + + /** + * Removes the last item from the navigation stack + * Won't remove the root item + */ + function pop() { + if (navStack.value.length > 1) { + navStack.value.pop() + } + } + + /** + * Resets the navigation stack to initial state + */ + function reset() { + navStack.value = + navStack.value.length === 1 ? navStack.value : [initialNavStackItem] + } + + /** + * Updates the schema and validation errors + * Rebuilds the navigation stack if needed + * @param newSchema - The new GraphQL schema + * @param newValidationErrors - Array of validation errors + */ + function updateSchema( + newSchema: GraphQLSchema, + newValidationErrors: any[] = [] + ) { + schema.value = newSchema + validationErrors.value = newValidationErrors + + // If schema is invalid, reset navigation + if (!newSchema || newValidationErrors.length > 0) { + reset() + return + } + + // Rebuild navigation stack with new schema + rebuildNavStack() + } + + /** + * Navigates to a specific index in the navigation stack + * @param index - Target index to navigate to + */ + const navigateToIndex = (index: number) => { + while (navStack.value.length > index + 1) { + pop() + } + } + + /** + * Rebuilds the navigation stack based on the current schema + * Used when schema is updated to maintain valid navigation + */ + function rebuildNavStack() { + if (!schema.value) return + + const newNavStack: ExplorerNavStack = [initialNavStackItem] + let lastEntity: GraphQLNamedType | GraphQLField | null = null + + for (const item of navStack.value.slice(1)) { + if (item.def) { + if (isNamedType(item.def)) { + const newType = schema.value.getType(item.def.name) + if (newType) { + newNavStack.push({ + name: item.name, + def: newType, + }) + lastEntity = newType + } else { + break + } + } else if (lastEntity === null) { + break + } else if (isObjectType(lastEntity) || isInputObjectType(lastEntity)) { + const field = lastEntity.getFields()[item.name] + if (field) { + newNavStack.push({ + name: item.name, + def: field, + }) + } else { + break + } + } else if ( + isScalarType(lastEntity) || + isEnumType(lastEntity) || + isInterfaceType(lastEntity) || + isUnionType(lastEntity) + ) { + break + } else { + const field: GraphQLField = lastEntity + const arg = field.args.find((a) => a.name === item.name) + + if (arg) { + newNavStack.push({ + name: item.name, + def: field, + }) + } else { + break + } + } + } else { + lastEntity = null + newNavStack.push(item) + } + } + + navStack.value = newNavStack + } + + return { + navStack, + currentNavItem, + schema, + validationErrors, + push, + pop, + navigateToIndex, + reset, + updateSchema, + } +} + +/** + * Recursively renders a GraphQL type as a Vue virtual node + * Handles non-null types, list types, and named types + * + * @param type - The GraphQL type to render + * @param renderNamedType - Function to render named types + * @returns VNode representing the rendered type + */ +export function renderType( + type: GraphQLType, + renderNamedType: (namedType: GraphQLNamedType) => any +): VNode { + if (isNonNullType(type)) { + return h("span", {}, [renderType(type.ofType, renderNamedType), "!"]) + } + if (isListType(type)) { + return h("span", {}, ["[", renderType(type.ofType, renderNamedType), "]"]) + } + return renderNamedType(type as GraphQLNamedType) +} diff --git a/packages/hoppscotch-common/src/helpers/graphql/query.ts b/packages/hoppscotch-common/src/helpers/graphql/query.ts new file mode 100644 index 00000000..302ba9d4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/graphql/query.ts @@ -0,0 +1,418 @@ +import { useService } from "dioc/vue" +import { + ArgumentNode, + DocumentNode, + FieldNode, + getNamedType, + GraphQLArgument, + GraphQLType, + Kind, + OperationDefinitionNode, + OperationTypeNode, + print, +} from "graphql" +import { ref } from "vue" +import { GQLTabService } from "~/services/tab/graphql" +import { ExplorerFieldDef, ExplorerNavStackItem, useExplorer } from "./explorer" + +/** + * Makes all properties in type T mutable + */ +type Mutable = { + -readonly [K in keyof T]: T[K] +} + +const updatedQuery = ref("") +const cursorPosition = ref({ line: 0, ch: 1 }) +const operations = ref([]) + +/** + * Hook to manage GraphQL query operations and mutations + * Provides functionality for building and modifying GraphQL operations + */ +export function useQuery() { + const tabs = useService(GQLTabService) + const { navStack } = useExplorer() + + /** + * Returns default value for a GraphQL type + * @param type - GraphQL type to get default value for + */ + const getDefaultArgumentValue = (type: GraphQLType): string => { + const namedType = getNamedType(type) + const defaultValues: Record = { + String: "", + Int: "0", + Float: "0.0", + Boolean: "false", + } + return defaultValues[namedType.name] || "null" + } + + /** + * Maps operation name to GraphQL operation type + * @param name - Operation name to convert + */ + const getOperationTypeNode = (name: string): OperationTypeNode => { + const operationTypes: Record = { + Query: OperationTypeNode.QUERY, + Mutation: OperationTypeNode.MUTATION, + Subscription: OperationTypeNode.SUBSCRIPTION, + } + return operationTypes[name] || OperationTypeNode.QUERY + } + + /** + * Finds operation definition node at given cursor position + * @param cursorPosition - Position to find operation at + */ + const getOperation = (cursorPosition: number) => { + return operations.value.find( + ({ loc }) => + loc && cursorPosition >= loc.start && cursorPosition <= loc.end + ) + } + + /** + * Creates an ArgumentNode with default value + * @param argName - Name of the argument + * @param type - GraphQL type of the argument + */ + const createArgumentNode = ( + argName: string, + type: GraphQLType + ): ArgumentNode => ({ + kind: Kind.ARGUMENT, + name: { kind: Kind.NAME, value: argName }, + value: { kind: Kind.STRING, value: getDefaultArgumentValue(type) }, + }) + + /** + * Creates a FieldNode with optional arguments and nested fields + * @param name - Field name + * @param args - Field arguments + * @param hasNestedFields - Whether field has nested selections + */ + const createFieldNode = ( + name: string, + args: GraphQLArgument[] | undefined, + hasNestedFields = false + ): Mutable => ({ + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: name }, + arguments: args?.map((arg) => createArgumentNode(arg.name, arg.type)) || [], + directives: [], + selectionSet: hasNestedFields + ? { kind: Kind.SELECTION_SET, selections: [] } + : undefined, + }) + + /** + * Result of processing an operation, including document and field location + */ + type OperationResult = { + append?: boolean + document: DocumentNode | null + fieldLocation?: { + start: number + end: number + } + } + + /** + * Processes GraphQL operation based on navigation stack + * Handles merging with existing operations and field/argument modifications + * + * @param navItems - Navigation stack items + * @param existingOperation - Existing operation to modify (if any) + * @param isArgument - Whether processing an argument + */ + const processOperation = ( + navItems: ExplorerNavStackItem[], + existingOperation?: OperationDefinitionNode, + isArgument = false + ): OperationResult => { + const queryPath = navItems.slice(2, isArgument ? -1 : undefined) + const argumentItem = isArgument ? navItems[navItems.length - 1] : null + const lastItem = queryPath[queryPath.length - 1] + const requestedOperationType = getOperationTypeNode(navItems[1].name) + + // Handle new operations + if ( + !existingOperation || + existingOperation.operation !== requestedOperationType + ) { + // Build from bottom up starting with the last field + let currentSelection = createFieldNode( + lastItem.name, + lastItem.def?.args, + lastItem.def?.fields?.length > 0 + ) + + for (let i = queryPath.length - 2; i >= 0; i--) { + const item = queryPath[i] + const parentField = createFieldNode(item.name, item.def?.args, true) + parentField.selectionSet!.selections = [currentSelection] + currentSelection = parentField + } + + return { + document: { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation: requestedOperationType, + name: { kind: Kind.NAME, value: queryPath[0].name }, + variableDefinitions: [], + directives: [], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [currentSelection], + }, + }, + ], + }, + } + } + + // For existing operations + let currentSelectionSet = existingOperation.selectionSet + let fieldExists = false + let fieldLocation: { start: number; end: number } | undefined + let append = false + + if ( + requestedOperationType === OperationTypeNode.SUBSCRIPTION && + requestedOperationType === existingOperation?.operation + ) { + // Check if paths are different at the top level + const existingTopField = existingOperation.selectionSet + .selections[0] as FieldNode + if (existingTopField.name.value !== queryPath[0].name) { + append = true + currentSelectionSet.selections = [] + } + } + + for (let i = 0; i < queryPath.length; i++) { + const item = queryPath[i] + const isLastItem = i === queryPath.length - 1 + + const existingFieldIndex = currentSelectionSet.selections.findIndex( + (selection): selection is FieldNode => + selection.kind === Kind.FIELD && selection.name.value === item.name + ) + + if (existingFieldIndex !== -1) { + const existingField = currentSelectionSet.selections[ + existingFieldIndex + ] as Mutable + + if (isLastItem) { + if (isArgument && argumentItem) { + // Handle argument modifications + const argIndex = + existingField.arguments?.findIndex( + (arg) => arg.name.value === argumentItem.name + ) ?? -1 + + if (argIndex !== -1) { + existingField.arguments?.splice(argIndex, 1) + } else { + const newArg = createArgumentNode( + argumentItem.name, + (argumentItem.def as any)?.type + ) + existingField.arguments = existingField.arguments || [] + existingField.arguments.push(newArg) + } + } else { + // Remove the field if it's not an argument operation + currentSelectionSet.selections.splice(existingFieldIndex, 1) + fieldExists = true + } + + if (existingField.loc) { + fieldLocation = { + start: existingField.loc.start, + end: existingField.loc.end, + } + } + break + } + + // Ensure parent has a selection set + if (!existingField.selectionSet) { + existingField.selectionSet = { + kind: Kind.SELECTION_SET, + selections: [], + } + } + + // Move to the next level + currentSelectionSet = existingField.selectionSet ?? { + kind: Kind.SELECTION_SET, + selections: [], + } + } else { + const newField = createFieldNode( + item.name, + (item.def as any)?.args, // these type assertion is avoidable + !isLastItem || (isLastItem && (item.def as any)?.fields?.length > 0) + ) + + // Store the approximate location where field will be added + if (currentSelectionSet.loc) { + fieldLocation = { + start: currentSelectionSet.loc.end - 1, + end: currentSelectionSet.loc.end - 1, + } + } + + if (!isLastItem) { + // Ensure non-leaf nodes have a selection set + newField.selectionSet = { + kind: Kind.SELECTION_SET, + selections: [], + } + } + + currentSelectionSet.selections.push(newField) + + if (!isLastItem) { + // Move to the next level + currentSelectionSet = newField.selectionSet! + } + } + } + + return { + document: + existingOperation.selectionSet.selections.length === 0 + ? null + : { + kind: Kind.DOCUMENT, + definitions: [existingOperation], + }, + fieldLocation, + append, + } + } + + /** + * Handles operation modifications for fields and arguments + * Updates query and cursor position based on changes + * + * @param item - Field or argument to process + * @param isArgument - Whether item is an argument + */ + const handleOperation = (item: ExplorerFieldDef, isArgument = false) => { + const currentTab = tabs.currentActiveTab.value + if (!currentTab) return + + const currentQuery = currentTab.document.request.query || "" + const selectedOperation = getOperation(currentTab.document.cursorPosition) + const navItems = [...navStack.value, { name: item.name, def: item }] + + const result = processOperation( + navItems as ExplorerNavStackItem[], + selectedOperation, + isArgument + ) + + const newQuery = result.document + ? print(result.document.definitions[0]) + : "\n" + + // If operation type is different or no existing operation, + // append as a new operation + if ( + !selectedOperation || + selectedOperation.operation !== getOperationTypeNode(navItems[1].name) || + result.append + ) { + updatedQuery.value = currentQuery.trim() + ? `${currentQuery}\n\n${newQuery}` + : newQuery + cursorPosition.value = { + line: currentQuery.split("\n").length + (currentQuery.trim() ? 2 : 1), + ch: -1, + } + return + } + + // Replace existing operation if operation types match + updatedQuery.value = currentQuery.replace( + currentQuery.substring( + selectedOperation.loc!.start, + selectedOperation.loc!.end + ), + newQuery + ) + + // Update cursor position to field location + if (result.fieldLocation) { + const precedingText = currentQuery.substring( + 0, + result.fieldLocation.start + ) + const lines = precedingText.split("\n") + cursorPosition.value = { + line: lines.length - 1, + ch: -1, + } + } + } + + const isItemInOperation = ( + item: ExplorerFieldDef, + isArgument = false + ): boolean => { + const operation = getOperation( + tabs.currentActiveTab.value?.document.cursorPosition + ) + if (!operation) return false + + // Get the current navigation path (excluding root and operation type) + const navPath = navStack.value.slice(2) + + // Start from the operation's selection set + let currentSelections = operation.selectionSet.selections + + // Follow the navigation path + for (let i = 0; i < navPath.length; i++) { + const pathItem = navPath[i] + const foundField = currentSelections.find( + (selection) => + selection.kind === Kind.FIELD && + selection.name.value === pathItem.name + ) as FieldNode | undefined + + if (!foundField?.selectionSet) return false + + currentSelections = foundField.selectionSet.selections + } + + // Check based on type + return currentSelections.some((selection) => { + if (selection.kind !== Kind.FIELD) return false + return isArgument + ? selection.arguments?.some( + (argNode) => argNode.name.value === item.name + ) + : selection.name.value === item.name + }) + } + + return { + handleAddField: (field: ExplorerFieldDef) => handleOperation(field), + handleAddArgument: (arg: ExplorerFieldDef) => handleOperation(arg, true), + updatedQuery, + cursorPosition, + operationDefinitions: operations, + isFieldInOperation: (field: ExplorerFieldDef) => isItemInOperation(field), + isArgumentInOperation: (arg: ExplorerFieldDef) => + isItemInOperation(arg, true), + } +} diff --git a/packages/hoppscotch-common/src/pages/graphql.vue b/packages/hoppscotch-common/src/pages/graphql.vue index 8bdffb80..05f033c6 100644 --- a/packages/hoppscotch-common/src/pages/graphql.vue +++ b/packages/hoppscotch-common/src/pages/graphql.vue @@ -8,7 +8,7 @@ v-if="currentTabID" :id="'gql_windows'" :model-value="currentTabID" - @update:model-value="(tabID) => tabs.setActiveTab(tabID)" + @update:model-value="changeTab" @remove-tab="removeTab" @add-tab="addNewTab" @sort="sortTabs" @@ -92,12 +92,14 @@ import { defineActionHandler } from "~/helpers/actions" import { connection, disconnect } from "~/helpers/graphql/connection" import { getDefaultGQLRequest } from "~/helpers/graphql/default" import { HoppGQLDocument } from "~/helpers/graphql/document" +import { useExplorer } from "~/helpers/graphql/explorer" import { InspectionService } from "~/services/inspection" import { HoppTab } from "~/services/tab" import { GQLTabService } from "~/services/tab/graphql" const t = useI18n() const tabs = useService(GQLTabService) +const { reset } = useExplorer() const currentTabID = computed(() => tabs.currentTabID.value) @@ -115,6 +117,7 @@ const addNewTab = () => { const tab = tabs.createNewTab({ request: getDefaultGQLRequest(), isDirty: false, + cursorPosition: 0, }) tabs.setActiveTab(tab.id) @@ -122,6 +125,10 @@ const addNewTab = () => { const sortTabs = (e: { oldIndex: number; newIndex: number }) => { tabs.updateTabOrdering(e.oldIndex, e.newIndex) } +const changeTab = (tabID: string) => { + reset() + tabs.setActiveTab(tabID) +} const removeTab = (tabID: string) => { const tabState = tabs.getTabRef(tabID).value @@ -211,6 +218,7 @@ const duplicateTab = (tabID: string) => { const newTab = tabs.createNewTab({ request: tab.value.document.request, isDirty: true, + cursorPosition: 0, }) tabs.setActiveTab(newTab.id) } @@ -221,6 +229,7 @@ defineActionHandler("gql.request.open", ({ request, saveContext }) => { saveContext, request: request, isDirty: false, + cursorPosition: 0, }) }) diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index b6faa3e8..6a9e1382 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -329,6 +329,7 @@ export const GQL_TAB_STATE_SCHEMA = z responseTabPreference: z.optional(z.string()), optionTabPreference: z.optional(z.enum(validGqlOperations)), inheritedProperties: z.optional(HoppInheritedPropertySchema), + cursorPosition: z.optional(z.number()), }) .strict(), }) diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts index cb195b0b..048d2e31 100644 --- a/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts @@ -360,6 +360,7 @@ export class CollectionsSpotlightSearcherService folderPath: folderPath.join("/"), requestIndex: reqIndex, }, + cursorPosition: 0, request: req, isDirty: false, inheritedProperties: { diff --git a/packages/hoppscotch-common/src/services/tab/graphql.ts b/packages/hoppscotch-common/src/services/tab/graphql.ts index afaa257a..c88d619f 100644 --- a/packages/hoppscotch-common/src/services/tab/graphql.ts +++ b/packages/hoppscotch-common/src/services/tab/graphql.ts @@ -19,6 +19,7 @@ export class GQLTabService extends TabService { request: getDefaultGQLRequest(), isDirty: false, optionTabPreference: "query", + cursorPosition: 0, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d162804f..297850b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,9 @@ importers: '@shopify/lang-jsonc': specifier: 1.0.0 version: 1.0.0 + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 '@unhead/vue': specifier: 1.11.10 version: 1.11.10(vue@3.5.12(typescript@5.3.3)) @@ -601,6 +604,9 @@ importers: lossless-json: specifier: 4.0.2 version: 4.0.2 + markdown-it: + specifier: 14.1.0 + version: 14.1.0 minisearch: specifier: 7.1.0 version: 7.1.0 @@ -5113,6 +5119,9 @@ packages: '@types/jsonwebtoken@9.0.6': resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.10': resolution: {integrity: sha512-YJP+w/2khSBwbUSFdGsSqmDvmnN3cCKoPOL7Zjle6s30ZtemkkqhjVfFqGwPN7ASil5VyjE2GtyU/yqYY6mC0A==} @@ -5131,6 +5140,12 @@ packages: '@types/luxon@3.4.2': resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -9121,12 +9136,19 @@ packages: map-stream@0.0.7: resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -16918,6 +16940,8 @@ snapshots: dependencies: '@types/node': 22.7.6 + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.10': dependencies: '@types/lodash': 4.17.10 @@ -16934,6 +16958,13 @@ snapshots: '@types/luxon@3.4.2': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -22167,7 +22198,6 @@ snapshots: linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 - optional: true lint-staged@15.2.10: dependencies: @@ -22402,12 +22432,23 @@ snapshots: map-stream@0.0.7: {} + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + mdn-data@2.0.28: optional: true mdn-data@2.0.30: optional: true + mdurl@2.0.0: {} + media-typer@0.3.0: {} memfs@3.5.3: @@ -24084,8 +24125,7 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 - punycode.js@2.3.1: - optional: true + punycode.js@2.3.1: {} punycode@1.4.1: {} @@ -25598,8 +25638,7 @@ snapshots: ua-parser-js@1.0.39: {} - uc.micro@2.1.0: - optional: true + uc.micro@2.1.0: {} ufo@1.5.4: {}