feat: graphql document generation from docs (#4626)
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
parent
0a83894e6a
commit
499505397f
34 changed files with 2016 additions and 373 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
28
packages/hoppscotch-common/src/components.d.ts
vendored
28
packages/hoppscotch-common/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
35
packages/hoppscotch-common/src/components/app/Markdown.vue
Normal file
35
packages/hoppscotch-common/src/components/app/Markdown.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div :class="classes" v-html="renderedMarkdown"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, useSlots } from "vue"
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
interface Props {
|
||||
onlyShowFirstChild?: boolean
|
||||
type: "description" | "deprecation"
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const slots: { default?: () => any[] } = useSlots()
|
||||
|
||||
const classes = computed(() => {
|
||||
let classList = `hopp-markdown-${props.type}`
|
||||
|
||||
if (props.onlyShowFirstChild) {
|
||||
classList += " hopp-markdown-preview"
|
||||
}
|
||||
|
||||
return classList
|
||||
})
|
||||
|
||||
const renderedMarkdown = computed(() =>
|
||||
markdown.render(slots.default ? slots.default()[0].children : "")
|
||||
)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<template v-if="inline">
|
||||
<span>
|
||||
<span class="hopp-doc-explorer-argument-name"> {{ arg.name }} </span>:
|
||||
<GraphqlTypeLink :type="arg.type" />
|
||||
<GraphqlDefaultValue v-if="showDefaultValue !== false" :field="arg" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="hopp-doc-explorer-argument">
|
||||
<p class="inline-flex items-center mb-0 gap-2 align-bottom">
|
||||
<span
|
||||
v-if="showAddButton"
|
||||
class="hover:text-accent cursor-pointer"
|
||||
:class="{ 'text-accent': isArgumentInOperation(arg) }"
|
||||
@click="insertQuery"
|
||||
>
|
||||
<icon-lucide-plus-circle v-if="!isArgumentInOperation(arg)" />
|
||||
<icon-lucide-circle-check v-else />
|
||||
</span>
|
||||
<span class="hopp-doc-explorer-argument-name"> {{ arg.name }} </span>:
|
||||
<GraphqlTypeLink :type="arg.type" />
|
||||
<GraphqlDefaultValue v-if="showDefaultValue !== false" :field="arg" />
|
||||
</p>
|
||||
|
||||
<!-- <AppMarkdown v-if="arg.description" type="description">
|
||||
{{ arg.description }}
|
||||
</AppMarkdown> -->
|
||||
|
||||
<div
|
||||
v-if="arg.deprecationReason"
|
||||
class="hopp-doc-explorer-argument-deprecation"
|
||||
>
|
||||
<div class="hopp-doc-explorer-argument-deprecation-label">Deprecated</div>
|
||||
|
||||
<AppMarkdown type="deprecationReason">
|
||||
{{ arg.deprecationReason }}
|
||||
</AppMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GraphQLArgument } from "graphql"
|
||||
import { useQuery } from "~/helpers/graphql/query"
|
||||
|
||||
const { handleAddArgument, isArgumentInOperation } = useQuery()
|
||||
|
||||
interface ArgumentProps {
|
||||
/**
|
||||
* The argument that should be rendered.
|
||||
*/
|
||||
arg: GraphQLArgument
|
||||
/**
|
||||
* Toggle if the default value for the argument is shown (if there is one)
|
||||
* @default false
|
||||
*/
|
||||
showDefaultValue?: boolean
|
||||
/**
|
||||
* Toggle whether to render the whole argument including description and
|
||||
* deprecation reason (`false`) or to just render the argument name, type,
|
||||
* and default value in a single line (`true`).
|
||||
* @default false
|
||||
*/
|
||||
inline?: boolean
|
||||
|
||||
/**
|
||||
* Whether to show the add button or not
|
||||
*/
|
||||
showAddButton?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ArgumentProps>(), {
|
||||
showDefaultValue: false,
|
||||
inline: false,
|
||||
showAddButton: false,
|
||||
})
|
||||
|
||||
const insertQuery = () => {
|
||||
handleAddArgument(props.arg)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hopp-doc-explorer-argument {
|
||||
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<template v-if="'args' in field">
|
||||
<GraphqlExplorerSection v-if="field.args.length > 0" title="Arguments">
|
||||
<GraphqlArgument
|
||||
v-for="arg in field.args"
|
||||
:key="arg.name"
|
||||
:arg="arg"
|
||||
:show-add-button="true"
|
||||
/>
|
||||
</GraphqlExplorerSection>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GraphQLField } from "graphql"
|
||||
|
||||
defineProps<{
|
||||
field: GraphQLField<any, any>
|
||||
}>()
|
||||
</script>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<template v-if="defaultValueAst">
|
||||
{' = '}
|
||||
<span class="hopp-doc-explorer-default-value">
|
||||
{{ printDefault(defaultValueAst) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { astFromValue, print } from "graphql"
|
||||
import type { ValueNode } from "graphql"
|
||||
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
field: ExplorerFieldDef
|
||||
}>()
|
||||
|
||||
const printDefault = (ast?: ValueNode | null): string => {
|
||||
if (!ast) return ""
|
||||
return print(ast)
|
||||
}
|
||||
|
||||
const defaultValueAst = computed(() => {
|
||||
if (
|
||||
!("defaultValue" in props.field) ||
|
||||
props.field.defaultValue === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return astFromValue(props.field.defaultValue, props.field.type)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<ExplorerSection v-if="directives.length > 0" title="Directives">
|
||||
<div v-for="directive in directives" :key="directive.name.value">
|
||||
<span class="hopp-doc-explorer-directive">
|
||||
@{{ directive?.name?.value }}
|
||||
</span>
|
||||
</div>
|
||||
</ExplorerSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
|
||||
import ExplorerSection from "./ExplorerSection.vue"
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
field: ExplorerFieldDef
|
||||
}>()
|
||||
|
||||
const directives = computed(() => props.field.astNode?.directives || [])
|
||||
</script>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<section
|
||||
v-if="schema"
|
||||
class="hopp-doc-explorer pb-10"
|
||||
aria-label="Documentation Explorer"
|
||||
>
|
||||
<div class="sticky top-0 z-10 border-b border-dividerLight bg-primary">
|
||||
<GraphqlSchemaSearch />
|
||||
<div
|
||||
class="flex items-center overflow-x-auto whitespace-nowrap px-3 py-2 text-tiny text-secondaryLight"
|
||||
>
|
||||
<template v-for="(item, index) in navStack" :key="index">
|
||||
<span
|
||||
class="cursor-pointer hover:text-secondary"
|
||||
@click="navigateToIndex(index)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span>
|
||||
<icon-lucide-chevron-right
|
||||
v-if="index < navStack.length - 1"
|
||||
class="mx-1"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hopp-doc-explorer-content mt-4">
|
||||
<template v-if="navStack.length === 1">
|
||||
<GraphqlSchemaDocumentation :schema="schema" />
|
||||
</template>
|
||||
<template v-else-if="isType(currentNavItem.def)">
|
||||
<div
|
||||
class="hopp-doc-explorer-title text-xl font-bold break-words px-3 mb-4"
|
||||
>
|
||||
{{ currentNavItem.name }}
|
||||
</div>
|
||||
<GraphqlTypeDocumentation :type="currentNavItem.def" />
|
||||
</template>
|
||||
<template v-else-if="currentNavItem.def">
|
||||
<GraphqlFieldDocumentation :field="currentNavItem.def" />
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
v-else
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
:alt="t('empty.empty_schema')"
|
||||
:text="t('empty.empty_schema')"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isType } from "graphql"
|
||||
import { schema } from "~/helpers/graphql/connection"
|
||||
import { useExplorer } from "../../helpers/graphql/explorer"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
// Use explorer composable
|
||||
const { navStack, currentNavItem, navigateToIndex } = useExplorer()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.hopp-doc-explorer-field-name {
|
||||
color: hsl(208, 100%, 72%);
|
||||
}
|
||||
.hopp-doc-explorer-root-type {
|
||||
color: hsl(208, 100%, 72%);
|
||||
}
|
||||
.hopp-doc-explorer-type-name {
|
||||
cursor: pointer;
|
||||
color: hsl(30, 100%, 80%);
|
||||
}
|
||||
|
||||
.hopp-doc-explorer-argument-name {
|
||||
color: hsl(243, 100%, 77%);
|
||||
}
|
||||
|
||||
.hopp-doc-explorer-argument-multiple {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.hopp-doc-explorer-argument-deprecation {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: hsl(0, 100%, 90%);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<template v-if="isEnumType(type)">
|
||||
<GraphqlExplorerSection
|
||||
v-if="enumValues.values.length > 0"
|
||||
title="Enum Values"
|
||||
>
|
||||
<div
|
||||
v-for="value in enumValues.values"
|
||||
:key="value.name"
|
||||
class="hopp-doc-explorer-item"
|
||||
>
|
||||
<div class="hopp-doc-explorer-enum-value">{{ value.name }}</div>
|
||||
<GraphqlMarkdown v-if="value.description" type="description">
|
||||
{{ value.description }}
|
||||
</GraphqlMarkdown>
|
||||
<GraphqlMarkdown v-if="value.deprecationReason" type="deprecation">
|
||||
{{ value.deprecationReason }}
|
||||
</GraphqlMarkdown>
|
||||
</div>
|
||||
</GraphqlExplorerSection>
|
||||
|
||||
<template v-if="enumValues.deprecatedValues.length > 0">
|
||||
<ExplorerSection
|
||||
v-if="showDeprecated || enumValues.values.length === 0"
|
||||
title="Deprecated Enum Values"
|
||||
>
|
||||
<EnumValue
|
||||
v-for="value in enumValues.deprecatedValues"
|
||||
:key="value.name"
|
||||
:value="value"
|
||||
/>
|
||||
</ExplorerSection>
|
||||
|
||||
<button v-else type="button" @click="handleShowDeprecated">
|
||||
{{ t("graphql.show_depricated_values") }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
import type { GraphQLNamedType, GraphQLEnumValue } from "graphql"
|
||||
import { isEnumType } from "graphql"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
interface EnumValuesProps {
|
||||
type: GraphQLNamedType
|
||||
}
|
||||
|
||||
const props = defineProps<EnumValuesProps>()
|
||||
|
||||
const showDeprecated = ref(false)
|
||||
|
||||
const handleShowDeprecated = () => {
|
||||
showDeprecated.value = true
|
||||
}
|
||||
|
||||
const enumValues = computed(() => {
|
||||
if (!isEnumType(props.type)) {
|
||||
return { values: [], deprecatedValues: [] }
|
||||
}
|
||||
|
||||
const values: GraphQLEnumValue[] = []
|
||||
const deprecatedValues: GraphQLEnumValue[] = []
|
||||
|
||||
for (const value of props.type.getValues()) {
|
||||
if (value.deprecationReason) {
|
||||
deprecatedValues.push(value)
|
||||
} else {
|
||||
values.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
return { values, deprecatedValues }
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="mb-6">
|
||||
<div class="hopp-doc-explorer-section-title flex gap-2 mb-2 font-bold">
|
||||
<!-- <component :is="iconComponent" /> -->
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="hopp-doc-explorer-section-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconArgument from "~icons/lucide/diamond-minus"
|
||||
import IconDeprecatedArgument from "~icons/lucide/diamond-minus"
|
||||
import IconDeprecatedEnumValue from "~icons/lucide/diamond-minus"
|
||||
import IconDeprecatedField from "~icons/lucide/diamond-minus"
|
||||
import IconDierctive from "~icons/lucide/arrow-right"
|
||||
import IconEnumValue from "~icons/lucide/arrow-right"
|
||||
import IconField from "~icons/lucide/rectangle-ellipsis"
|
||||
import IconImplements from "~icons/lucide/arrow-right"
|
||||
import IconType from "~icons/lucide/file-type"
|
||||
import IconRootTypes from "~icons/lucide/folder-kanban"
|
||||
|
||||
type ExplorerSectionTitle =
|
||||
| "Root Types"
|
||||
| "Fields"
|
||||
| "Deprecated Fields"
|
||||
| "Type"
|
||||
| "Arguments"
|
||||
| "Deprecated Arguments"
|
||||
| "Implements"
|
||||
| "Implementations"
|
||||
| "Possible Types"
|
||||
| "Enum Values"
|
||||
| "Deprecated Enum Values"
|
||||
| "Directives"
|
||||
| "All Schema Types"
|
||||
|
||||
const props = defineProps<{
|
||||
title: ExplorerSectionTitle
|
||||
}>()
|
||||
|
||||
const TYPE_TO_ICON: Record<ExplorerSectionTitle, any> = {
|
||||
Arguments: IconArgument,
|
||||
"Deprecated Arguments": IconDeprecatedArgument,
|
||||
"Deprecated Enum Values": IconDeprecatedEnumValue,
|
||||
"Deprecated Fields": IconDeprecatedField,
|
||||
Directives: IconDierctive,
|
||||
"Enum Values": IconEnumValue,
|
||||
Fields: IconField,
|
||||
Implements: IconImplements,
|
||||
Implementations: IconType,
|
||||
"Possible Types": IconType,
|
||||
"Root Types": IconRootTypes,
|
||||
Type: IconType,
|
||||
"All Schema Types": IconType,
|
||||
}
|
||||
|
||||
const iconComponent = TYPE_TO_ICON[props.title]
|
||||
</script>
|
||||
|
|
@ -1,102 +1,90 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="hopp-doc-explorer-item" @click="handleClick">
|
||||
<div class="flex">
|
||||
<div
|
||||
class="field-title flex-1"
|
||||
:class="{ 'field-highlighted': isHighlighted }"
|
||||
:class="{
|
||||
'!line-through': field.deprecationReason,
|
||||
}"
|
||||
>
|
||||
{{ fieldName }}
|
||||
<span v-if="fieldArgs.length > 0">
|
||||
(
|
||||
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
|
||||
{{ field.name }}:
|
||||
<GraphqlTypeLink
|
||||
:gql-type="field.type"
|
||||
@jump-to-type="jumpToType"
|
||||
/>
|
||||
<span v-if="index !== fieldArgs.length - 1">, </span>
|
||||
</span>
|
||||
) </span
|
||||
<GraphqlFieldLink
|
||||
:field="field"
|
||||
:show-add-field="showAddField"
|
||||
:is-added="isFieldInOperation(field)"
|
||||
@add-field="insertQuery"
|
||||
/>
|
||||
<template v-if="args.length > 0">
|
||||
(<span>
|
||||
<template v-for="arg in args" :key="arg.name">
|
||||
<div
|
||||
v-if="args.length > 1"
|
||||
class="hopp-doc-explorer-argument-multiple"
|
||||
>
|
||||
<GraphqlArgument :arg="arg" inline />
|
||||
</div>
|
||||
<GraphqlArgument v-else :arg="arg" inline />
|
||||
</template> </span
|
||||
>)</template
|
||||
>:
|
||||
<GraphqlTypeLink :gql-type="gqlField.type" @jump-to-type="jumpToType" />
|
||||
</div>
|
||||
<div v-if="gqlField.deprecationReason">
|
||||
<span
|
||||
v-tippy="{ theme: 'tomato' }"
|
||||
class="flex cursor-pointer items-center gap-2 text-xs !text-red-500 hover:!text-red-600"
|
||||
:title="gqlField.deprecationReason"
|
||||
>
|
||||
<IconAlertTriangle /> {{ t("state.deprecated") }}
|
||||
</span>
|
||||
<GraphqlTypeLink :type="field.type" />
|
||||
<GraphqlDefaultValue :field="field" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="field.deprecationReason"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
class="hopp-doc-explorer-deprecated inline ml-auto text-red-500"
|
||||
:title="field.deprecationReason"
|
||||
>
|
||||
<icon-lucide-triangle-alert />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="gqlField.description"
|
||||
class="field-desc py-2 text-secondaryLight"
|
||||
|
||||
<AppMarkdown
|
||||
v-if="field.description"
|
||||
type="description"
|
||||
class="hidden"
|
||||
:only-show-first-child="true"
|
||||
>
|
||||
{{ gqlField.description }}
|
||||
</div>
|
||||
<div v-if="fieldArgs.length > 0">
|
||||
<h5 class="my-2">Arguments:</h5>
|
||||
<div class="border-l-2 border-divider pl-4">
|
||||
<div v-for="(field, index) in fieldArgs" :key="`field-${index}`">
|
||||
<span>
|
||||
{{ field.name }}:
|
||||
<GraphqlTypeLink
|
||||
:gql-type="field.type"
|
||||
@jump-to-type="jumpToType"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="field-desc py-2 text-secondaryLight"
|
||||
>
|
||||
{{ field.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ field.description }}
|
||||
</AppMarkdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { GraphQLType } from "graphql"
|
||||
import { computed } from "vue"
|
||||
import IconAlertTriangle from "~icons/lucide/alert-triangle"
|
||||
|
||||
const t = useI18n()
|
||||
import { ExplorerFieldDef, useExplorer } from "~/helpers/graphql/explorer"
|
||||
import { useQuery } from "~/helpers/graphql/query"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
gqlField: any
|
||||
isHighlighted: boolean
|
||||
field: ExplorerFieldDef
|
||||
showAddField: boolean
|
||||
}>(),
|
||||
{
|
||||
gqlField: {},
|
||||
isHighlighted: false,
|
||||
showAddField: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "jump-to-type", type: GraphQLType): void
|
||||
}>()
|
||||
const { push } = useExplorer()
|
||||
const { handleAddField, isFieldInOperation } = useQuery()
|
||||
|
||||
const fieldName = computed(() => props.gqlField.name)
|
||||
|
||||
const fieldArgs = computed(() => props.gqlField.args || [])
|
||||
|
||||
const jumpToType = (type: GraphQLType) => {
|
||||
emit("jump-to-type", type)
|
||||
const handleClick = () => {
|
||||
push({ name: props.field.name, def: props.field })
|
||||
}
|
||||
|
||||
const insertQuery = () => {
|
||||
handleAddField(props.field)
|
||||
}
|
||||
|
||||
const args = computed(() =>
|
||||
"args" in props.field
|
||||
? props.field.args.filter((arg) => !arg.deprecationReason)
|
||||
: []
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-highlighted {
|
||||
@apply border-b-2 border-accent;
|
||||
}
|
||||
|
||||
.field-title {
|
||||
@apply select-text;
|
||||
<style scoped lang="scss">
|
||||
.hopp-doc-explorer-item {
|
||||
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="px-3">
|
||||
<div
|
||||
class="hopp-doc-explorer-title text-xl font-bold break-words flex-wrap mb-4 flex items-center gap-2 leading-[inherit]"
|
||||
>
|
||||
{{ field.name }}:
|
||||
<GraphqlTypeLink :type="field.type" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="deprecationReason"
|
||||
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-3 mb-4 -mt-2"
|
||||
role="alert"
|
||||
>
|
||||
<p class="font-bold uppercase">
|
||||
{{ t("graphql.deprecated") }}
|
||||
</p>
|
||||
<AppMarkdown type="deprecation">
|
||||
{{ deprecationReason }}
|
||||
</AppMarkdown>
|
||||
</div>
|
||||
|
||||
<AppMarkdown v-if="hasDescription" type="description" class="mb-6">
|
||||
{{ description }}
|
||||
</AppMarkdown>
|
||||
<!-- <GraphqlExplorerSection title="Type">
|
||||
<GraphqlTypeLink :type="field.type" />
|
||||
</GraphqlExplorerSection> -->
|
||||
<GraphqlArguments :field="field" />
|
||||
<GraphqlFields :type="resolvedType" />
|
||||
<GraphqlDirectives :field="field" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GraphQLOutputType, getNamedType } from "graphql"
|
||||
import { computed, defineProps } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
// Update the Field interface
|
||||
interface Field {
|
||||
name: string
|
||||
description: string | null
|
||||
deprecationReason: string
|
||||
type: GraphQLOutputType
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
field: Field
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
// Computed properties
|
||||
const hasDescription = computed(() => props.field.description !== null)
|
||||
const description = computed(() => props.field.description)
|
||||
const deprecationReason = computed(() => props.field.deprecationReason)
|
||||
|
||||
// Add new computed property to resolve the named type
|
||||
const resolvedType = computed(() => getNamedType(props.field.type))
|
||||
</script>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<p class="inline-flex items-center mb-0 gap-2 align-bottom">
|
||||
<span
|
||||
v-if="showAddField"
|
||||
class="hover:text-accent cursor-pointer"
|
||||
:class="{ 'text-accent': isAdded }"
|
||||
@click.stop="emit('add-field', field)"
|
||||
>
|
||||
<icon-lucide-plus-circle v-if="!isAdded" />
|
||||
<icon-lucide-circle-check v-else />
|
||||
</span>
|
||||
<span
|
||||
class="hopp-doc-explorer-field-name [text-decoration:inherit]"
|
||||
@click="clickable ? handleClick : undefined"
|
||||
>
|
||||
{{ field.name }}
|
||||
</span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type ExplorerFieldDef, useExplorer } from "~/helpers/graphql/explorer"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
field: ExplorerFieldDef
|
||||
clickable?: boolean
|
||||
showAddField: boolean
|
||||
isAdded?: boolean
|
||||
}>(),
|
||||
{
|
||||
clickable: true,
|
||||
showAddField: true,
|
||||
isAdded: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "add-field", field: ExplorerFieldDef): void
|
||||
}>()
|
||||
|
||||
const { push } = useExplorer()
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
push({ name: props.field.name, def: props.field })
|
||||
}
|
||||
</script>
|
||||
48
packages/hoppscotch-common/src/components/graphql/Fields.vue
Normal file
48
packages/hoppscotch-common/src/components/graphql/Fields.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div>
|
||||
<GraphqlExplorerSection v-if="fields.length > 0" title="Fields">
|
||||
<GraphqlField
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
:field="field"
|
||||
:show-add-field="showAddField"
|
||||
/>
|
||||
</GraphqlExplorerSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GraphQLNamedType,
|
||||
isInputObjectType,
|
||||
isInterfaceType,
|
||||
isObjectType,
|
||||
} from "graphql"
|
||||
import { computed } from "vue"
|
||||
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: GraphQLNamedType
|
||||
showAddField: boolean
|
||||
}>(),
|
||||
{
|
||||
showAddField: true,
|
||||
}
|
||||
)
|
||||
|
||||
const fieldMap = computed(() => {
|
||||
if (
|
||||
!isObjectType(props.type) &&
|
||||
!isInterfaceType(props.type) &&
|
||||
!isInputObjectType(props.type)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return props.type.getFields()
|
||||
})
|
||||
|
||||
const fields = computed(() => {
|
||||
return Object.values(fieldMap.value) as ExplorerFieldDef[]
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<GraphqlExplorerSection v-if="interfaces.length > 0" title="Implements">
|
||||
<div
|
||||
v-for="implementedInterface in interfaces"
|
||||
:key="implementedInterface.name"
|
||||
>
|
||||
<GraphqlTypeLink :type="implementedInterface" />
|
||||
</div>
|
||||
</GraphqlExplorerSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, computed } from "vue"
|
||||
import { isObjectType, GraphQLNamedType } from "graphql"
|
||||
|
||||
const props = defineProps<{
|
||||
type: GraphQLNamedType
|
||||
}>()
|
||||
|
||||
const interfaces = computed(() => {
|
||||
if (!isObjectType(props.type)) {
|
||||
return []
|
||||
}
|
||||
return props.type.getInterfaces()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -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<any | null>(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<typeof IconCopy | typeof IconCheck>(
|
||||
|
|
@ -141,45 +143,60 @@ const prettifyQueryIcon = refAutoReset<
|
|||
|
||||
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlQuery")
|
||||
|
||||
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
|
||||
const selectedOperation = ref<OperationDefinitionNode | null>(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 = () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
v-model="request.query"
|
||||
@run-query="runQuery"
|
||||
@save-request="saveRequest"
|
||||
@cursor-position="updateCursorPos"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
|
|
@ -244,6 +245,10 @@ watch(
|
|||
{ deep: true }
|
||||
)
|
||||
|
||||
const updateCursorPos = (pos: number) => {
|
||||
tabs.currentActiveTab.value.document.cursorPosition = pos
|
||||
}
|
||||
|
||||
const hideRequestModal = () => {
|
||||
showSaveRequestModal.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div class="px-3">
|
||||
<AppMarkdown type="description" class="mb-4">
|
||||
{{ schemaDescription }}
|
||||
</AppMarkdown>
|
||||
|
||||
<GraphqlExplorerSection title="Root Types">
|
||||
<div
|
||||
v-if="queryType"
|
||||
class="hopp-doc-explorer-root-wrapper"
|
||||
@click="handleTypeClick(queryType)"
|
||||
>
|
||||
<span class="hopp-doc-explorer-root-type">
|
||||
{{ t("graphql.query") }}
|
||||
</span>
|
||||
{{ ": " }}
|
||||
<GraphqlTypeLink :type="queryType" />
|
||||
</div>
|
||||
<div
|
||||
v-if="mutationType"
|
||||
class="hopp-doc-explorer-root-wrapper"
|
||||
@click="handleTypeClick(mutationType)"
|
||||
>
|
||||
<span class="hopp-doc-explorer-root-type">
|
||||
{{ t("graphql.mutation") }}
|
||||
</span>
|
||||
{{ ": " }}
|
||||
<GraphqlTypeLink :type="mutationType" />
|
||||
</div>
|
||||
<div
|
||||
v-if="subscriptionType"
|
||||
class="hopp-doc-explorer-root-wrapper"
|
||||
@click="handleTypeClick(subscriptionType)"
|
||||
>
|
||||
<span class="hopp-doc-explorer-root-type">
|
||||
{{ t("graphql.subscription") }}
|
||||
</span>
|
||||
{{ ": " }}
|
||||
<GraphqlTypeLink :type="subscriptionType" />
|
||||
</div>
|
||||
</GraphqlExplorerSection>
|
||||
<GraphqlExplorerSection title="All Schema Types">
|
||||
<div v-if="filteredTypes">
|
||||
<div v-for="type in filteredTypes" :key="type.name" class="px-2">
|
||||
<GraphqlTypeLink :type="type" :clickable="true" />
|
||||
</div>
|
||||
</div>
|
||||
</GraphqlExplorerSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import type { GraphQLNamedType, GraphQLSchema } from "graphql"
|
||||
import { useExplorer } from "~/helpers/graphql/explorer"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const props = defineProps<{
|
||||
schema: GraphQLSchema
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const queryType = props.schema.getQueryType()
|
||||
const mutationType = props.schema.getMutationType?.()
|
||||
const subscriptionType = props.schema.getSubscriptionType?.()
|
||||
const typeMap = props.schema.getTypeMap()
|
||||
|
||||
const schemaDescription = computed(
|
||||
() =>
|
||||
props.schema.description ||
|
||||
"A GraphQL schema provides a root type for each kind of operation."
|
||||
)
|
||||
|
||||
const { push } = useExplorer()
|
||||
|
||||
const handleTypeClick = (namedType: GraphQLNamedType) => {
|
||||
push({ name: namedType.name, def: namedType })
|
||||
}
|
||||
|
||||
const ignoreTypesInAllSchema = computed(() => [
|
||||
queryType?.name,
|
||||
mutationType?.name,
|
||||
subscriptionType?.name,
|
||||
])
|
||||
|
||||
const filteredTypes = computed(() => {
|
||||
if (!typeMap) return []
|
||||
|
||||
return Object.values(typeMap).filter(
|
||||
(type) =>
|
||||
!ignoreTypesInAllSchema.value.includes(type.name) &&
|
||||
!type.name.startsWith("__")
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hopp-doc-explorer-root-wrapper {
|
||||
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
|
||||
}
|
||||
.hopp-doc-explorer-root-type {
|
||||
@apply lowercase;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
<template>
|
||||
<div class="border-b border-dividerLight autocomplete-wrapper">
|
||||
<div ref="searchWrapper" class="no-scrollbar inset-0 flex flex-1">
|
||||
<input
|
||||
v-model="searchText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex w-full bg-transparent px-3 py-2 h-8"
|
||||
:placeholder="t('action.search')"
|
||||
@keydown="handleSearchKeystroke"
|
||||
@focusin="showSearchResults = true"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
v-if="showSearchResults && filteredResults.length > 0"
|
||||
ref="resultsMenu"
|
||||
class="suggestions"
|
||||
>
|
||||
<li
|
||||
v-for="(result, index) in filteredResults"
|
||||
:key="`result-${index}`"
|
||||
:class="{ active: currentResultIndex === index }"
|
||||
@click="selectSearchResult(result)"
|
||||
>
|
||||
<span class="truncate py-0.5">
|
||||
{{ result.name }}
|
||||
<span class="text-secondaryLight">- {{ result.type }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
GraphQLArgument,
|
||||
GraphQLField,
|
||||
GraphQLInputField,
|
||||
GraphQLNamedType,
|
||||
} from "graphql"
|
||||
import { isInputObjectType, isInterfaceType, isObjectType } from "graphql"
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { schema } from "~/helpers/graphql/connection"
|
||||
import { ExplorerNavStackItem, useExplorer } from "~/helpers/graphql/explorer"
|
||||
|
||||
// Types
|
||||
type SearchResult = {
|
||||
name: string
|
||||
type: string
|
||||
def:
|
||||
| GraphQLNamedType
|
||||
| GraphQLField<unknown, unknown>
|
||||
| GraphQLInputField
|
||||
| GraphQLArgument
|
||||
}
|
||||
|
||||
// Composables
|
||||
const { t } = useI18n()
|
||||
const { navStack, push, pop } = useExplorer()
|
||||
|
||||
// Refs
|
||||
const searchText = ref("")
|
||||
const showSearchResults = ref(false)
|
||||
const currentResultIndex = ref(-1)
|
||||
const searchWrapper = ref<HTMLElement | null>(null)
|
||||
const resultsMenu = ref<HTMLElement | null>(null)
|
||||
|
||||
// Search Results Generation
|
||||
const generateSearchResults = () => {
|
||||
const results: SearchResult[] = []
|
||||
|
||||
if (!schema.value) return results
|
||||
|
||||
const typeMap = schema.value.getTypeMap()
|
||||
const typeNames = Object.keys(typeMap).filter(
|
||||
(name) => !name.startsWith("__")
|
||||
) // Filter out __ types
|
||||
|
||||
for (const typeName of typeNames) {
|
||||
if (results.length >= 100) break
|
||||
|
||||
const type = typeMap[typeName]
|
||||
|
||||
// Add type match
|
||||
if (typeName.toLowerCase().includes(searchText.value.toLowerCase())) {
|
||||
results.push({
|
||||
name: typeName,
|
||||
type: "Type",
|
||||
def: type,
|
||||
})
|
||||
}
|
||||
|
||||
// Skip if not an object, interface, or input object type
|
||||
if (
|
||||
!isObjectType(type) &&
|
||||
!isInterfaceType(type) &&
|
||||
!isInputObjectType(type)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search fields
|
||||
const fields = type.getFields()
|
||||
for (const fieldName in fields) {
|
||||
// Skip fields starting with __
|
||||
if (fieldName.startsWith("__")) continue
|
||||
|
||||
const field = fields[fieldName]
|
||||
|
||||
// Field name match
|
||||
if (fieldName.toLowerCase().includes(searchText.value.toLowerCase())) {
|
||||
results.push({
|
||||
name: fieldName,
|
||||
type: `Field in ${typeName}`,
|
||||
def: field,
|
||||
})
|
||||
}
|
||||
|
||||
// Search arguments if applicable
|
||||
if ("args" in field) {
|
||||
field.args.forEach((arg) => {
|
||||
if (arg.name.toLowerCase().includes(searchText.value.toLowerCase())) {
|
||||
results.push({
|
||||
name: arg.name,
|
||||
type: `Argument in ${fieldName}`,
|
||||
def: arg,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Computed
|
||||
const filteredResults = computed(() =>
|
||||
searchText.value.trim() ? generateSearchResults() : []
|
||||
)
|
||||
|
||||
// Methods
|
||||
const buildNavigationPath = (result: SearchResult): ExplorerNavStackItem[] => {
|
||||
const path: ExplorerNavStackItem[] = []
|
||||
|
||||
if (result.type === "Type") {
|
||||
// For type results, traverse through schema to build proper path
|
||||
const type = result.def as GraphQLNamedType
|
||||
const rootTypes = {
|
||||
query: schema.value?.getQueryType(),
|
||||
mutation: schema.value?.getMutationType(),
|
||||
subscription: schema.value?.getSubscriptionType(),
|
||||
}
|
||||
|
||||
// Check if the type is directly accessible from root types
|
||||
const rootEntry = Object.entries(rootTypes).find(
|
||||
([, rootType]) => rootType?.name === type.name
|
||||
)
|
||||
|
||||
if (rootEntry) {
|
||||
path.push({
|
||||
name: rootEntry[0].charAt(0).toUpperCase() + rootEntry[0].slice(1),
|
||||
def: type,
|
||||
})
|
||||
} else {
|
||||
// If not a root type, try to find the path through fields
|
||||
const parentType = Object.values(rootTypes).find((rootType) => {
|
||||
if (!rootType || !isObjectType(rootType)) return false
|
||||
const fields = rootType.getFields()
|
||||
return Object.values(fields).some((field) =>
|
||||
field.type.toString().includes(type.name)
|
||||
)
|
||||
})
|
||||
|
||||
if (parentType) {
|
||||
path.push({
|
||||
name: parentType.name,
|
||||
def: parentType,
|
||||
})
|
||||
|
||||
// Find the field that leads to our type
|
||||
const field = Object.values(parentType.getFields()).find((field) =>
|
||||
field.type.toString().includes(type.name)
|
||||
)
|
||||
|
||||
if (field) {
|
||||
path.push({
|
||||
name: field.name,
|
||||
def: field,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
path.push({
|
||||
name: type.name,
|
||||
def: type,
|
||||
})
|
||||
}
|
||||
} else if (result.type.startsWith("Field in")) {
|
||||
// For fields, ensure we have the complete path from root
|
||||
const parentTypeName = result.type.replace("Field in ", "")
|
||||
const parentType = schema.value?.getType(parentTypeName)
|
||||
|
||||
if (parentType) {
|
||||
// First find path to parent type
|
||||
const parentPath = buildNavigationPath({
|
||||
name: parentType.name,
|
||||
type: "Type",
|
||||
def: parentType,
|
||||
})
|
||||
|
||||
path.push(...parentPath)
|
||||
path.push({
|
||||
name: result.name,
|
||||
def: result.def,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const selectSearchResult = (result: SearchResult) => {
|
||||
const navigationPath = buildNavigationPath(result)
|
||||
|
||||
// Reset to root
|
||||
while (navStack.value.length > 1) {
|
||||
pop()
|
||||
}
|
||||
|
||||
// Push each item in the path sequentially
|
||||
navigationPath.forEach((item, i) => {
|
||||
if (
|
||||
i === 0 ||
|
||||
(!isObjectType(item.def) &&
|
||||
!isInterfaceType(item.def) &&
|
||||
!isInputObjectType(item.def))
|
||||
)
|
||||
push(item)
|
||||
})
|
||||
|
||||
showSearchResults.value = false
|
||||
searchText.value = ""
|
||||
currentResultIndex.value = -1
|
||||
}
|
||||
|
||||
const scrollActiveResultIntoView = async () => {
|
||||
await nextTick()
|
||||
if (resultsMenu.value && currentResultIndex.value > -1) {
|
||||
const activeElement = resultsMenu.value.children[
|
||||
currentResultIndex.value
|
||||
] as HTMLElement
|
||||
activeElement?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchKeystroke = (ev: KeyboardEvent) => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
|
||||
if (["Escape", "Tab"].includes(ev.key)) {
|
||||
showSearchResults.value = false
|
||||
}
|
||||
|
||||
if (ev.key === "Enter" && currentResultIndex.value > -1) {
|
||||
selectSearchResult(filteredResults.value[currentResultIndex.value])
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown") {
|
||||
currentResultIndex.value =
|
||||
currentResultIndex.value < filteredResults.value.length - 1
|
||||
? currentResultIndex.value + 1
|
||||
: filteredResults.value.length - 1
|
||||
scrollActiveResultIntoView()
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
currentResultIndex.value =
|
||||
currentResultIndex.value > 0 ? currentResultIndex.value - 1 : 0
|
||||
scrollActiveResultIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
// Click outside handling
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
searchWrapper.value &&
|
||||
!searchWrapper.value.contains(event.target as Node)
|
||||
) {
|
||||
showSearchResults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add watch for searchText
|
||||
watch(searchText, () => {
|
||||
currentResultIndex.value = -1
|
||||
if (searchText.value.trim()) {
|
||||
showSearchResults.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.autocomplete-wrapper {
|
||||
@apply relative;
|
||||
@apply whitespace-nowrap;
|
||||
|
||||
.suggestions {
|
||||
@apply absolute;
|
||||
@apply bg-popover;
|
||||
@apply z-50;
|
||||
@apply shadow-lg;
|
||||
@apply max-h-46;
|
||||
@apply border-x border-b border-divider;
|
||||
@apply overflow-y-auto;
|
||||
@apply -left-[1px];
|
||||
@apply -right-[1px];
|
||||
|
||||
top: calc(100% + 1px);
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
li {
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-between;
|
||||
@apply w-full;
|
||||
@apply px-4 py-2;
|
||||
@apply text-secondary;
|
||||
@apply cursor-pointer;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 0 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-primaryDark;
|
||||
@apply text-secondaryDark;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,103 +10,7 @@
|
|||
:icon="IconBookOpen"
|
||||
:label="`${t('tab.documentation')}`"
|
||||
>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="
|
||||
queryFields.length === 0 &&
|
||||
mutationFields.length === 0 &&
|
||||
subscriptionFields.length === 0 &&
|
||||
graphqlTypes.length === 0
|
||||
"
|
||||
:src="`/images/states/${colorMode.value}/add_comment.svg`"
|
||||
:alt="`${t('empty.documentation')}`"
|
||||
:text="t('empty.documentation')"
|
||||
/>
|
||||
<div v-else>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
|
||||
>
|
||||
<input
|
||||
v-model="graphqlFieldsFilterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex w-full bg-transparent px-4 py-2 h-8"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
/>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/protocols/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartTabs
|
||||
v-model="selectedGqlTab"
|
||||
styles="border-t border-b border-dividerLight bg-primary sticky overflow-x-auto flex-shrink-0 z-10 top-sidebarPrimaryStickyFold"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<HoppSmartTab
|
||||
v-if="queryFields.length > 0"
|
||||
:id="'queries'"
|
||||
:label="`${t('tab.queries')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredQueryFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
class="p-4"
|
||||
@jump-to-type="handleJumpToType"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="mutationFields.length > 0"
|
||||
:id="'mutations'"
|
||||
:label="`${t('graphql.mutations')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredMutationFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
class="p-4"
|
||||
@jump-to-type="handleJumpToType"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="subscriptionFields.length > 0"
|
||||
:id="'subscriptions'"
|
||||
:label="`${t('graphql.subscriptions')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredSubscriptionFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
class="p-4"
|
||||
@jump-to-type="handleJumpToType"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="graphqlTypes.length > 0"
|
||||
:id="'types'"
|
||||
:label="`${t('tab.types')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlType
|
||||
v-for="(type, index) in filteredGraphqlTypes"
|
||||
:key="`type-${index}`"
|
||||
:gql-type="type"
|
||||
:gql-types="graphqlTypes"
|
||||
:is-highlighted="isGqlTypeHighlighted(type)"
|
||||
:highlighted-fields="getGqlTypeHighlightedFields(type)"
|
||||
@jump-to-type="handleJumpToType"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
<GraphqlDocExplorer />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab :id="'schema'" :icon="IconBox" :label="`${t('tab.schema')}`">
|
||||
<div
|
||||
|
|
@ -175,91 +79,34 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { copyToClipboard } from "@helpers/utils/clipboard"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { reactive, ref } from "vue"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { schemaString } from "~/helpers/graphql/connection"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import { platform } from "~/platform"
|
||||
import IconBookOpen from "~icons/lucide/book-open"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconBox from "~icons/lucide/box"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClock from "~icons/lucide/clock"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconBox from "~icons/lucide/box"
|
||||
import { computed, nextTick, reactive, ref } from "vue"
|
||||
import { GraphQLField, GraphQLType, getNamedType } from "graphql"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { copyToClipboard } from "@helpers/utils/clipboard"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
graphqlTypes,
|
||||
mutationFields,
|
||||
queryFields,
|
||||
schemaString,
|
||||
subscriptionFields,
|
||||
} from "~/helpers/graphql/connection"
|
||||
import { platform } from "~/platform"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
|
||||
type NavigationTabs = "history" | "collection" | "docs" | "schema"
|
||||
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
|
||||
|
||||
const selectedNavigationTab = ref<NavigationTabs>("docs")
|
||||
const selectedGqlTab = ref<GqlTabs>("queries")
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
function isTextFoundInGraphqlFieldObject(
|
||||
text: string,
|
||||
field: GraphQLField<any, any>
|
||||
) {
|
||||
const normalizedText = text.toLowerCase()
|
||||
|
||||
const isFilterTextFoundInDescription = field.description
|
||||
? field.description.toLowerCase().includes(normalizedText)
|
||||
: false
|
||||
const isFilterTextFoundInName = field.name
|
||||
.toLowerCase()
|
||||
.includes(normalizedText)
|
||||
|
||||
return isFilterTextFoundInDescription || isFilterTextFoundInName
|
||||
}
|
||||
|
||||
function getFilteredGraphqlFields(
|
||||
filterText: string,
|
||||
fields: GraphQLField<any, any>[]
|
||||
) {
|
||||
if (!filterText) return fields
|
||||
|
||||
return fields.filter((field) =>
|
||||
isTextFoundInGraphqlFieldObject(filterText, field)
|
||||
)
|
||||
}
|
||||
|
||||
function getFilteredGraphqlTypes(filterText: string, types: GraphQLType[]) {
|
||||
if (!filterText) return types
|
||||
|
||||
return types.filter((type) => {
|
||||
const isFilterTextMatching = isTextFoundInGraphqlFieldObject(
|
||||
filterText,
|
||||
type as any
|
||||
)
|
||||
|
||||
if (isFilterTextMatching) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isFilterTextMatchingAtLeastOneField = Object.values(
|
||||
(type as any)._fields || {}
|
||||
).some((field) => isTextFoundInGraphqlFieldObject(filterText, field as any))
|
||||
|
||||
return isFilterTextMatchingAtLeastOneField
|
||||
})
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const downloadSchemaIcon = refAutoReset<typeof IconDownload | typeof IconCheck>(
|
||||
|
|
@ -271,83 +118,6 @@ const copySchemaIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
|||
1000
|
||||
)
|
||||
|
||||
const graphqlFieldsFilterText = ref("")
|
||||
|
||||
const filteredQueryFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
queryFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredMutationFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
mutationFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredSubscriptionFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
subscriptionFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredGraphqlTypes = computed(() => {
|
||||
return getFilteredGraphqlTypes(
|
||||
graphqlFieldsFilterText.value,
|
||||
graphqlTypes.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const isGqlTypeHighlighted = (gqlType: GraphQLType) => {
|
||||
if (!graphqlFieldsFilterText.value) return false
|
||||
|
||||
return isTextFoundInGraphqlFieldObject(
|
||||
graphqlFieldsFilterText.value,
|
||||
gqlType as any
|
||||
)
|
||||
}
|
||||
|
||||
const getGqlTypeHighlightedFields = (gqlType: GraphQLType) => {
|
||||
if (!graphqlFieldsFilterText.value) return []
|
||||
|
||||
const fields = Object.values((gqlType as any)._fields || {})
|
||||
if (!fields || fields.length === 0) return []
|
||||
|
||||
return fields.filter((field) =>
|
||||
isTextFoundInGraphqlFieldObject(graphqlFieldsFilterText.value, field as any)
|
||||
)
|
||||
}
|
||||
|
||||
const handleJumpToType = async (type: GraphQLType) => {
|
||||
selectedGqlTab.value = "types"
|
||||
await nextTick()
|
||||
|
||||
const rootTypeName = getNamedType(type).name
|
||||
const target = document.getElementById(`type_${rootTypeName}`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
target.classList.add(
|
||||
"transition-all",
|
||||
"ring-inset",
|
||||
"ring-accentLight",
|
||||
"ring-4"
|
||||
)
|
||||
setTimeout(
|
||||
() =>
|
||||
target.classList.remove(
|
||||
"ring-inset",
|
||||
"ring-accentLight",
|
||||
"ring-4",
|
||||
"transition-all"
|
||||
),
|
||||
2000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const schemaEditor = ref<any | null>(null)
|
||||
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlSchema")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div v-if="isNamedType(type)" class="px-3">
|
||||
<AppMarkdown v-if="type.description" type="description">
|
||||
{{ type.description }}
|
||||
</AppMarkdown>
|
||||
<GraphqlImplementsInterfaces :type="type" />
|
||||
<GraphqlFields
|
||||
:type="type"
|
||||
:insert-query="false"
|
||||
:show-add-field="isShowAddField"
|
||||
/>
|
||||
<GraphqlEnumValues :type="type" />
|
||||
<GraphqlPossibleTypes :type="type" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue"
|
||||
import { GraphQLNamedType, isNamedType } from "graphql"
|
||||
|
||||
const props = defineProps<{
|
||||
type: GraphQLNamedType
|
||||
}>()
|
||||
|
||||
const isShowAddField = ["Query", "Mutation", "Subscription"].includes(
|
||||
props.type.name
|
||||
)
|
||||
</script>
|
||||
|
|
@ -1,31 +1,42 @@
|
|||
<template>
|
||||
<span
|
||||
:class="isScalar ? 'text-secondaryLight' : 'cursor-pointer text-accent'"
|
||||
@click="jumpToType"
|
||||
>
|
||||
{{ typeString }}
|
||||
</span>
|
||||
<template v-if="type">
|
||||
<component :is="renderedComponent" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GraphQLScalarType, GraphQLType, getNamedType } from "graphql"
|
||||
import { computed } from "vue"
|
||||
import { GraphQLNamedType, GraphQLType } from "graphql"
|
||||
import { computed, h } from "vue"
|
||||
import { renderType, useExplorer } from "~/helpers/graphql/explorer"
|
||||
|
||||
const props = defineProps<{
|
||||
gqlType: GraphQLType
|
||||
type: GraphQLType
|
||||
clickable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "jump-to-type", type: GraphQLType): void
|
||||
}>()
|
||||
const { push } = useExplorer()
|
||||
|
||||
const typeString = computed(() => `${props.gqlType}`)
|
||||
const isScalar = computed(() => {
|
||||
return getNamedType(props.gqlType) instanceof GraphQLScalarType
|
||||
})
|
||||
|
||||
function jumpToType() {
|
||||
if (isScalar.value) return
|
||||
emit("jump-to-type", props.gqlType)
|
||||
const handleTypeClick = (event: MouseEvent, namedType: GraphQLNamedType) => {
|
||||
event.preventDefault()
|
||||
push({ name: namedType.name, def: namedType })
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the rendered HTML for the GraphQL type.
|
||||
*/
|
||||
const renderedComponent = computed(() => {
|
||||
if (!props.type) return ""
|
||||
|
||||
return renderType(props.type, (namedType: GraphQLNamedType) => {
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
class: "hopp-doc-explorer-type-name",
|
||||
onClick: (event: MouseEvent) =>
|
||||
props.clickable ? handleTypeClick(event, namedType) : undefined,
|
||||
},
|
||||
namedType.name
|
||||
)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="h-full relative">
|
||||
<div ref="variableEditor" class="flex flex-1 flex-col"></div>
|
||||
<div ref="variableEditor" class="flex flex-1 flex-col h-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ export const disconnect = () => {
|
|||
|
||||
clearTimeout(timeoutSubscription)
|
||||
connection.state = "DISCONNECTED"
|
||||
connection.schema = null
|
||||
}
|
||||
|
||||
export const reset = () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
224
packages/hoppscotch-common/src/helpers/graphql/explorer.ts
Normal file
224
packages/hoppscotch-common/src/helpers/graphql/explorer.ts
Normal file
|
|
@ -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<unknown, unknown, unknown>
|
||||
| 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<ExplorerNavStack>([initialNavStackItem])
|
||||
const schema = ref<GraphQLSchema | null>()
|
||||
const validationErrors = ref<any[]>([])
|
||||
|
||||
/**
|
||||
* 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<any, any, any> | 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<any, any> = 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)
|
||||
}
|
||||
418
packages/hoppscotch-common/src/helpers/graphql/query.ts
Normal file
418
packages/hoppscotch-common/src/helpers/graphql/query.ts
Normal file
|
|
@ -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<T> = {
|
||||
-readonly [K in keyof T]: T[K]
|
||||
}
|
||||
|
||||
const updatedQuery = ref("")
|
||||
const cursorPosition = ref({ line: 0, ch: 1 })
|
||||
const operations = ref<OperationDefinitionNode[]>([])
|
||||
|
||||
/**
|
||||
* 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, string> = {
|
||||
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<string, OperationTypeNode> = {
|
||||
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<FieldNode> => ({
|
||||
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<FieldNode>
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -360,6 +360,7 @@ export class CollectionsSpotlightSearcherService
|
|||
folderPath: folderPath.join("/"),
|
||||
requestIndex: reqIndex,
|
||||
},
|
||||
cursorPosition: 0,
|
||||
request: req,
|
||||
isDirty: false,
|
||||
inheritedProperties: {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export class GQLTabService extends TabService<HoppGQLDocument> {
|
|||
request: getDefaultGQLRequest(),
|
||||
isDirty: false,
|
||||
optionTabPreference: "query",
|
||||
cursorPosition: 0,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue