feat: gql revamp (#2644)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com> Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
@@ -32,7 +32,7 @@
|
||||
:active="authName === 'None'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'none'
|
||||
auth.authType = 'none'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -43,7 +43,7 @@
|
||||
:active="authName === 'Basic Auth'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'basic'
|
||||
auth.authType = 'basic'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -54,7 +54,7 @@
|
||||
:active="authName === 'Bearer'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'bearer'
|
||||
auth.authType = 'bearer'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -65,7 +65,7 @@
|
||||
:active="authName === 'OAuth 2.0'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'oauth-2'
|
||||
auth.authType = 'oauth-2'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -76,7 +76,7 @@
|
||||
:active="authName === 'API key'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'api-key'
|
||||
auth.authType = 'api-key'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -90,8 +90,8 @@
|
||||
:on="!URLExcludes.auth"
|
||||
@change="setExclude('auth', !$event)"
|
||||
>
|
||||
{{ t("authorization.include_in_url") }}
|
||||
</HoppSmartCheckbox> -->
|
||||
{{ $t("authorization.include_in_url") }}
|
||||
</HoppSmartCheckbox>-->
|
||||
<HoppSmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@@ -115,7 +115,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="authType === 'none'"
|
||||
v-if="auth.authType === 'none'"
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
:text="t('empty.authorization')"
|
||||
@@ -132,109 +132,43 @@
|
||||
</HoppSmartPlaceholder>
|
||||
<div v-else class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div v-if="authType === 'basic'">
|
||||
<div v-if="auth.authType === 'basic'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicUsername"
|
||||
v-model="auth.username"
|
||||
:environment-highlights="false"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicPassword"
|
||||
v-model="auth.password"
|
||||
:environment-highlights="false"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'bearer'">
|
||||
<div v-if="auth.authType === 'bearer'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="bearerToken"
|
||||
v-model="auth.token"
|
||||
:environment-highlights="false"
|
||||
placeholder="Token"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'oauth-2'">
|
||||
<div v-if="auth.authType === 'oauth-2'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="oauth2Token"
|
||||
v-model="auth.token"
|
||||
:environment-highlights="false"
|
||||
placeholder="Token"
|
||||
/>
|
||||
</div>
|
||||
<HttpOAuth2Authorization />
|
||||
<HttpOAuth2Authorization v-model="auth" />
|
||||
</div>
|
||||
<div v-if="authType === 'api-key'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="apiKey"
|
||||
:environment-highlights="false"
|
||||
placeholder="Key"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="apiValue"
|
||||
:environment-highlights="false"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center border-b border-dividerLight">
|
||||
<span class="flex items-center">
|
||||
<label class="ml-4 text-secondaryLight">
|
||||
{{ t("authorization.pass_key_by") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => authTippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<HoppButtonSecondary
|
||||
:label="addTo || t('state.none')"
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="authTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
:icon="addTo === 'Headers' ? IconCircleDot : IconCircle"
|
||||
:active="addTo === 'Headers'"
|
||||
:label="'Headers'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Headers'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
:icon="
|
||||
addTo === 'Query params' ? IconCircleDot : IconCircle
|
||||
"
|
||||
:active="addTo === 'Query params'"
|
||||
:label="'Query params'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Query params'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="auth.authType === 'api-key'">
|
||||
<HttpAuthorizationApiKey v-model="auth" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -257,55 +191,45 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref } from "vue"
|
||||
import {
|
||||
HoppGQLAuthAPIKey,
|
||||
HoppGQLAuthBasic,
|
||||
HoppGQLAuthBearer,
|
||||
HoppGQLAuthOAuth2,
|
||||
} from "@hoppscotch/data"
|
||||
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { gqlAuth$, setGQLAuth } from "~/newstore/GQLSession"
|
||||
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import { computed, ref } from "vue"
|
||||
import { HoppGQLAuth } from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const auth = useStream(
|
||||
gqlAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setGQLAuth
|
||||
)
|
||||
const props = defineProps<{
|
||||
modelValue: HoppGQLAuth
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: HoppGQLAuth): void
|
||||
}>()
|
||||
|
||||
const auth = useVModel(props, "modelValue", emit)
|
||||
|
||||
const AUTH_KEY_NAME = {
|
||||
basic: "Basic Auth",
|
||||
bearer: "Bearer",
|
||||
"oauth-2": "OAuth 2.0",
|
||||
"api-key": "API key",
|
||||
none: "None",
|
||||
} as const
|
||||
|
||||
const authType = pluckRef(auth, "authType")
|
||||
const authName = computed(() => {
|
||||
if (authType.value === "basic") return "Basic Auth"
|
||||
else if (authType.value === "bearer") return "Bearer"
|
||||
else if (authType.value === "oauth-2") return "OAuth 2.0"
|
||||
else if (authType.value === "api-key") return "API key"
|
||||
else return "None"
|
||||
})
|
||||
const authName = computed(() =>
|
||||
AUTH_KEY_NAME[authType.value] ? AUTH_KEY_NAME[authType.value] : "None"
|
||||
)
|
||||
const authActive = pluckRef(auth, "authActive")
|
||||
const basicUsername = pluckRef(auth as Ref<HoppGQLAuthBasic>, "username")
|
||||
const basicPassword = pluckRef(auth as Ref<HoppGQLAuthBasic>, "password")
|
||||
const bearerToken = pluckRef(auth as Ref<HoppGQLAuthBearer>, "token")
|
||||
const oauth2Token = pluckRef(auth as Ref<HoppGQLAuthOAuth2>, "token")
|
||||
const apiKey = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "key")
|
||||
const apiValue = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "value")
|
||||
const addTo = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "addTo")
|
||||
if (typeof addTo.value === "undefined") {
|
||||
addTo.value = "Headers"
|
||||
apiKey.value = ""
|
||||
apiValue.value = ""
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
auth.value = {
|
||||
@@ -316,5 +240,4 @@ const clearContent = () => {
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const authTippyActions = ref<any | null>(null)
|
||||
</script>
|
||||
|
||||
431
packages/hoppscotch-common/src/components/graphql/Headers.vue
Normal file
431
packages/hoppscotch-common/src/components/graphql/Headers.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("tab.headers") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="bulkMode"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-col flex-1"></div>
|
||||
<div v-else>
|
||||
<draggable
|
||||
v-model="workingHeaders"
|
||||
:item-key="(header: any) => `header-${header.id}`"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: header, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== workingHeaders?.length - 1
|
||||
? t('action.drag_to_reorder')
|
||||
: null,
|
||||
}"
|
||||
:icon="IconGripVertical"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
:class="{
|
||||
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
|
||||
index !== workingHeaders?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<HoppSmartAutoComplete
|
||||
:placeholder="`${t('count.header', { count: index + 1 })}`"
|
||||
:source="commonHeaders"
|
||||
:spellcheck="false"
|
||||
:value="header.key"
|
||||
autofocus
|
||||
styles="
|
||||
bg-transparent
|
||||
flex
|
||||
flex-1
|
||||
py-1
|
||||
px-4
|
||||
truncate
|
||||
"
|
||||
class="flex-1 !flex"
|
||||
@input="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: $event,
|
||||
value: header.value,
|
||||
active: header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<input
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:name="`value ${String(index)}`"
|
||||
:value="header.value"
|
||||
autofocus
|
||||
@change="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: header.key,
|
||||
value: ($event!.target! as HTMLInputElement).value,
|
||||
active: header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
header.hasOwnProperty('active')
|
||||
? header.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
header.hasOwnProperty('active')
|
||||
? header.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: header.key,
|
||||
value: header.value,
|
||||
active: !header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteHeader(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="workingHeaders.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
:text="t('empty.headers')"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconGripVertical from "~icons/lucide/grip-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import {
|
||||
GQLHeader,
|
||||
rawKeyValueEntriesToString,
|
||||
parseRawKeyValueEntriesE,
|
||||
RawKeyValueEntry,
|
||||
HoppGQLRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import draggable from "vuedraggable-es"
|
||||
import { clone, cloneDeep, isEqual } from "lodash-es"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { commonHeaders } from "~/helpers/headers"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// v-model integration with props and emit
|
||||
const props = defineProps<{ modelValue: HoppGQLRequest }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: HoppGQLRequest): void
|
||||
}>()
|
||||
|
||||
const request = useVModel(props, "modelValue", emit)
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const linewrapEnabled = ref(false)
|
||||
const bulkMode = ref(false)
|
||||
const bulkHeaders = ref("")
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
const bulkEditor = ref<any | null>(null)
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkHeaders,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
// The UI representation of the headers list (has the empty end header)
|
||||
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working Headers always have one empty header or the last element is always an empty header
|
||||
watch(workingHeaders, (headersList) => {
|
||||
if (
|
||||
headersList.length > 0 &&
|
||||
headersList[headersList.length - 1].key !== ""
|
||||
) {
|
||||
workingHeaders.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sync logic between headers and working headers
|
||||
watch(
|
||||
props.modelValue.headers,
|
||||
(newHeadersList) => {
|
||||
// Sync should overwrite working headers
|
||||
const filteredWorkingHeaders = pipe(
|
||||
workingHeaders.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredBulkHeaders = pipe(
|
||||
parseRawKeyValueEntriesE(bulkHeaders.value),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(newHeadersList, filteredWorkingHeaders)) {
|
||||
workingHeaders.value = pipe(
|
||||
newHeadersList,
|
||||
A.map((x) => ({ id: idTicker.value++, ...x }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEqual(newHeadersList, filteredBulkHeaders)) {
|
||||
bulkHeaders.value = rawKeyValueEntriesToString(newHeadersList)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workingHeaders, (newWorkingHeaders) => {
|
||||
const fixedHeaders = pipe(
|
||||
newWorkingHeaders,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(request.value.headers, fixedHeaders)) {
|
||||
request.value.headers = cloneDeep(fixedHeaders)
|
||||
}
|
||||
})
|
||||
|
||||
// Bulk Editor Syncing with Working Headers
|
||||
watch(bulkHeaders, (newBulkHeaders) => {
|
||||
const filteredBulkHeaders = pipe(
|
||||
parseRawKeyValueEntriesE(newBulkHeaders),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(request.value.headers, filteredBulkHeaders)) {
|
||||
request.value.headers = filteredBulkHeaders
|
||||
}
|
||||
})
|
||||
|
||||
watch(workingHeaders, (newHeadersList) => {
|
||||
// If we are in bulk mode, don't apply direct changes
|
||||
if (bulkMode.value) return
|
||||
|
||||
try {
|
||||
const currentBulkHeaders = bulkHeaders.value.split("\n").map((item) => ({
|
||||
key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
|
||||
value: item.substring(item.indexOf(":") + 1).trimLeft(),
|
||||
active: !item.trim().startsWith("#"),
|
||||
}))
|
||||
|
||||
const filteredHeaders = newHeadersList.filter((x) => x.key !== "")
|
||||
|
||||
if (!isEqual(currentBulkHeaders, filteredHeaders)) {
|
||||
bulkHeaders.value = rawKeyValueEntriesToString(filteredHeaders)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
const addHeader = () => {
|
||||
workingHeaders.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateHeader = (index: number, header: GQLHeader & { id: number }) => {
|
||||
workingHeaders.value = workingHeaders.value.map((h, i) =>
|
||||
i === index ? header : h
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHeader = (index: number) => {
|
||||
const headersBeforeDeletion = clone(workingHeaders.value)
|
||||
|
||||
if (
|
||||
!(
|
||||
headersBeforeDeletion.length > 0 &&
|
||||
index === headersBeforeDeletion.length - 1
|
||||
)
|
||||
) {
|
||||
if (deletionToast.value) {
|
||||
deletionToast.value.goAway(0)
|
||||
deletionToast.value = null
|
||||
}
|
||||
|
||||
deletionToast.value = toast.success(`${t("state.deleted")}`, {
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.undo")}`,
|
||||
onClick: (_: any, toastObject: any) => {
|
||||
workingHeaders.value = headersBeforeDeletion
|
||||
toastObject.goAway(0)
|
||||
deletionToast.value = null
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
onComplete: () => {
|
||||
deletionToast.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workingHeaders.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set headers list to the initial state
|
||||
workingHeaders.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
|
||||
bulkHeaders.value = ""
|
||||
}
|
||||
</script>
|
||||
237
packages/hoppscotch-common/src/components/graphql/Query.vue
Normal file
237
packages/hoppscotch-common/src/components/graphql/Query.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight gqlRunQuery"
|
||||
>
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("request.query") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-if="subscriptionState === 'SUBSCRIBED'"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
allowHTML: true,
|
||||
}"
|
||||
:title="`${t('request.stop')}`"
|
||||
:label="`${t('request.stop')}`"
|
||||
:icon="IconStop"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="unsubscribe()"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-if="selectedOperation && subscriptionState !== 'SUBSCRIBED'"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
allowHTML: true,
|
||||
}"
|
||||
:title="`${t('request.run')} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
|
||||
:label="`${selectedOperation.name?.value ?? t('request.run')}`"
|
||||
:icon="IconPlay"
|
||||
:disabled="!selectedOperation"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="runQuery(selectedOperation)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.save'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
|
||||
:label="`${t('request.save')}`"
|
||||
:icon="IconSave"
|
||||
class="rounded-none"
|
||||
@click="saveRequest"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLQuery()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyQueryIcon"
|
||||
@click="prettifyQuery"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyQueryIcon"
|
||||
@click="copyQuery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="queryEditor" class="flex flex-col flex-1"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconStop from "~icons/lucide/stop-circle"
|
||||
import IconSave from "~icons/lucide/save"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
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 { 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 { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
|
||||
import queryCompleter from "~/helpers/editor/completion/gqlQuery"
|
||||
import { selectedGQLOpHighlight } from "~/helpers/editor/gql/operation"
|
||||
import { debounce } from "lodash-es"
|
||||
import { ViewUpdate } from "@codemirror/view"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import {
|
||||
schema,
|
||||
socketDisconnect,
|
||||
subscriptionState,
|
||||
} from "~/helpers/graphql/connection"
|
||||
|
||||
// Template refs
|
||||
const queryEditor = ref<any | null>(null)
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save-request"): void
|
||||
(e: "update:modelValue", val: string): void
|
||||
(e: "run-query", definition: gql.OperationDefinitionNode | null): void
|
||||
}>()
|
||||
|
||||
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
const prettifyQueryIcon = refAutoReset<
|
||||
typeof IconWand | typeof IconCheck | typeof IconInfo
|
||||
>(IconWand, 1000)
|
||||
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
|
||||
|
||||
const gqlQueryString = useVModel(props, "modelValue", emit)
|
||||
|
||||
const debouncedOnUpdateQueryState = debounce((update: ViewUpdate) => {
|
||||
if (!update.selectionSet) return
|
||||
|
||||
const selectedPos = update.state.selection.main.head
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
selectedOperation.value =
|
||||
(operations.definitions.find((def) => {
|
||||
if (def.kind !== "OperationDefinition") return false
|
||||
const { start, end } = def.loc!
|
||||
return selectedPos >= start && selectedPos <= end
|
||||
}) as gql.OperationDefinitionNode) ?? null
|
||||
} catch (error) {
|
||||
// console.error(error)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const operations = gql.parse(gqlQueryString.value)
|
||||
if (operations.definitions.length) {
|
||||
selectedOperation.value = operations
|
||||
.definitions[0] as gql.OperationDefinitionNode
|
||||
return
|
||||
}
|
||||
} catch (error) {}
|
||||
})
|
||||
|
||||
useCodemirror(
|
||||
queryEditor,
|
||||
gqlQueryString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "graphql",
|
||||
placeholder: `${t("request.query")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: createGQLQueryLinter(schema),
|
||||
completer: queryCompleter(schema),
|
||||
environmentHighlights: false,
|
||||
additionalExts: [markRaw(selectedGQLOpHighlight)],
|
||||
onUpdate: debouncedOnUpdateQueryState,
|
||||
})
|
||||
)
|
||||
|
||||
// operations on graphql query string
|
||||
// const operations = useReadonlyStream(props.request.operations$, [])
|
||||
|
||||
const prettifyQuery = () => {
|
||||
try {
|
||||
gqlQueryString.value = gql.print(
|
||||
gql.parse(gqlQueryString.value, {
|
||||
allowLegacyFragmentVariables: true,
|
||||
})
|
||||
)
|
||||
prettifyQueryIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
toast.error(`${t("error.gql_prettify_invalid_query")}`)
|
||||
prettifyQueryIcon.value = IconInfo
|
||||
}
|
||||
}
|
||||
|
||||
const copyQuery = () => {
|
||||
copyToClipboard(gqlQueryString.value)
|
||||
copyQueryIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const clearGQLQuery = () => {
|
||||
gqlQueryString.value = ""
|
||||
}
|
||||
|
||||
const runQuery = (definition: gql.OperationDefinitionNode | null = null) => {
|
||||
emit("run-query", definition)
|
||||
}
|
||||
const unsubscribe = () => {
|
||||
socketDisconnect()
|
||||
}
|
||||
const saveRequest = () => {
|
||||
emit("save-request")
|
||||
}
|
||||
|
||||
defineActionHandler("editor.format", prettifyQuery)
|
||||
</script>
|
||||
@@ -17,58 +17,127 @@
|
||||
<HoppButtonPrimary
|
||||
id="get"
|
||||
name="get"
|
||||
:loading="isLoading"
|
||||
:loading="connection.state === 'CONNECTING'"
|
||||
:label="!connected ? t('action.connect') : t('action.disconnect')"
|
||||
class="w-32"
|
||||
@click="onConnectClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartModal
|
||||
v-if="connectionSwitchModal"
|
||||
dialog
|
||||
:dimissible="false"
|
||||
:title="t('graphql.switch_connection')"
|
||||
@close="connectionSwitchModal = false"
|
||||
>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
{{ t("graphql.connection_switch_url") }}:
|
||||
<kbd class="shortcut-key !ml-0"> {{ lastTwoUrls.at(0) }} </kbd>
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
{{ t("graphql.connection_switch_new_url") }}:
|
||||
<kbd class="shortcut-key !ml-0"> {{ lastTwoUrls.at(1) }} </kbd>
|
||||
</p>
|
||||
<p>{{ t("graphql.connection_switch_confirm") }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
:label="t('action.connect')"
|
||||
:loading="connection.state === 'CONNECTING'"
|
||||
outline
|
||||
@click="switchConnection()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="cancelSwitch()"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { platform } from "~/platform"
|
||||
import { GQLConnection } from "~/helpers/GQLConnection"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
gqlAuth$,
|
||||
gqlHeaders$,
|
||||
gqlURL$,
|
||||
setGQLURL,
|
||||
} from "~/newstore/GQLSession"
|
||||
import { useService } from "dioc/vue"
|
||||
import { currentActiveTab } from "~/helpers/graphql/tab"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { connection } from "~/helpers/graphql/connection"
|
||||
import { connect } from "~/helpers/graphql/connection"
|
||||
import { disconnect } from "~/helpers/graphql/connection"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { useService } from "dioc/vue"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const interceptorService = useService(InterceptorService)
|
||||
|
||||
const props = defineProps<{
|
||||
conn: GQLConnection
|
||||
}>()
|
||||
const connectionSwitchModal = ref(false)
|
||||
|
||||
const connected = useReadonlyStream(props.conn.connected$, false)
|
||||
const isLoading = useReadonlyStream(props.conn.isLoading$, false)
|
||||
const headers = useReadonlyStream(gqlHeaders$, [])
|
||||
const auth = useReadonlyStream(gqlAuth$, {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
const connected = computed(() => connection.state === "CONNECTED")
|
||||
|
||||
const url = computed({
|
||||
get: () => currentActiveTab.value?.document.request.url ?? "",
|
||||
set: (value) => {
|
||||
currentActiveTab.value!.document.request.url = value
|
||||
},
|
||||
})
|
||||
|
||||
const url = useStream(gqlURL$, "", setGQLURL)
|
||||
|
||||
const onConnectClick = () => {
|
||||
if (!connected.value) {
|
||||
props.conn.connect(url.value, headers.value as any, auth.value)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_REQUEST_RUN",
|
||||
platform: "graphql-schema",
|
||||
strategy: interceptorService.currentInterceptorID.value!,
|
||||
})
|
||||
gqlConnect()
|
||||
} else {
|
||||
props.conn.disconnect()
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const gqlConnect = () => {
|
||||
connect(url.value, currentActiveTab.value?.document.request.headers)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_REQUEST_RUN",
|
||||
platform: "graphql-schema",
|
||||
strategy: interceptorService.currentInterceptorID.value!,
|
||||
})
|
||||
}
|
||||
|
||||
const switchConnection = () => {
|
||||
gqlConnect()
|
||||
connectionSwitchModal.value = false
|
||||
}
|
||||
|
||||
const lastTwoUrls = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
currentActiveTab,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
lastTwoUrls.value.push(newVal.document.request.url)
|
||||
if (lastTwoUrls.value.length > 2) {
|
||||
lastTwoUrls.value.shift()
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
connected.value &&
|
||||
lastTwoUrls.value.length === 2 &&
|
||||
lastTwoUrls.value.at(0) !== lastTwoUrls.value.at(1)
|
||||
) {
|
||||
disconnect()
|
||||
connectionSwitchModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
const cancelSwitch = () => {
|
||||
if (connected.value) disconnect()
|
||||
connectionSwitchModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,311 +2,42 @@
|
||||
<div class="flex flex-col flex-1 h-full">
|
||||
<HoppSmartTabs
|
||||
v-model="selectedOptionTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
styles="sticky bg-primary z-10"
|
||||
:render-inactive-tabs="true"
|
||||
>
|
||||
<HoppSmartTab
|
||||
:id="'query'"
|
||||
:label="`${t('tab.query')}`"
|
||||
:indicator="gqlQueryString && gqlQueryString.length > 0 ? true : false"
|
||||
:indicator="request.query && request.query.length > 0 ? true : false"
|
||||
>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold gqlRunQuery"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.query") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.run'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
|
||||
:label="`${t('request.run')}`"
|
||||
:icon="IconPlay"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="runQuery()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.save'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
|
||||
:label="`${t('request.save')}`"
|
||||
:icon="IconSave"
|
||||
class="rounded-none"
|
||||
@click="saveRequest"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLQuery()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabledQuery }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabledQuery = !linewrapEnabledQuery"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyQueryIcon"
|
||||
@click="prettifyQuery"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyQueryIcon"
|
||||
@click="copyQuery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="queryEditor" class="flex flex-col flex-1"></div>
|
||||
<GraphqlQuery
|
||||
v-model="request.query"
|
||||
@run-query="runQuery"
|
||||
@save-request="saveRequest"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'variables'"
|
||||
:label="`${t('tab.variables')}`"
|
||||
:indicator="variableString && variableString.length > 0 ? true : false"
|
||||
:indicator="
|
||||
request.variables && request.variables.length > 0 ? true : false
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.variables") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLVariables()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabledVariable }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="
|
||||
linewrapEnabledVariable = !linewrapEnabledVariable
|
||||
"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyVariablesIcon"
|
||||
@click="prettifyVariableString"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyVariablesIcon"
|
||||
@click="copyVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="variableEditor" class="flex flex-col flex-1"></div>
|
||||
<GraphqlVariable
|
||||
v-model="request.variables"
|
||||
@run-query="runQuery"
|
||||
@save-request="saveRequest"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'headers'"
|
||||
:label="`${t('tab.headers')}`"
|
||||
:info="activeGQLHeadersCount === 0 ? null : `${activeGQLHeadersCount}`"
|
||||
>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("tab.headers") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="bulkMode"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="bulkMode"
|
||||
ref="bulkEditor"
|
||||
class="flex flex-col flex-1"
|
||||
></div>
|
||||
<div v-else>
|
||||
<draggable
|
||||
v-model="workingHeaders"
|
||||
:item-key="(header) => `header-${header.id}`"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: header, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== workingHeaders?.length - 1
|
||||
? t('action.drag_to_reorder')
|
||||
: null,
|
||||
}"
|
||||
:icon="IconGripVertical"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
:class="{
|
||||
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
|
||||
index !== workingHeaders?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<HoppSmartAutoComplete
|
||||
:placeholder="`${t('count.header', { count: index + 1 })}`"
|
||||
:source="commonHeaders"
|
||||
:spellcheck="false"
|
||||
:value="header.key"
|
||||
autofocus
|
||||
styles="
|
||||
bg-transparent
|
||||
flex
|
||||
flex-1
|
||||
py-1
|
||||
px-4
|
||||
truncate
|
||||
"
|
||||
class="flex-1 !flex"
|
||||
@input="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: $event,
|
||||
value: header.value,
|
||||
active: header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<input
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:name="`value ${String(index)}`"
|
||||
:value="header.value"
|
||||
autofocus
|
||||
@change="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: header.key,
|
||||
value: ($event!.target! as HTMLInputElement).value,
|
||||
active: header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
header.hasOwnProperty('active')
|
||||
? header.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
header.hasOwnProperty('active')
|
||||
? header.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: header.key,
|
||||
value: header.value,
|
||||
active: !header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteHeader(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="workingHeaders.length === 0"
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
:text="t('empty.headers')"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<GraphqlHeaders v-model="request" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
|
||||
<GraphqlAuthorization />
|
||||
<GraphqlAuthorization v-model="request.auth" />
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
<CollectionsSaveRequest
|
||||
@@ -318,432 +49,102 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconSave from "~icons/lucide/save"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconGripVertical from "~icons/lucide/grip-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import IconWand2 from "~icons/lucide/wand-2"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { Ref, computed, reactive, ref, watch } from "vue"
|
||||
import * as gql from "graphql"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import {
|
||||
GQLHeader,
|
||||
makeGQLRequest,
|
||||
rawKeyValueEntriesToString,
|
||||
parseRawKeyValueEntriesE,
|
||||
RawKeyValueEntry,
|
||||
} from "@hoppscotch/data"
|
||||
import draggable from "vuedraggable-es"
|
||||
import { clone, cloneDeep, isEqual } from "lodash-es"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
|
||||
import {
|
||||
gqlAuth$,
|
||||
gqlHeaders$,
|
||||
gqlQuery$,
|
||||
gqlResponse$,
|
||||
gqlURL$,
|
||||
gqlVariables$,
|
||||
setGQLAuth,
|
||||
setGQLHeaders,
|
||||
setGQLQuery,
|
||||
setGQLResponse,
|
||||
setGQLVariables,
|
||||
} from "~/newstore/GQLSession"
|
||||
import { commonHeaders } from "~/helpers/headers"
|
||||
import { GQLConnection } from "~/helpers/GQLConnection"
|
||||
import { makeGQLHistoryEntry, addGraphqlHistoryEntry } from "~/newstore/history"
|
||||
import { platform } from "~/platform"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import jsonLinter from "~/helpers/editor/linting/json"
|
||||
import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
|
||||
import queryCompleter from "~/helpers/editor/completion/gqlQuery"
|
||||
import { completePageProgress, startPageProgress } from "@modules/loadingbar"
|
||||
import * as gql from "graphql"
|
||||
import { clone } from "lodash-es"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
import { platform } from "~/platform"
|
||||
import { currentActiveTab } from "~/helpers/graphql/tab"
|
||||
import { computedWithControl } from "@vueuse/core"
|
||||
import {
|
||||
GQLResponseEvent,
|
||||
runGQLOperation,
|
||||
gqlMessageEvent,
|
||||
} from "~/helpers/graphql/connection"
|
||||
import { useService } from "dioc/vue"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
|
||||
type OptionTabs = "query" | "headers" | "variables" | "authorization"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const selectedOptionTab = ref<OptionTabs>("query")
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const interceptorService = useService(InterceptorService)
|
||||
|
||||
const props = defineProps<{
|
||||
conn: GQLConnection
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const url = useReadonlyStream(gqlURL$, "")
|
||||
const gqlQueryString = useStream(gqlQuery$, "", setGQLQuery)
|
||||
const variableString = useStream(gqlVariables$, "", setGQLVariables)
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const bulkMode = ref(false)
|
||||
const bulkHeaders = ref("")
|
||||
const bulkEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkHeaders,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
// The functional headers list (the headers actually in the system)
|
||||
const headers = useStream(gqlHeaders$, [], setGQLHeaders) as Ref<GQLHeader[]>
|
||||
|
||||
const auth = useStream(
|
||||
gqlAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setGQLAuth
|
||||
)
|
||||
|
||||
// The UI representation of the headers list (has the empty end header)
|
||||
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
|
||||
// v-model integration with props and emit
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: HoppGQLRequest
|
||||
response?: GQLResponseEvent[] | null
|
||||
tabId: string
|
||||
}>(),
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working Headers always have one empty header or the last element is always an empty header
|
||||
watch(workingHeaders, (headersList) => {
|
||||
if (
|
||||
headersList.length > 0 &&
|
||||
headersList[headersList.length - 1].key !== ""
|
||||
) {
|
||||
workingHeaders.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
response: null,
|
||||
}
|
||||
})
|
||||
)
|
||||
const emit = defineEmits(["update:modelValue", "update:response"])
|
||||
|
||||
const request = ref(props.modelValue)
|
||||
|
||||
// Sync logic between headers and working headers
|
||||
watch(
|
||||
headers,
|
||||
(newHeadersList) => {
|
||||
// Sync should overwrite working headers
|
||||
const filteredWorkingHeaders = pipe(
|
||||
workingHeaders.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredBulkHeaders = pipe(
|
||||
parseRawKeyValueEntriesE(bulkHeaders.value),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(newHeadersList, filteredWorkingHeaders)) {
|
||||
workingHeaders.value = pipe(
|
||||
newHeadersList,
|
||||
A.map((x) => ({ id: idTicker.value++, ...x }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEqual(newHeadersList, filteredBulkHeaders)) {
|
||||
bulkHeaders.value = rawKeyValueEntriesToString(newHeadersList)
|
||||
}
|
||||
() => request.value,
|
||||
(newVal) => {
|
||||
emit("update:modelValue", newVal)
|
||||
},
|
||||
{ immediate: true }
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(workingHeaders, (newWorkingHeaders) => {
|
||||
const fixedHeaders = pipe(
|
||||
newWorkingHeaders,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(headers.value, fixedHeaders)) {
|
||||
headers.value = cloneDeep(fixedHeaders)
|
||||
}
|
||||
})
|
||||
|
||||
// Bulk Editor Syncing with Working Headers
|
||||
watch(bulkHeaders, (newBulkHeaders) => {
|
||||
const filteredBulkHeaders = pipe(
|
||||
parseRawKeyValueEntriesE(newBulkHeaders),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(headers.value, filteredBulkHeaders)) {
|
||||
headers.value = filteredBulkHeaders
|
||||
}
|
||||
})
|
||||
|
||||
watch(workingHeaders, (newHeadersList) => {
|
||||
// If we are in bulk mode, don't apply direct changes
|
||||
if (bulkMode.value) return
|
||||
|
||||
try {
|
||||
const currentBulkHeaders = bulkHeaders.value.split("\n").map((item) => ({
|
||||
key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
|
||||
value: item.substring(item.indexOf(":") + 1).trimLeft(),
|
||||
active: !item.trim().startsWith("#"),
|
||||
}))
|
||||
|
||||
const filteredHeaders = newHeadersList.filter((x) => x.key !== "")
|
||||
|
||||
if (!isEqual(currentBulkHeaders, filteredHeaders)) {
|
||||
bulkHeaders.value = rawKeyValueEntriesToString(filteredHeaders)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
const addHeader = () => {
|
||||
workingHeaders.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateHeader = (index: number, header: GQLHeader & { id: number }) => {
|
||||
workingHeaders.value = workingHeaders.value.map((h, i) =>
|
||||
i === index ? header : h
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHeader = (index: number) => {
|
||||
const headersBeforeDeletion = clone(workingHeaders.value)
|
||||
|
||||
if (
|
||||
!(
|
||||
headersBeforeDeletion.length > 0 &&
|
||||
index === headersBeforeDeletion.length - 1
|
||||
)
|
||||
) {
|
||||
if (deletionToast.value) {
|
||||
deletionToast.value.goAway(0)
|
||||
deletionToast.value = null
|
||||
}
|
||||
|
||||
deletionToast.value = toast.success(`${t("state.deleted")}`, {
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.undo")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
workingHeaders.value = headersBeforeDeletion
|
||||
toastObject.goAway(0)
|
||||
deletionToast.value = null
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
onComplete: () => {
|
||||
deletionToast.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workingHeaders.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set headers list to the initial state
|
||||
workingHeaders.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
|
||||
bulkHeaders.value = ""
|
||||
}
|
||||
const url = computedWithControl(
|
||||
() => currentActiveTab.value,
|
||||
() => currentActiveTab.value.document.request.url
|
||||
)
|
||||
|
||||
const activeGQLHeadersCount = computed(
|
||||
() =>
|
||||
headers.value.filter((x) => x.active && (x.key !== "" || x.value !== ""))
|
||||
.length
|
||||
request.value.headers.filter(
|
||||
(x) => x.active && (x.key !== "" || x.value !== "")
|
||||
).length
|
||||
)
|
||||
|
||||
const variableEditor = ref<any | null>(null)
|
||||
const linewrapEnabledVariable = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
variableEditor,
|
||||
variableString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/ld+json",
|
||||
placeholder: `${t("request.variables")}`,
|
||||
lineWrapping: linewrapEnabledVariable,
|
||||
},
|
||||
linter: computed(() =>
|
||||
variableString.value.length > 0 ? jsonLinter : null
|
||||
),
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const queryEditor = ref<any | null>(null)
|
||||
const schema = useReadonlyStream(props.conn.schema$, null, "noclone")
|
||||
const linewrapEnabledQuery = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
queryEditor,
|
||||
gqlQueryString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "graphql",
|
||||
placeholder: `${t("request.query")}`,
|
||||
lineWrapping: linewrapEnabledQuery,
|
||||
},
|
||||
linter: createGQLQueryLinter(schema),
|
||||
completer: queryCompleter(schema),
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
const copyVariablesIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
const prettifyQueryIcon = refAutoReset<
|
||||
typeof IconWand2 | typeof IconCheck | typeof IconInfo
|
||||
>(IconWand2, 1000)
|
||||
const prettifyVariablesIcon = refAutoReset<
|
||||
typeof IconWand2 | typeof IconCheck | typeof IconInfo
|
||||
>(IconWand2, 1000)
|
||||
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
const copyQuery = () => {
|
||||
copyToClipboard(gqlQueryString.value)
|
||||
copyQueryIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const response = useStream(gqlResponse$, "", setGQLResponse)
|
||||
|
||||
const runQuery = async () => {
|
||||
const runQuery = async (
|
||||
definition: gql.OperationDefinitionNode | null = null
|
||||
) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
startPageProgress()
|
||||
response.value = "loading"
|
||||
|
||||
try {
|
||||
const runURL = clone(url.value)
|
||||
const runHeaders = clone(headers.value)
|
||||
const runQuery = clone(gqlQueryString.value)
|
||||
const runVariables = clone(variableString.value)
|
||||
const runAuth = clone(auth.value)
|
||||
const runHeaders = clone(request.value.headers)
|
||||
const runQuery = clone(request.value.query)
|
||||
const runVariables = clone(request.value.variables)
|
||||
const runAuth = clone(request.value.auth)
|
||||
|
||||
const responseText = await props.conn.runQuery(
|
||||
runURL,
|
||||
runHeaders,
|
||||
runQuery,
|
||||
runVariables,
|
||||
runAuth
|
||||
)
|
||||
await runGQLOperation({
|
||||
name: request.value.name,
|
||||
url: runURL,
|
||||
headers: runHeaders,
|
||||
query: runQuery,
|
||||
variables: runVariables,
|
||||
auth: runAuth,
|
||||
operationName: definition?.name?.value,
|
||||
operationType: definition?.operation ?? "query",
|
||||
})
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
completePageProgress()
|
||||
|
||||
response.value = JSON.stringify(JSON.parse(responseText), null, 2)
|
||||
|
||||
addGraphqlHistoryEntry(
|
||||
makeGQLHistoryEntry({
|
||||
request: makeGQLRequest({
|
||||
name: "",
|
||||
url: runURL,
|
||||
query: runQuery,
|
||||
headers: runHeaders,
|
||||
variables: runVariables,
|
||||
auth: runAuth,
|
||||
}),
|
||||
response: response.value,
|
||||
star: false,
|
||||
})
|
||||
)
|
||||
|
||||
toast.success(`${t("state.finished_in", { duration })}`)
|
||||
} catch (e: any) {
|
||||
response.value = `${e}`
|
||||
console.log(e)
|
||||
// response.value = [`${e}`]
|
||||
completePageProgress()
|
||||
|
||||
toast.error(
|
||||
`${t("error.something_went_wrong")}. ${t("error.check_console_details")}`,
|
||||
{}
|
||||
)
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_REQUEST_RUN",
|
||||
platform: "graphql-query",
|
||||
@@ -751,54 +152,39 @@ const runQuery = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => gqlMessageEvent.value,
|
||||
(event) => {
|
||||
if (event === "reset") {
|
||||
emit("update:response", [])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (event?.operationType !== "subscription") {
|
||||
// response.value = [event]
|
||||
emit("update:response", [event])
|
||||
} else {
|
||||
emit("update:response", [...(props.response ?? []), event])
|
||||
|
||||
// TODO: subscription indicator??
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const hideRequestModal = () => {
|
||||
showSaveRequestModal.value = false
|
||||
}
|
||||
|
||||
const prettifyQuery = () => {
|
||||
try {
|
||||
gqlQueryString.value = gql.print(
|
||||
gql.parse(gqlQueryString.value, {
|
||||
allowLegacyFragmentVariables: true,
|
||||
})
|
||||
)
|
||||
prettifyQueryIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
toast.error(`${t("error.gql_prettify_invalid_query")}`)
|
||||
prettifyQueryIcon.value = IconInfo
|
||||
}
|
||||
}
|
||||
|
||||
const saveRequest = () => {
|
||||
showSaveRequestModal.value = true
|
||||
}
|
||||
|
||||
const copyVariables = () => {
|
||||
copyToClipboard(variableString.value)
|
||||
copyVariablesIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const prettifyVariableString = () => {
|
||||
try {
|
||||
const jsonObj = JSON.parse(variableString.value)
|
||||
variableString.value = JSON.stringify(jsonObj, null, 2)
|
||||
prettifyVariablesIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
prettifyVariablesIcon.value = IconInfo
|
||||
toast.error(`${t("error.json_prettify_invalid_body")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const clearGQLQuery = () => {
|
||||
gqlQueryString.value = ""
|
||||
request.value.query = ""
|
||||
}
|
||||
|
||||
const clearGQLVariables = () => {
|
||||
variableString.value = ""
|
||||
}
|
||||
|
||||
defineActionHandler("request.send-cancel", runQuery)
|
||||
defineActionHandler("request.save", saveRequest)
|
||||
defineActionHandler("request.reset", clearGQLQuery)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="gql-primary">
|
||||
<template #primary>
|
||||
<GraphqlRequestOptions
|
||||
v-model="tab.document.request"
|
||||
v-model:response="tab.response"
|
||||
:tab-id="tab.id"
|
||||
/>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<GraphqlResponse :response="tab.response" />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { watch } from "vue"
|
||||
import { isEqualHoppGQLRequest } from "~/helpers/graphql"
|
||||
import { HoppGQLTab } from "~/helpers/graphql/tab"
|
||||
|
||||
// TODO: Move Response and Request execution code to over here
|
||||
|
||||
const props = defineProps<{ modelValue: HoppGQLTab }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: HoppGQLTab): void
|
||||
}>()
|
||||
|
||||
const tab = useVModel(props, "modelValue", emit)
|
||||
|
||||
// TODO: Come up with a better dirty check
|
||||
let oldRequest = cloneDeep(tab.value.document.request)
|
||||
watch(
|
||||
() => tab.value.document.request,
|
||||
(updatedValue) => {
|
||||
// TODO: Check equality of request
|
||||
if (
|
||||
!tab.value.document.isDirty &&
|
||||
!isEqualHoppGQLRequest(oldRequest, updatedValue)
|
||||
) {
|
||||
tab.value.document.isDirty = true
|
||||
}
|
||||
|
||||
oldRequest = cloneDeep(updatedValue)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,14 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="responseString === 'loading'"
|
||||
:text="t('state.loading')"
|
||||
>
|
||||
<template #icon>
|
||||
<HoppSmartSpinner class="my-4" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
<div v-else-if="responseString" class="flex flex-col flex-1">
|
||||
<div v-if="response?.length === 1" class="flex flex-col flex-1">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
|
||||
>
|
||||
@@ -37,12 +29,18 @@
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyResponseIcon"
|
||||
@click="copyResponse"
|
||||
@click="copyResponse(response[0].data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="schemaEditor" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="response && response?.length > 1"
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<GraphqlSubscriptionLog :log="response" />
|
||||
</div>
|
||||
<AppShortcutsPrompt v-else class="p-4" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,22 +50,34 @@ import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import { reactive, ref } from "vue"
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { gqlResponse$ } from "~/newstore/GQLSession"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { GQLResponseEvent } from "~/helpers/graphql/connection"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const responseString = useReadonlyStream(gqlResponse$, "")
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
response: GQLResponseEvent[] | null
|
||||
}>(),
|
||||
{
|
||||
response: null,
|
||||
}
|
||||
)
|
||||
|
||||
const responseString = computed(() => {
|
||||
if (props.response?.length === 1) {
|
||||
return JSON.stringify(JSON.parse(props.response[0].data), null, 2)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
const schemaEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
@@ -95,14 +105,14 @@ const copyResponseIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
1000
|
||||
)
|
||||
|
||||
const copyResponse = () => {
|
||||
copyToClipboard(responseString.value!)
|
||||
const copyResponse = (str: string) => {
|
||||
copyToClipboard(str)
|
||||
copyResponseIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const downloadResponse = () => {
|
||||
const dataToWrite = responseString.value
|
||||
const downloadResponse = (str: string) => {
|
||||
const dataToWrite = str
|
||||
const file = new Blob([dataToWrite!], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
@@ -118,6 +128,10 @@ const downloadResponse = () => {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
defineActionHandler("response.file.download", () =>
|
||||
downloadResponse.bind(responseString.value)
|
||||
)
|
||||
defineActionHandler("response.copy", () =>
|
||||
copyResponse.bind(responseString.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -5,20 +5,6 @@
|
||||
vertical
|
||||
render-inactive-tabs
|
||||
>
|
||||
<HoppSmartTab
|
||||
:id="'history'"
|
||||
:icon="IconClock"
|
||||
:label="`${t('tab.history')}`"
|
||||
>
|
||||
<History :page="'graphql'" @use-history="handleUseHistory" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'collections'"
|
||||
:icon="IconFolder"
|
||||
:label="`${t('tab.collections')}`"
|
||||
>
|
||||
<CollectionsGraphql />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'docs'"
|
||||
:icon="IconBookOpen"
|
||||
@@ -173,6 +159,21 @@
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab
|
||||
:id="'collections'"
|
||||
:icon="IconFolder"
|
||||
:label="`${t('tab.collections')}`"
|
||||
>
|
||||
<CollectionsGraphql />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'history'"
|
||||
:icon="IconClock"
|
||||
:label="`${t('tab.history')}`"
|
||||
>
|
||||
<History :page="'graphql'" />
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</template>
|
||||
|
||||
@@ -188,29 +189,24 @@ import IconCopy from "~icons/lucide/copy"
|
||||
import IconBox from "~icons/lucide/box"
|
||||
import { computed, nextTick, reactive, ref } from "vue"
|
||||
import { GraphQLField, GraphQLType } from "graphql"
|
||||
import { map } from "rxjs/operators"
|
||||
import { GQLHeader } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { GQLConnection } from "@helpers/GQLConnection"
|
||||
import { copyToClipboard } from "@helpers/utils/clipboard"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
setGQLAuth,
|
||||
setGQLHeaders,
|
||||
setGQLQuery,
|
||||
setGQLResponse,
|
||||
setGQLURL,
|
||||
setGQLVariables,
|
||||
} from "~/newstore/GQLSession"
|
||||
graphqlTypes,
|
||||
mutationFields,
|
||||
queryFields,
|
||||
schemaString,
|
||||
subscriptionFields,
|
||||
} from "~/helpers/graphql/connection"
|
||||
|
||||
type NavigationTabs = "history" | "collection" | "docs" | "schema"
|
||||
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
|
||||
|
||||
const selectedNavigationTab = ref<NavigationTabs>("history")
|
||||
const selectedNavigationTab = ref<NavigationTabs>("docs")
|
||||
const selectedGqlTab = ref<GqlTabs>("queries")
|
||||
|
||||
const t = useI18n()
|
||||
@@ -270,40 +266,8 @@ function resolveRootType(type: GraphQLType) {
|
||||
return t
|
||||
}
|
||||
|
||||
type GQLHistoryEntry = {
|
||||
url: string
|
||||
headers: GQLHeader[]
|
||||
query: string
|
||||
response: string
|
||||
variables: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
conn: GQLConnection
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const queryFields = useReadonlyStream(
|
||||
props.conn.queryFields$.pipe(map((x) => x ?? [])),
|
||||
[]
|
||||
)
|
||||
|
||||
const mutationFields = useReadonlyStream(
|
||||
props.conn.mutationFields$.pipe(map((x) => x ?? [])),
|
||||
[]
|
||||
)
|
||||
|
||||
const subscriptionFields = useReadonlyStream(
|
||||
props.conn.subscriptionFields$.pipe(map((x) => x ?? [])),
|
||||
[]
|
||||
)
|
||||
|
||||
const graphqlTypes = useReadonlyStream(
|
||||
props.conn.graphqlTypes$.pipe(map((x) => x ?? [])),
|
||||
[]
|
||||
)
|
||||
|
||||
const downloadSchemaIcon = refAutoReset<typeof IconDownload | typeof IconCheck>(
|
||||
IconDownload,
|
||||
1000
|
||||
@@ -390,11 +354,6 @@ const handleJumpToType = async (type: GraphQLType) => {
|
||||
}
|
||||
}
|
||||
|
||||
const schemaString = useReadonlyStream(
|
||||
props.conn.schemaString$.pipe(map((x) => x ?? "")),
|
||||
""
|
||||
)
|
||||
|
||||
const schemaEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
@@ -436,23 +395,4 @@ const copySchema = () => {
|
||||
copyToClipboard(schemaString.value)
|
||||
copySchemaIcon.value = IconCheck
|
||||
}
|
||||
|
||||
const handleUseHistory = (entry: GQLHistoryEntry) => {
|
||||
const url = entry.url
|
||||
const headers = entry.headers
|
||||
const gqlQueryString = entry.query
|
||||
const variableString = entry.variables
|
||||
const responseText = entry.response
|
||||
|
||||
setGQLURL(url)
|
||||
setGQLHeaders(headers)
|
||||
setGQLQuery(gqlQueryString)
|
||||
setGQLVariables(variableString)
|
||||
setGQLResponse(responseText)
|
||||
setGQLAuth({
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
})
|
||||
props.conn.reset()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-col flex-1">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between flex-none pl-4 border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<label for="log" class="py-2 font-semibold text-secondaryLight">
|
||||
{{ "Subscription Log" }}
|
||||
</label>
|
||||
<div>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.delete')"
|
||||
:icon="IconTrash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.scroll_to_top')"
|
||||
:icon="IconArrowUp"
|
||||
@click="scrollTo('top')"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.scroll_to_bottom')"
|
||||
:icon="IconArrowDown"
|
||||
@click="scrollTo('bottom')"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
id="bottompage"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.autoscroll')"
|
||||
:icon="IconChevronsDown"
|
||||
:class="toggleAutoscrollColor"
|
||||
@click="toggleAutoscroll()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="log.length !== 0"
|
||||
ref="logs"
|
||||
class="overflow-y-auto border-b border-dividerLight"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col h-auto h-full border-r divide-y divide-dividerLight border-dividerLight"
|
||||
>
|
||||
<RealtimeLogEntry
|
||||
v-for="(entry, index) in log"
|
||||
:key="`entry-${index}`"
|
||||
:is-open="log.length - 1 === index"
|
||||
:entry="{ ts: entry.time, source: 'info', payload: entry.data }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, PropType, computed, watch, Ref } from "vue"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconArrowUp from "~icons/lucide/arrow-up"
|
||||
import IconArrowDown from "~icons/lucide/arrow-down"
|
||||
import IconChevronsDown from "~icons/lucide/chevron-down"
|
||||
import { useThrottleFn, useScroll } from "@vueuse/core"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { GQLResponseEvent } from "~/helpers/graphql/connection"
|
||||
|
||||
const props = defineProps({
|
||||
log: { type: Array as PropType<GQLResponseEvent[]>, default: () => [] },
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const logs = ref<HTMLElement | null>(null)
|
||||
|
||||
const autoScrollEnabled = ref(true)
|
||||
|
||||
const logListScroll = useScroll(logs as Ref<HTMLElement>)
|
||||
|
||||
// Disable autoscroll when scrolling to top
|
||||
watch(logListScroll.isScrolling, (isScrolling) => {
|
||||
if (isScrolling && logListScroll.directions.top)
|
||||
autoScrollEnabled.value = false
|
||||
})
|
||||
|
||||
const scrollTo = (position: "top" | "bottom") => {
|
||||
if (position === "top") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: 0,
|
||||
})
|
||||
} else if (position === "bottom") {
|
||||
logs.value?.scroll({
|
||||
behavior: "smooth",
|
||||
top: logs.value?.scrollHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.log,
|
||||
useThrottleFn(() => {
|
||||
if (autoScrollEnabled.value) scrollTo("bottom")
|
||||
}, 200),
|
||||
{ flush: "post" }
|
||||
)
|
||||
|
||||
const toggleAutoscroll = () => {
|
||||
autoScrollEnabled.value = !autoScrollEnabled.value
|
||||
}
|
||||
|
||||
const toggleAutoscrollColor = computed(() =>
|
||||
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
|
||||
)
|
||||
</script>
|
||||
@@ -55,51 +55,48 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// TODO: TypeScript + Setup Script this at some point :)
|
||||
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLInterfaceType,
|
||||
} from "graphql"
|
||||
import { computed } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
// eslint-disable-next-line vue/require-default-prop, vue/require-prop-types
|
||||
gqlType: {},
|
||||
gqlTypes: { type: Array, default: () => [] },
|
||||
jumpTypeCallback: { type: Function, default: () => ({}) },
|
||||
isHighlighted: { type: Boolean, default: false },
|
||||
highlightedFields: { type: Array, default: () => [] },
|
||||
},
|
||||
computed: {
|
||||
isInput() {
|
||||
return this.gqlType instanceof GraphQLInputObjectType
|
||||
},
|
||||
isInterface() {
|
||||
return this.gqlType instanceof GraphQLInterfaceType
|
||||
},
|
||||
isEnum() {
|
||||
return this.gqlType instanceof GraphQLEnumType
|
||||
},
|
||||
interfaces() {
|
||||
return (this.gqlType.getInterfaces && this.gqlType.getInterfaces()) || []
|
||||
},
|
||||
children() {
|
||||
return this.gqlTypes.filter(
|
||||
(type) =>
|
||||
type.getInterfaces && type.getInterfaces().includes(this.gqlType)
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isFieldHighlighted({ field }) {
|
||||
return !!this.highlightedFields.find(({ name }) => name === field.name)
|
||||
},
|
||||
const props = defineProps({
|
||||
gqlType: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
gqlTypes: { type: Array, default: () => [] },
|
||||
jumpTypeCallback: { type: Function, default: () => ({}) },
|
||||
isHighlighted: { type: Boolean, default: false },
|
||||
highlightedFields: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const isInput = computed(() => {
|
||||
return props.gqlType instanceof GraphQLInputObjectType
|
||||
})
|
||||
|
||||
const isInterface = computed(() => {
|
||||
return props.gqlType instanceof GraphQLInterfaceType
|
||||
})
|
||||
const isEnum = computed(() => {
|
||||
return props.gqlType instanceof GraphQLEnumType
|
||||
})
|
||||
const interfaces = computed(() => {
|
||||
return (props.gqlType.getInterfaces && props.gqlType.getInterfaces()) || []
|
||||
})
|
||||
|
||||
const children = computed(() => {
|
||||
return props.gqlTypes.filter(
|
||||
(type) => type.getInterfaces && type.getInterfaces().includes(props.gqlType)
|
||||
)
|
||||
})
|
||||
|
||||
const isFieldHighlighted = ({ field }) => {
|
||||
return !!props.highlightedFields.find(({ name }) => name === field.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
172
packages/hoppscotch-common/src/components/graphql/Variable.vue
Normal file
172
packages/hoppscotch-common/src/components/graphql/Variable.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<label class="font-semibold text-secondaryLight">
|
||||
{{ t("request.variables") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-if="subscriptionState === 'SUBSCRIBED'"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
allowHTML: true,
|
||||
}"
|
||||
:title="`${t('request.stop')}`"
|
||||
:label="`${t('request.stop')}`"
|
||||
:icon="IconStop"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="unsubscribe()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="selectedOperation && subscriptionState !== 'SUBSCRIBED'"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
allowHTML: true,
|
||||
}"
|
||||
:title="`${t('request.run')} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
|
||||
:label="`${selectedOperation.name?.value ?? t('request.run')}`"
|
||||
:icon="IconPlay"
|
||||
:disabled="!selectedOperation"
|
||||
class="rounded-none !text-accent !hover:text-accentDark"
|
||||
@click="runQuery(selectedOperation)"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLVariables()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyVariablesIcon"
|
||||
@click="prettifyVariableString"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyVariablesIcon"
|
||||
@click="copyVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="variableEditor" class="flex flex-col flex-1"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import IconStop from "~icons/lucide/stop-circle"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
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 { computed, reactive, ref } from "vue"
|
||||
import jsonLinter from "~/helpers/editor/linting/json"
|
||||
import { copyToClipboard } from "@helpers/utils/clipboard"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import * as gql from "graphql"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import {
|
||||
socketDisconnect,
|
||||
subscriptionState,
|
||||
} from "~/helpers/graphql/connection"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save-request"): void
|
||||
(e: "update:modelValue", val: string): void
|
||||
(e: "run-query", definition: gql.OperationDefinitionNode | null): void
|
||||
}>()
|
||||
|
||||
// Watch operations on graphql query string
|
||||
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
|
||||
|
||||
const variableString = useVModel(props, "modelValue", emit)
|
||||
|
||||
const variableEditor = ref<any | null>(null)
|
||||
|
||||
const linewrapEnabled = ref(false)
|
||||
|
||||
const copyVariablesIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
const prettifyVariablesIcon = refAutoReset<
|
||||
typeof IconWand | typeof IconCheck | typeof IconInfo
|
||||
>(IconWand, 1000)
|
||||
|
||||
useCodemirror(
|
||||
variableEditor,
|
||||
variableString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/ld+json",
|
||||
placeholder: `${t("request.variables")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: computed(() =>
|
||||
variableString.value.length > 0 ? jsonLinter : null
|
||||
),
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const copyVariables = () => {
|
||||
copyToClipboard(variableString.value)
|
||||
copyVariablesIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const prettifyVariableString = () => {
|
||||
try {
|
||||
const jsonObj = JSON.parse(variableString.value)
|
||||
variableString.value = JSON.stringify(jsonObj, null, 2)
|
||||
prettifyVariablesIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
prettifyVariablesIcon.value = IconInfo
|
||||
toast.error(`${t("error.json_prettify_invalid_body")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const clearGQLVariables = () => {
|
||||
variableString.value = ""
|
||||
}
|
||||
|
||||
const runQuery = (definition: gql.OperationDefinitionNode | null = null) => {
|
||||
emit("run-query", definition)
|
||||
}
|
||||
const unsubscribe = () => {
|
||||
socketDisconnect()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user