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:
@@ -574,3 +574,11 @@ details[open] summary .indicator {
|
||||
@apply rounded;
|
||||
@apply border-0;
|
||||
}
|
||||
|
||||
.gql-operation-not-highlight {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gql-operation-highlight {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
|
||||
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
|
||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||
"close_unsaved_tab": "Are you sure you want to close this tab?",
|
||||
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
|
||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
||||
},
|
||||
@@ -281,6 +282,10 @@
|
||||
"graphql": {
|
||||
"mutations": "Mutations",
|
||||
"schema": "Schema",
|
||||
"switch_connection": "Switch connection",
|
||||
"connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is",
|
||||
"connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is",
|
||||
"connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?",
|
||||
"subscriptions": "Subscriptions"
|
||||
},
|
||||
"group": {
|
||||
@@ -473,6 +478,7 @@
|
||||
"rename": "Rename Request",
|
||||
"renamed": "Request renamed",
|
||||
"run": "Run",
|
||||
"stop": "Stop",
|
||||
"save": "Save",
|
||||
"save_as": "Save as",
|
||||
"saved": "Request saved",
|
||||
@@ -578,6 +584,10 @@
|
||||
"show_all": "Keyboard shortcuts",
|
||||
"title": "General"
|
||||
},
|
||||
"others": {
|
||||
"title": "Others",
|
||||
"prettify": "Prettify Editor's Content"
|
||||
},
|
||||
"miscellaneous": {
|
||||
"invite": "Invite people to Hoppscotch",
|
||||
"title": "Miscellaneous"
|
||||
|
||||
22
packages/hoppscotch-common/src/components.d.ts
vendored
22
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -1,11 +1,11 @@
|
||||
// generated by unplugin-vue-components
|
||||
// We suggest you to commit this file into source control
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||
@@ -72,12 +72,17 @@ declare module '@vue/runtime-core' {
|
||||
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
|
||||
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
|
||||
GraphqlField: typeof import('./components/graphql/Field.vue')['default']
|
||||
GraphqlHeaders: typeof import('./components/graphql/Headers.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']
|
||||
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
|
||||
GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default']
|
||||
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
|
||||
GraphqlTypeLink: 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']
|
||||
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
|
||||
@@ -90,7 +95,6 @@ declare module '@vue/runtime-core' {
|
||||
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
|
||||
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
|
||||
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
|
||||
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
|
||||
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
||||
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
|
||||
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
|
||||
@@ -104,6 +108,7 @@ declare module '@vue/runtime-core' {
|
||||
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
|
||||
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
|
||||
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
|
||||
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
|
||||
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
|
||||
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
|
||||
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
|
||||
@@ -136,7 +141,6 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
@@ -146,10 +150,9 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
||||
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
|
||||
@@ -214,5 +217,4 @@ declare module '@vue/runtime-core' {
|
||||
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
|
||||
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ import {
|
||||
updateTeamRequest,
|
||||
} from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { Picked } from "~/helpers/types/HoppPicked"
|
||||
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
@@ -82,8 +81,9 @@ import {
|
||||
} from "~/newstore/collections"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { computedWithControl } from "@vueuse/core"
|
||||
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||
import { platform } from "~/platform"
|
||||
import { currentActiveTab as activeRESTTab } from "~/helpers/rest/tab"
|
||||
import { currentActiveTab as activeGQLTab } from "~/helpers/graphql/tab"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -122,10 +122,14 @@ const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const gqlRequestName = useGQLRequestName()
|
||||
const gqlRequestName = computedWithControl(
|
||||
() => activeGQLTab.value,
|
||||
() => activeGQLTab.value.document.request.name
|
||||
)
|
||||
|
||||
const restRequestName = computedWithControl(
|
||||
() => currentActiveTab.value,
|
||||
() => currentActiveTab.value.document.request.name
|
||||
() => activeRESTTab.value,
|
||||
() => activeRESTTab.value.document.request.name
|
||||
)
|
||||
|
||||
const reqName = computed(() => {
|
||||
@@ -141,11 +145,13 @@ const reqName = computed(() => {
|
||||
const requestName = ref(reqName.value)
|
||||
|
||||
watch(
|
||||
() => [currentActiveTab.value, gqlRequestName.value],
|
||||
() => [activeRESTTab.value, activeGQLTab.value],
|
||||
() => {
|
||||
if (props.mode === "rest") {
|
||||
requestName.value = currentActiveTab.value?.document.request.name ?? ""
|
||||
} else requestName.value = gqlRequestName.value
|
||||
requestName.value = activeRESTTab.value?.document.request.name ?? ""
|
||||
} else {
|
||||
requestName.value = activeGQLTab.value?.document.request.name ?? ""
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -202,15 +208,10 @@ const saveRequestAs = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
let requestUpdated
|
||||
|
||||
if (props.request) {
|
||||
requestUpdated = cloneDeep(props.request)
|
||||
} else if (props.mode === "rest") {
|
||||
requestUpdated = cloneDeep(currentActiveTab.value.document.request)
|
||||
} else {
|
||||
requestUpdated = cloneDeep(getGQLSession().request)
|
||||
}
|
||||
const requestUpdated =
|
||||
props.mode === "rest"
|
||||
? cloneDeep(activeRESTTab.value.document.request)
|
||||
: cloneDeep(activeGQLTab.value.document.request)
|
||||
|
||||
requestUpdated.name = requestName.value
|
||||
|
||||
@@ -223,7 +224,7 @@ const saveRequestAs = async () => {
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
currentActiveTab.value.document = {
|
||||
activeRESTTab.value.document = {
|
||||
request: requestUpdated,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
@@ -250,7 +251,7 @@ const saveRequestAs = async () => {
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
currentActiveTab.value.document = {
|
||||
activeRESTTab.value.document = {
|
||||
request: requestUpdated,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
@@ -278,7 +279,7 @@ const saveRequestAs = async () => {
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
currentActiveTab.value.document = {
|
||||
activeRESTTab.value.document = {
|
||||
request: requestUpdated,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
@@ -438,7 +439,7 @@ const updateTeamCollectionOrFolder = (
|
||||
(result) => {
|
||||
const { createRequestInCollection } = result
|
||||
|
||||
currentActiveTab.value.document = {
|
||||
activeRESTTab.value.document = {
|
||||
request: requestUpdated,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
@@ -459,7 +460,7 @@ const updateTeamCollectionOrFolder = (
|
||||
const requestSaved = () => {
|
||||
toast.success(`${t("request.added")}`)
|
||||
nextTick(() => {
|
||||
currentActiveTab.value.document.isDirty = false
|
||||
activeRESTTab.value.document.isDirty = false
|
||||
})
|
||||
hideModal()
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { getGQLSession } from "~/newstore/GQLSession"
|
||||
import { currentActiveTab } from "~/helpers/graphql/tab"
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
@@ -63,7 +63,7 @@ watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
editingName.value = getGQLSession().request.name
|
||||
editingName.value = currentActiveTab.value?.document.request.name
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -132,7 +132,7 @@ import { useToast } from "@composables/toast"
|
||||
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { removeGraphqlRequest } from "~/newstore/collections"
|
||||
import { setGQLSession } from "~/newstore/GQLSession"
|
||||
import { createNewTab } from "~/helpers/graphql/tab"
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
@@ -179,7 +179,7 @@ const selectRequest = () => {
|
||||
if (props.saveRequest) {
|
||||
pick()
|
||||
} else {
|
||||
setGQLSession({
|
||||
createNewTab({
|
||||
request: cloneDeep(
|
||||
makeGQLRequest({
|
||||
name: props.request.name,
|
||||
@@ -190,8 +190,7 @@ const selectRequest = () => {
|
||||
auth: props.request.auth,
|
||||
})
|
||||
),
|
||||
schema: "",
|
||||
response: "",
|
||||
isDirty: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,6 @@ import {
|
||||
addGraphqlFolder,
|
||||
saveGraphqlRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
@@ -146,6 +145,7 @@ import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { platform } from "~/platform"
|
||||
import { createNewTab, currentActiveTab } from "~/helpers/graphql/tab"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -267,15 +267,15 @@ export default defineComponent({
|
||||
},
|
||||
onAddRequest({ name, path }) {
|
||||
const newRequest = {
|
||||
...getGQLSession().request,
|
||||
...currentActiveTab.value.document.request,
|
||||
name,
|
||||
}
|
||||
|
||||
saveGraphqlRequestAs(path, newRequest)
|
||||
setGQLSession({
|
||||
|
||||
createNewTab({
|
||||
request: newRequest,
|
||||
schema: "",
|
||||
response: "",
|
||||
isDirty: false,
|
||||
})
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
|
||||
@@ -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>
|
||||
@@ -56,9 +56,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { makeGQLRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { setGQLSession } from "~/newstore/GQLSession"
|
||||
import { GQLHistoryEntry } from "~/newstore/history"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
@@ -69,6 +66,8 @@ import IconMinimize2 from "~icons/lucide/minimize-2"
|
||||
import IconMaximize2 from "~icons/lucide/maximize-2"
|
||||
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { makeGQLRequest } from "@hoppscotch/data"
|
||||
import { createNewTab } from "~/helpers/graphql/tab"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -94,19 +93,16 @@ const query = computed(() =>
|
||||
)
|
||||
|
||||
const useEntry = () => {
|
||||
setGQLSession({
|
||||
request: cloneDeep(
|
||||
makeGQLRequest({
|
||||
name: props.entry.request.name,
|
||||
url: props.entry.request.url,
|
||||
headers: props.entry.request.headers,
|
||||
query: props.entry.request.query,
|
||||
variables: props.entry.request.variables,
|
||||
auth: props.entry.request.auth,
|
||||
})
|
||||
),
|
||||
schema: "",
|
||||
response: props.entry.response,
|
||||
createNewTab({
|
||||
request: makeGQLRequest({
|
||||
name: props.entry.request.name,
|
||||
url: props.entry.request.url,
|
||||
headers: props.entry.request.headers,
|
||||
query: props.entry.request.query,
|
||||
variables: props.entry.request.variables,
|
||||
auth: props.entry.request.auth,
|
||||
}),
|
||||
isDirty: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { HoppRESTAuthOAuth2, parseTemplateString } from "@hoppscotch/data"
|
||||
import {
|
||||
HoppGQLAuthOAuth2,
|
||||
HoppRESTAuthOAuth2,
|
||||
parseTemplateString,
|
||||
} from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
@@ -44,7 +48,7 @@ const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppRESTAuthOAuth2
|
||||
modelValue: HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, PropType, computed, watch } from "vue"
|
||||
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"
|
||||
@@ -73,7 +73,7 @@ export type LogEntryData = {
|
||||
ts: number | undefined
|
||||
source: "info" | "client" | "server" | "disconnected"
|
||||
payload: string
|
||||
event: "connecting" | "connected" | "disconnected" | "error"
|
||||
event?: "connecting" | "connected" | "disconnected" | "error"
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
@@ -94,7 +94,7 @@ const logs = ref<HTMLElement>()
|
||||
|
||||
const autoScrollEnabled = ref(true)
|
||||
|
||||
const logListScroll = useScroll(logs)
|
||||
const logListScroll = useScroll(logs as Ref<HTMLElement>)
|
||||
|
||||
// Disable autoscroll when scrolling to top
|
||||
watch(logListScroll.isScrolling, (isScrolling) => {
|
||||
|
||||
@@ -209,7 +209,7 @@ import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import * as LJSON from "lossless-json"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { ref, computed, reactive, watch, markRaw } from "vue"
|
||||
import { ref, computed, reactive, watch, markRaw, PropType } from "vue"
|
||||
import { refAutoReset, useTimeAgo } from "@vueuse/core"
|
||||
import { LogEntryData } from "./Log.vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
@@ -227,7 +227,16 @@ import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{ entry: LogEntryData }>()
|
||||
const props = defineProps({
|
||||
entry: {
|
||||
type: Object as PropType<LogEntryData>,
|
||||
required: true,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
@@ -304,7 +313,7 @@ const outlinePath = computed(() =>
|
||||
)
|
||||
|
||||
// Code for UI Changes
|
||||
const minimized = ref(true)
|
||||
const minimized = ref(props.isOpen ? false : true)
|
||||
watch(minimized, () => {
|
||||
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
|
||||
})
|
||||
@@ -342,7 +351,9 @@ const ENTRY_COLORS = {
|
||||
} as const
|
||||
|
||||
// Assigns color based on entry event
|
||||
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
|
||||
const entryColor = computed(
|
||||
() => props.entry.event && ENTRY_COLORS[props.entry.event]
|
||||
)
|
||||
|
||||
const ICONS = {
|
||||
info: {
|
||||
|
||||
@@ -480,7 +480,7 @@ watch(editor, () => {
|
||||
@apply flex;
|
||||
@apply flex-1;
|
||||
@apply flex-shrink-0;
|
||||
@apply whitespace-nowrap;
|
||||
@apply whitespace-nowrap py-4;
|
||||
|
||||
.suggestions {
|
||||
@apply absolute;
|
||||
|
||||
@@ -58,6 +58,11 @@ type CodeMirrorOptions = {
|
||||
|
||||
// NOTE: This property is not reactive
|
||||
environmentHighlights: boolean
|
||||
|
||||
additionalExts?: Extension[]
|
||||
|
||||
// callback on editor update
|
||||
onUpdate?: (view: ViewUpdate) => void
|
||||
}
|
||||
|
||||
const hoppCompleterExt = (completer: Completer): Extension => {
|
||||
@@ -189,6 +194,7 @@ export function useCodemirror(
|
||||
): { cursor: Ref<{ line: number; ch: number }> } {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const additionalExts = new Compartment()
|
||||
const language = new Compartment()
|
||||
const lineWrapping = new Compartment()
|
||||
const placeholderConfig = new Compartment()
|
||||
@@ -254,12 +260,24 @@ export function useCodemirror(
|
||||
|
||||
el.addEventListener("mouseup", debounceFn)
|
||||
el.addEventListener("keyup", debounceFn)
|
||||
const cursorPos = update.state.selection.main.head
|
||||
const line = update.state.doc.lineAt(cursorPos)
|
||||
|
||||
cachedCursor.value = {
|
||||
line: line.number - 1,
|
||||
ch: cursorPos - line.from,
|
||||
if (options.onUpdate) {
|
||||
options.onUpdate(update)
|
||||
}
|
||||
|
||||
if (update.selectionSet) {
|
||||
const cursorPos = update.state.selection.main.head
|
||||
const line = update.state.doc.lineAt(cursorPos)
|
||||
|
||||
cachedCursor.value = {
|
||||
line: line.number - 1,
|
||||
ch: cursorPos - line.from,
|
||||
}
|
||||
|
||||
cursor.value = {
|
||||
line: cachedCursor.value.line,
|
||||
ch: cachedCursor.value.ch,
|
||||
}
|
||||
}
|
||||
|
||||
cursor.value = {
|
||||
@@ -313,6 +331,7 @@ export function useCodemirror(
|
||||
},
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||
additionalExts.of(options.additionalExts ?? []),
|
||||
]
|
||||
|
||||
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
||||
@@ -388,6 +407,15 @@ export function useCodemirror(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => options.additionalExts,
|
||||
(newExts) => {
|
||||
view.value?.dispatch({
|
||||
effects: additionalExts.reconfigure(newExts ?? []),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => options.extendedEditorConfig.lineWrapping,
|
||||
(newMode) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ type CloneMode = "noclone" | "shallow" | "deep"
|
||||
*/
|
||||
export function useReadonlyStream<T>(
|
||||
stream$: Observable<T>,
|
||||
initialValue: T,
|
||||
initialValue?: T,
|
||||
cloneMode: CloneMode = "shallow"
|
||||
): Ref<T> {
|
||||
let sub: Subscription | null = null
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import {
|
||||
getIntrospectionQuery,
|
||||
buildClientSchema,
|
||||
GraphQLSchema,
|
||||
printSchema,
|
||||
GraphQLObjectType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLEnumType,
|
||||
GraphQLInterfaceType,
|
||||
} from "graphql"
|
||||
import { distinctUntilChanged, map } from "rxjs/operators"
|
||||
import { GQLHeader, HoppGQLAuth } from "@hoppscotch/data"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
|
||||
const GQL_SCHEMA_POLL_INTERVAL = 7000
|
||||
|
||||
/**
|
||||
GQLConnection deals with all the operations (like polling, schema extraction) that runs
|
||||
when a connection is made to a GraphQL server.
|
||||
*/
|
||||
export class GQLConnection {
|
||||
public isLoading$ = new BehaviorSubject<boolean>(false)
|
||||
public connected$ = new BehaviorSubject<boolean>(false)
|
||||
public schema$ = new BehaviorSubject<GraphQLSchema | null>(null)
|
||||
|
||||
public schemaString$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
return printSchema(schema)
|
||||
})
|
||||
)
|
||||
|
||||
public queryFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getQueryType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public mutationFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getMutationType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public subscriptionFields$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const fields = schema.getSubscriptionType()?.getFields()
|
||||
if (!fields) return null
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
)
|
||||
|
||||
public graphqlTypes$ = this.schema$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map((schema) => {
|
||||
if (!schema) return null
|
||||
|
||||
const typeMap = schema.getTypeMap()
|
||||
|
||||
const queryTypeName = schema.getQueryType()?.name ?? ""
|
||||
const mutationTypeName = schema.getMutationType()?.name ?? ""
|
||||
const subscriptionTypeName = schema.getSubscriptionType()?.name ?? ""
|
||||
|
||||
return Object.values(typeMap).filter((type) => {
|
||||
return (
|
||||
!type.name.startsWith("__") &&
|
||||
![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
|
||||
type.name
|
||||
) &&
|
||||
(type instanceof GraphQLObjectType ||
|
||||
type instanceof GraphQLInputObjectType ||
|
||||
type instanceof GraphQLEnumType ||
|
||||
type instanceof GraphQLInterfaceType)
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
private timeoutSubscription: any
|
||||
|
||||
public connect(url: string, headers: GQLHeader[], auth: HoppGQLAuth) {
|
||||
if (this.connected$.value) {
|
||||
throw new Error(
|
||||
"A connection is already running. Close it before starting another."
|
||||
)
|
||||
}
|
||||
|
||||
// Polling
|
||||
this.connected$.next(true)
|
||||
|
||||
const poll = async () => {
|
||||
await this.getSchema(url, headers, auth)
|
||||
this.timeoutSubscription = setTimeout(() => {
|
||||
poll()
|
||||
}, GQL_SCHEMA_POLL_INTERVAL)
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (!this.connected$.value) {
|
||||
throw new Error("No connections are running to be disconnected")
|
||||
}
|
||||
|
||||
clearTimeout(this.timeoutSubscription)
|
||||
this.connected$.next(false)
|
||||
}
|
||||
|
||||
public reset() {
|
||||
if (this.connected$.value) this.disconnect()
|
||||
|
||||
this.isLoading$.next(false)
|
||||
this.connected$.next(false)
|
||||
this.schema$.next(null)
|
||||
}
|
||||
|
||||
private async getSchema(
|
||||
url: string,
|
||||
reqHeaders: GQLHeader[],
|
||||
auth: HoppGQLAuth
|
||||
) {
|
||||
try {
|
||||
this.isLoading$.next(true)
|
||||
|
||||
const introspectionQuery = JSON.stringify({
|
||||
query: getIntrospectionQuery(),
|
||||
})
|
||||
|
||||
const headers = reqHeaders.filter((x) => x.active && x.key !== "")
|
||||
|
||||
// TODO: Support a better b64 implementation than btoa ?
|
||||
if (auth.authType === "basic") {
|
||||
const username = auth.username
|
||||
const password = auth.password
|
||||
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Basic ${btoa(`${username}:${password}`)}`,
|
||||
})
|
||||
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
|
||||
headers.push({
|
||||
active: true,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${auth.token}`,
|
||||
})
|
||||
} else if (auth.authType === "api-key") {
|
||||
const { key, value, addTo } = auth
|
||||
|
||||
if (addTo === "Headers") {
|
||||
headers.push({
|
||||
active: true,
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const finalHeaders: Record<string, string> = {}
|
||||
headers.forEach((x) => (finalHeaders[x.key] = x.value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "POST" as const,
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: introspectionQuery,
|
||||
}
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
const res = await interceptorService.runRequest(reqOptions).response
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
console.error(res.left)
|
||||
throw new Error(res.left.toString())
|
||||
}
|
||||
|
||||
const data = res.right
|
||||
|
||||
// HACK : Temporary trailing null character issue from the extension fix
|
||||
const response = new TextDecoder("utf-8")
|
||||
.decode(data.data as any)
|
||||
.replace(/\0+$/, "")
|
||||
|
||||
const introspectResponse = JSON.parse(response)
|
||||
|
||||
const schema = buildClientSchema(introspectResponse.data)
|
||||
|
||||
this.schema$.next(schema)
|
||||
|
||||
this.isLoading$.next(false)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
this.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public async runQuery(
|
||||
url: string,
|
||||
headers: GQLHeader[],
|
||||
query: string,
|
||||
variables: string,
|
||||
auth: HoppGQLAuth
|
||||
) {
|
||||
const finalHeaders: Record<string, string> = {}
|
||||
|
||||
const parsedVariables = JSON.parse(variables || "{}")
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (auth.authActive) {
|
||||
if (auth.authType === "basic") {
|
||||
const username = auth.username
|
||||
const password = auth.password
|
||||
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
|
||||
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
|
||||
finalHeaders.Authorization = `Bearer ${auth.token}`
|
||||
} else if (auth.authType === "api-key") {
|
||||
const { key, value, addTo } = auth
|
||||
if (addTo === "Headers") {
|
||||
finalHeaders[key] = value
|
||||
} else if (addTo === "Query params") {
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
.filter((item) => item.active && item.key !== "")
|
||||
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "POST" as const,
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: JSON.stringify({
|
||||
query,
|
||||
variables: parsedVariables,
|
||||
}),
|
||||
params: {
|
||||
...params,
|
||||
},
|
||||
}
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
const result = await interceptorService.runRequest(reqOptions).response
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error(result.left)
|
||||
throw new Error(result.left.toString())
|
||||
}
|
||||
|
||||
const res = result.right
|
||||
|
||||
// HACK: Temporary trailing null character issue from the extension fix
|
||||
const responseText = new TextDecoder("utf-8")
|
||||
.decode(res.data as any)
|
||||
.replace(/\0+$/, "")
|
||||
|
||||
return responseText
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export type HoppAction =
|
||||
| "history.clear" // Clear REST History
|
||||
| "user.login" // Login to Hoppscotch
|
||||
| "user.logout" // Log out of Hoppscotch
|
||||
| "editor.format" // Format editor content
|
||||
|
||||
/**
|
||||
* Defines the arguments, if present for a given type that is required to be passed on
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EditorState, Range } from "@codemirror/state"
|
||||
import { Decoration, ViewPlugin } from "@codemirror/view"
|
||||
import { syntaxTree } from "@codemirror/language"
|
||||
|
||||
function getOperationDefsPosInEditor(state: EditorState) {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
const defs: Array<{ from: number; to: number }> = []
|
||||
|
||||
tree.iterate({
|
||||
enter({ name, from, to }) {
|
||||
if (name === "OperationDefinition") {
|
||||
defs.push({ from, to })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
function generateSelectedOpDecors(state: EditorState) {
|
||||
const selectedPos = state.selection.main.head // Cursor Pos
|
||||
|
||||
const defsPositions = getOperationDefsPosInEditor(state)
|
||||
|
||||
if (defsPositions.length === 1) return Decoration.none
|
||||
|
||||
const decors = defsPositions
|
||||
.map(({ from, to }) => ({
|
||||
selected: selectedPos >= from && selectedPos <= to,
|
||||
from,
|
||||
to,
|
||||
}))
|
||||
.map((info) => ({
|
||||
...info,
|
||||
decor: Decoration.mark({
|
||||
class: info.selected
|
||||
? "gql-operation-highlight"
|
||||
: "gql-operation-not-highlight",
|
||||
inclusive: true,
|
||||
}),
|
||||
}))
|
||||
.map(({ from, to, decor }) => <Range<Decoration>>{ from, to, value: decor }) // Convert to Range<Decoration> (Range from "@codemirror/view")
|
||||
|
||||
return Decoration.set(decors)
|
||||
}
|
||||
|
||||
export const selectedGQLOpHighlight = ViewPlugin.define(
|
||||
(view) => ({
|
||||
decorations: generateSelectedOpDecors(view.state),
|
||||
update(u) {
|
||||
this.decorations = generateSelectedOpDecors(u.state)
|
||||
},
|
||||
}),
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
)
|
||||
390
packages/hoppscotch-common/src/helpers/graphql/connection.ts
Normal file
390
packages/hoppscotch-common/src/helpers/graphql/connection.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { GQLHeader, HoppGQLAuth, makeGQLRequest } from "@hoppscotch/data"
|
||||
import { OperationType } from "@urql/core"
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLInterfaceType,
|
||||
GraphQLObjectType,
|
||||
GraphQLSchema,
|
||||
buildClientSchema,
|
||||
getIntrospectionQuery,
|
||||
printSchema,
|
||||
} from "graphql"
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
|
||||
import { currentTabID } from "./tab"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
const GQL_SCHEMA_POLL_INTERVAL = 7000
|
||||
|
||||
type RunQueryOptions = {
|
||||
name?: string
|
||||
url: string
|
||||
headers: GQLHeader[]
|
||||
query: string
|
||||
variables: string
|
||||
auth: HoppGQLAuth
|
||||
operationName: string | undefined
|
||||
operationType: OperationType
|
||||
}
|
||||
|
||||
export type GQLResponseEvent = {
|
||||
time: number
|
||||
operationName: string | undefined
|
||||
operationType: OperationType
|
||||
data: string
|
||||
rawQuery?: RunQueryOptions
|
||||
}
|
||||
|
||||
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
|
||||
export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED"
|
||||
|
||||
const GQL = {
|
||||
CONNECTION_INIT: "connection_init",
|
||||
CONNECTION_ACK: "connection_ack",
|
||||
CONNECTION_ERROR: "connection_error",
|
||||
CONNECTION_KEEP_ALIVE: "ka",
|
||||
START: "start",
|
||||
STOP: "stop",
|
||||
CONNECTION_TERMINATE: "connection_terminate",
|
||||
DATA: "data",
|
||||
ERROR: "error",
|
||||
COMPLETE: "complete",
|
||||
}
|
||||
|
||||
type Connection = {
|
||||
state: ConnectionState
|
||||
subscriptionState: Map<string, SubscriptionState>
|
||||
socket: WebSocket | undefined
|
||||
schema: GraphQLSchema | null
|
||||
}
|
||||
|
||||
export const connection = reactive<Connection>({
|
||||
state: "DISCONNECTED",
|
||||
subscriptionState: new Map<string, SubscriptionState>(),
|
||||
socket: undefined,
|
||||
schema: null,
|
||||
})
|
||||
|
||||
export const schema = computed(() => connection.schema)
|
||||
export const subscriptionState = computed(() => {
|
||||
return connection.subscriptionState.get(currentTabID.value)
|
||||
})
|
||||
|
||||
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
|
||||
|
||||
export const schemaString = computed(() => {
|
||||
if (!connection.schema) return ""
|
||||
|
||||
return printSchema(connection.schema, {
|
||||
commentDescriptions: true,
|
||||
})
|
||||
})
|
||||
|
||||
export const queryFields = computed(() => {
|
||||
if (!connection.schema) return []
|
||||
|
||||
const fields = connection.schema.getQueryType()?.getFields()
|
||||
if (!fields) return []
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
|
||||
export const mutationFields = computed(() => {
|
||||
if (!connection.schema) return []
|
||||
|
||||
const fields = connection.schema.getMutationType()?.getFields()
|
||||
if (!fields) return []
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
|
||||
export const subscriptionFields = computed(() => {
|
||||
if (!connection.schema) return []
|
||||
|
||||
const fields = connection.schema.getSubscriptionType()?.getFields()
|
||||
if (!fields) return []
|
||||
|
||||
return Object.values(fields)
|
||||
})
|
||||
|
||||
export const graphqlTypes = computed(() => {
|
||||
if (!connection.schema) return []
|
||||
|
||||
const typeMap = connection.schema.getTypeMap()
|
||||
|
||||
const queryTypeName = connection.schema.getQueryType()?.name ?? ""
|
||||
const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
|
||||
const subscriptionTypeName =
|
||||
connection.schema.getSubscriptionType()?.name ?? ""
|
||||
|
||||
return Object.values(typeMap).filter((type) => {
|
||||
return (
|
||||
!type.name.startsWith("__") &&
|
||||
![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
|
||||
type.name
|
||||
) &&
|
||||
(type instanceof GraphQLObjectType ||
|
||||
type instanceof GraphQLInputObjectType ||
|
||||
type instanceof GraphQLEnumType ||
|
||||
type instanceof GraphQLInterfaceType)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
let timeoutSubscription: any
|
||||
|
||||
export const connect = (url: string, headers: GQLHeader[]) => {
|
||||
if (connection.state === "CONNECTED") {
|
||||
throw new Error(
|
||||
"A connection is already running. Close it before starting another."
|
||||
)
|
||||
}
|
||||
|
||||
// Polling
|
||||
connection.state = "CONNECTED"
|
||||
|
||||
const poll = async () => {
|
||||
await getSchema(url, headers)
|
||||
timeoutSubscription = setTimeout(() => {
|
||||
poll()
|
||||
}, GQL_SCHEMA_POLL_INTERVAL)
|
||||
}
|
||||
poll()
|
||||
}
|
||||
|
||||
export const disconnect = () => {
|
||||
if (connection.state !== "CONNECTED") {
|
||||
throw new Error("No connections are running to be disconnected")
|
||||
}
|
||||
|
||||
clearTimeout(timeoutSubscription)
|
||||
connection.state = "DISCONNECTED"
|
||||
}
|
||||
|
||||
export const reset = () => {
|
||||
if (connection.state === "CONNECTED") disconnect()
|
||||
|
||||
connection.state = "DISCONNECTED"
|
||||
connection.schema = null
|
||||
}
|
||||
|
||||
const getSchema = async (url: string, headers: GQLHeader[]) => {
|
||||
try {
|
||||
const introspectionQuery = JSON.stringify({
|
||||
query: getIntrospectionQuery(),
|
||||
})
|
||||
|
||||
const finalHeaders: Record<string, string> = {}
|
||||
headers
|
||||
.filter((x) => x.active && x.key !== "")
|
||||
.forEach((x) => (finalHeaders[x.key] = x.value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: introspectionQuery,
|
||||
}
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
const res = await interceptorService.runRequest(reqOptions).response
|
||||
|
||||
if (E.isLeft(res)) {
|
||||
console.error(res.left)
|
||||
throw new Error(res.left.toString())
|
||||
}
|
||||
|
||||
const data = res.right
|
||||
|
||||
// HACK : Temporary trailing null character issue from the extension fix
|
||||
const response = new TextDecoder("utf-8")
|
||||
.decode(data.data as any)
|
||||
.replace(/\0+$/, "")
|
||||
|
||||
const introspectResponse = JSON.parse(response)
|
||||
|
||||
const schema = buildClientSchema(introspectResponse.data)
|
||||
|
||||
connection.schema = schema
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export const runGQLOperation = async (options: RunQueryOptions) => {
|
||||
const { url, headers, query, variables, auth, operationName, operationType } =
|
||||
options
|
||||
|
||||
const finalHeaders: Record<string, string> = {}
|
||||
|
||||
const parsedVariables = JSON.parse(variables || "{}")
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (auth.authActive) {
|
||||
if (auth.authType === "basic") {
|
||||
const username = auth.username
|
||||
const password = auth.password
|
||||
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
|
||||
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
|
||||
finalHeaders.Authorization = `Bearer ${auth.token}`
|
||||
} else if (auth.authType === "api-key") {
|
||||
const { key, value, addTo } = auth
|
||||
if (addTo === "Headers") {
|
||||
finalHeaders[key] = value
|
||||
} else if (addTo === "Query params") {
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
.filter((item) => item.active && item.key !== "")
|
||||
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
||||
|
||||
const reqOptions = {
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
...finalHeaders,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
data: JSON.stringify({
|
||||
query,
|
||||
variables: parsedVariables,
|
||||
operationName,
|
||||
}),
|
||||
params: {
|
||||
...params,
|
||||
},
|
||||
}
|
||||
|
||||
if (operationType === "subscription") {
|
||||
return runSubscription(options)
|
||||
}
|
||||
|
||||
const interceptorService = getService(InterceptorService)
|
||||
const result = await interceptorService.runRequest(reqOptions).response
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error(result.left)
|
||||
throw new Error(result.left.toString())
|
||||
}
|
||||
|
||||
const res = result.right
|
||||
|
||||
// HACK: Temporary trailing null character issue from the extension fix
|
||||
const responseText = new TextDecoder("utf-8")
|
||||
.decode(res.data as any)
|
||||
.replace(/\0+$/, "")
|
||||
|
||||
gqlMessageEvent.value = {
|
||||
time: Date.now(),
|
||||
operationName: operationName ?? "query",
|
||||
data: responseText,
|
||||
rawQuery: options,
|
||||
operationType,
|
||||
}
|
||||
|
||||
addQueryToHistory(options, responseText)
|
||||
|
||||
return responseText
|
||||
}
|
||||
|
||||
export const runSubscription = (options: RunQueryOptions) => {
|
||||
const { url, query, operationName } = options
|
||||
const wsUrl = url.replace(/^http/, "ws")
|
||||
|
||||
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBING")
|
||||
|
||||
connection.socket = new WebSocket(wsUrl, "graphql-ws")
|
||||
|
||||
connection.socket.onopen = (event) => {
|
||||
console.log("WebSocket is open now.", event)
|
||||
connection.socket?.send(
|
||||
JSON.stringify({
|
||||
type: GQL.CONNECTION_INIT,
|
||||
payload: {},
|
||||
})
|
||||
)
|
||||
|
||||
connection.socket?.send(
|
||||
JSON.stringify({
|
||||
type: GQL.START,
|
||||
id: "1",
|
||||
payload: { query, operationName },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
gqlMessageEvent.value = "reset"
|
||||
|
||||
connection.socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
switch (data.type) {
|
||||
case GQL.CONNECTION_ACK: {
|
||||
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBED")
|
||||
break
|
||||
}
|
||||
case GQL.CONNECTION_ERROR: {
|
||||
console.error(data.payload)
|
||||
break
|
||||
}
|
||||
case GQL.CONNECTION_KEEP_ALIVE: {
|
||||
break
|
||||
}
|
||||
case GQL.DATA: {
|
||||
gqlMessageEvent.value = {
|
||||
time: Date.now(),
|
||||
operationName,
|
||||
data: JSON.stringify(data.payload),
|
||||
operationType: "subscription",
|
||||
}
|
||||
break
|
||||
}
|
||||
case GQL.COMPLETE: {
|
||||
console.log("completed", data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.socket.onclose = (event) => {
|
||||
console.log("WebSocket is closed now.", event)
|
||||
connection.subscriptionState.set(currentTabID.value, "UNSUBSCRIBED")
|
||||
}
|
||||
|
||||
addQueryToHistory(options, "")
|
||||
|
||||
return connection.socket
|
||||
}
|
||||
|
||||
export const socketDisconnect = () => {
|
||||
connection.socket?.close()
|
||||
}
|
||||
|
||||
const addQueryToHistory = (options: RunQueryOptions, response: string) => {
|
||||
const { name, url, headers, query, variables, auth } = options
|
||||
addGraphqlHistoryEntry(
|
||||
makeGQLHistoryEntry({
|
||||
request: makeGQLRequest({
|
||||
name: name ?? "Untitled Request",
|
||||
url,
|
||||
query,
|
||||
headers,
|
||||
variables,
|
||||
auth,
|
||||
}),
|
||||
response,
|
||||
star: false,
|
||||
})
|
||||
)
|
||||
}
|
||||
33
packages/hoppscotch-common/src/helpers/graphql/default.ts
Normal file
33
packages/hoppscotch-common/src/helpers/graphql/default.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { parse, print } from "graphql"
|
||||
import { HoppGQLRequest, GQL_REQ_SCHEMA_VERSION } from "@hoppscotch/data"
|
||||
|
||||
const DEFAULT_QUERY = print(
|
||||
parse(
|
||||
`
|
||||
query Request {
|
||||
method
|
||||
url
|
||||
headers {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ allowLegacyFragmentVariables: true }
|
||||
)
|
||||
)
|
||||
|
||||
export const getDefaultGQLRequest = (): HoppGQLRequest => ({
|
||||
v: GQL_REQ_SCHEMA_VERSION,
|
||||
name: "Untitled",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
headers: [],
|
||||
variables: `{
|
||||
"id": "1"
|
||||
}`,
|
||||
query: DEFAULT_QUERY,
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
})
|
||||
58
packages/hoppscotch-common/src/helpers/graphql/document.ts
Normal file
58
packages/hoppscotch-common/src/helpers/graphql/document.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
|
||||
export type HoppGQLSaveContext =
|
||||
| {
|
||||
/**
|
||||
* The origin source of the request
|
||||
*/
|
||||
originLocation: "user-collection"
|
||||
/**
|
||||
* Path to the request folder
|
||||
*/
|
||||
folderPath: string
|
||||
/**
|
||||
* Index to the request
|
||||
*/
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* The origin source of the request
|
||||
*/
|
||||
originLocation: "team-collection"
|
||||
/**
|
||||
* ID of the request in the team
|
||||
*/
|
||||
requestID: string
|
||||
/**
|
||||
* ID of the team
|
||||
*/
|
||||
teamID?: string
|
||||
/**
|
||||
* ID of the collection loaded
|
||||
*/
|
||||
collectionID?: string
|
||||
}
|
||||
| null
|
||||
|
||||
/**
|
||||
* Defines a live 'document' (something that is open and being edited) in the app
|
||||
*/
|
||||
export type HoppGQLDocument = {
|
||||
/**
|
||||
* The request as it is in the document
|
||||
*/
|
||||
request: HoppGQLRequest
|
||||
|
||||
/**
|
||||
* Whether the request has any unsaved changes
|
||||
* (atleast as far as we can say)
|
||||
*/
|
||||
isDirty: boolean
|
||||
|
||||
/**
|
||||
* Info about where this request should be saved.
|
||||
* This contains where the request is originated from basically.
|
||||
*/
|
||||
saveContext?: HoppGQLSaveContext
|
||||
}
|
||||
52
packages/hoppscotch-common/src/helpers/graphql/eq.ts
Normal file
52
packages/hoppscotch-common/src/helpers/graphql/eq.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as Eq from "fp-ts/Eq"
|
||||
import * as S from "fp-ts/string"
|
||||
import isEqual from "lodash-es/isEqual"
|
||||
|
||||
/*
|
||||
* Eq-s are fp-ts an interface (type class) that defines how the equality
|
||||
* of 2 values of a certain type are matched as equal
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an Eq from a non-undefinable value and makes it accept undefined
|
||||
* @param eq The non nullable Eq to add to
|
||||
* @returns The updated Eq which accepts undefined
|
||||
*/
|
||||
export const undefinedEq = <T>(eq: Eq.Eq<T>): Eq.Eq<T | undefined> => ({
|
||||
equals(x: T | undefined, y: T | undefined) {
|
||||
if (x !== undefined && y !== undefined) {
|
||||
return eq.equals(x, y)
|
||||
}
|
||||
|
||||
return x === undefined && y === undefined
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* An Eq which compares by transforming based on a mapping function and then applying the Eq to it
|
||||
* @param map The mapping function to map values to
|
||||
* @param eq The Eq which takes the value which the map returns
|
||||
* @returns An Eq which takes the input of the mapping function
|
||||
*/
|
||||
export const mapThenEq = <A, B>(map: (x: A) => B, eq: Eq.Eq<B>): Eq.Eq<A> => ({
|
||||
equals(x: A, y: A) {
|
||||
return eq.equals(map(x), map(y))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* An Eq which checks equality of 2 string in a case insensitive way
|
||||
*/
|
||||
export const stringCaseInsensitiveEq: Eq.Eq<string> = mapThenEq(
|
||||
S.toLowerCase,
|
||||
S.Eq
|
||||
)
|
||||
|
||||
/**
|
||||
* An Eq that does equality check with Lodash's isEqual function
|
||||
*/
|
||||
export const lodashIsEqualEq: Eq.Eq<any> = {
|
||||
equals(x: any, y: any) {
|
||||
return isEqual(x, y)
|
||||
},
|
||||
}
|
||||
54
packages/hoppscotch-common/src/helpers/graphql/index.ts
Normal file
54
packages/hoppscotch-common/src/helpers/graphql/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { HoppGQLRequest, ValidContentTypes } from "@hoppscotch/data"
|
||||
import * as Eq from "fp-ts/Eq"
|
||||
import * as N from "fp-ts/number"
|
||||
import * as S from "fp-ts/string"
|
||||
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "./eq"
|
||||
|
||||
export type HoppGQLParam = {
|
||||
key: string
|
||||
value: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type HoppGQLHeader = {
|
||||
key: string
|
||||
value: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type FormDataKeyValue = {
|
||||
key: string
|
||||
active: boolean
|
||||
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
|
||||
|
||||
export type HoppGQLReqBodyFormData = {
|
||||
contentType: "multipart/form-data"
|
||||
body: FormDataKeyValue[]
|
||||
}
|
||||
|
||||
export type HoppGQLReqBody =
|
||||
| {
|
||||
contentType: Exclude<ValidContentTypes, "multipart/form-data">
|
||||
body: string
|
||||
}
|
||||
| HoppGQLReqBodyFormData
|
||||
| {
|
||||
contentType: null
|
||||
body: null
|
||||
}
|
||||
|
||||
export const HoppGQLRequestEq = Eq.struct<HoppGQLRequest>({
|
||||
id: undefinedEq(S.Eq),
|
||||
v: N.Eq,
|
||||
name: S.Eq,
|
||||
url: S.Eq,
|
||||
headers: mapThenEq(
|
||||
(arr) => arr.filter((h) => h.key !== "" && h.value !== ""),
|
||||
lodashIsEqualEq
|
||||
),
|
||||
query: S.Eq,
|
||||
variables: S.Eq,
|
||||
auth: lodashIsEqualEq,
|
||||
})
|
||||
|
||||
export const isEqualHoppGQLRequest = HoppGQLRequestEq.equals
|
||||
199
packages/hoppscotch-common/src/helpers/graphql/tab.ts
Normal file
199
packages/hoppscotch-common/src/helpers/graphql/tab.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { refWithControl } from "@vueuse/core"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { v4 as uuidV4 } from "uuid"
|
||||
import { computed, reactive, ref, shallowReadonly, watch } from "vue"
|
||||
import { HoppTestResult } from "../types/HoppTestResult"
|
||||
import { GQLResponseEvent } from "./connection"
|
||||
import { getDefaultGQLRequest } from "./default"
|
||||
import { HoppGQLDocument, HoppGQLSaveContext } from "./document"
|
||||
|
||||
export type HoppGQLTab = {
|
||||
id: string
|
||||
document: HoppGQLDocument
|
||||
response?: GQLResponseEvent[] | null
|
||||
testResults?: HoppTestResult | null
|
||||
}
|
||||
|
||||
export type PersistableGQLTabState = {
|
||||
lastActiveTabID: string
|
||||
orderedDocs: Array<{
|
||||
tabID: string
|
||||
doc: HoppGQLDocument
|
||||
}>
|
||||
}
|
||||
|
||||
export const currentTabID = refWithControl("test", {
|
||||
onBeforeChange(newTabID) {
|
||||
if (!newTabID || !tabMap.has(newTabID)) {
|
||||
console.warn(
|
||||
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
|
||||
)
|
||||
|
||||
// Don't allow change
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const tabMap = reactive(
|
||||
new Map<string, HoppGQLTab>([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
id: "test",
|
||||
document: {
|
||||
request: getDefaultGQLRequest(),
|
||||
isDirty: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
const tabOrdering = ref<string[]>(["test"])
|
||||
|
||||
watch(
|
||||
tabOrdering,
|
||||
(newOrdering) => {
|
||||
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
|
||||
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
export const persistableTabState = computed<PersistableGQLTabState>(() => ({
|
||||
lastActiveTabID: currentTabID.value,
|
||||
orderedDocs: tabOrdering.value.map((tabID) => {
|
||||
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: tab.document,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
|
||||
|
||||
// TODO: Mark this unknown and do validations
|
||||
export function loadTabsFromPersistedState(data: PersistableGQLTabState) {
|
||||
if (data) {
|
||||
tabMap.clear()
|
||||
tabOrdering.value = []
|
||||
|
||||
for (const doc of data.orderedDocs) {
|
||||
tabMap.set(doc.tabID, {
|
||||
id: doc.tabID,
|
||||
document: doc.doc,
|
||||
})
|
||||
|
||||
tabOrdering.value.push(doc.tabID)
|
||||
}
|
||||
|
||||
currentTabID.value = data.lastActiveTabID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the active Tab IDs in order
|
||||
*/
|
||||
export function getActiveTabs() {
|
||||
return shallowReadonly(
|
||||
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
|
||||
)
|
||||
}
|
||||
|
||||
export function getTabRef(tabID: string) {
|
||||
return computed({
|
||||
get() {
|
||||
const result = tabMap.get(tabID)
|
||||
|
||||
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
|
||||
|
||||
return result
|
||||
},
|
||||
set(value) {
|
||||
return tabMap.set(tabID, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function generateNewTabID() {
|
||||
while (true) {
|
||||
const id = uuidV4()
|
||||
|
||||
if (!tabMap.has(id)) return id
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTab(tabUpdate: HoppGQLTab) {
|
||||
if (!tabMap.has(tabUpdate.id)) {
|
||||
console.warn(
|
||||
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
|
||||
)
|
||||
}
|
||||
|
||||
tabMap.set(tabUpdate.id, tabUpdate)
|
||||
}
|
||||
|
||||
export function createNewTab(document: HoppGQLDocument, switchToIt = true) {
|
||||
const id = generateNewTabID()
|
||||
|
||||
const tab: HoppGQLTab = { id, document }
|
||||
|
||||
tabMap.set(id, tab)
|
||||
tabOrdering.value.push(id)
|
||||
|
||||
if (switchToIt) {
|
||||
currentTabID.value = id
|
||||
}
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
export function updateTabOrdering(fromIndex: number, toIndex: number) {
|
||||
tabOrdering.value.splice(
|
||||
toIndex,
|
||||
0,
|
||||
tabOrdering.value.splice(fromIndex, 1)[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function closeTab(tabID: string) {
|
||||
if (!tabMap.has(tabID)) {
|
||||
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (tabOrdering.value.length === 1) {
|
||||
console.warn(
|
||||
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
|
||||
|
||||
tabMap.delete(tabID)
|
||||
}
|
||||
|
||||
export function getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
|
||||
for (const tab of tabMap.values()) {
|
||||
// For `team-collection` request id can be considered unique
|
||||
if (ctx && ctx.originLocation === "team-collection") {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "team-collection" &&
|
||||
tab.document.saveContext.requestID === ctx.requestID
|
||||
) {
|
||||
return getTabRef(tab.id)
|
||||
}
|
||||
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getTabsRefTo(func: (tab: HoppGQLTab) => boolean) {
|
||||
return Array.from(tabMap.values())
|
||||
.filter(func)
|
||||
.map((tab) => getTabRef(tab.id))
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export const bindings: {
|
||||
"ctrl-shift-p": "response.preview.toggle",
|
||||
"ctrl-j": "response.file.download",
|
||||
"ctrl-.": "response.copy",
|
||||
"ctrl-shift-l": "editor.format",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { distinctUntilChanged, pluck } from "rxjs/operators"
|
||||
import {
|
||||
GQLHeader,
|
||||
HoppGQLRequest,
|
||||
makeGQLRequest,
|
||||
HoppGQLAuth,
|
||||
} from "@hoppscotch/data"
|
||||
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
|
||||
import { useStream } from "@composables/stream"
|
||||
|
||||
type GQLSession = {
|
||||
request: HoppGQLRequest
|
||||
schema: string
|
||||
response: string
|
||||
}
|
||||
|
||||
export const defaultGQLSession: GQLSession = {
|
||||
request: makeGQLRequest({
|
||||
name: "Untitled request",
|
||||
url: "https://echo.hoppscotch.io/graphql",
|
||||
headers: [],
|
||||
variables: `{
|
||||
"id": "1"
|
||||
}`,
|
||||
query: `query Request {
|
||||
method
|
||||
url
|
||||
headers {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`,
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
}),
|
||||
schema: "",
|
||||
response: "",
|
||||
}
|
||||
|
||||
const dispatchers = defineDispatchers({
|
||||
setSession(_: GQLSession, { session }: { session: GQLSession }) {
|
||||
return session
|
||||
},
|
||||
setName(curr: GQLSession, { newName }: { newName: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
name: newName,
|
||||
},
|
||||
}
|
||||
},
|
||||
setURL(curr: GQLSession, { newURL }: { newURL: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
url: newURL,
|
||||
},
|
||||
}
|
||||
},
|
||||
setHeaders(curr: GQLSession, { headers }: { headers: GQLHeader[] }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers,
|
||||
},
|
||||
}
|
||||
},
|
||||
addHeader(curr: GQLSession, { header }: { header: GQLHeader }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: [...curr.request.headers, header],
|
||||
},
|
||||
}
|
||||
},
|
||||
removeHeader(curr: GQLSession, { headerIndex }: { headerIndex: number }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: curr.request.headers.filter((_x, i) => i !== headerIndex),
|
||||
},
|
||||
}
|
||||
},
|
||||
updateHeader(
|
||||
curr: GQLSession,
|
||||
{
|
||||
headerIndex,
|
||||
updatedHeader,
|
||||
}: { headerIndex: number; updatedHeader: GQLHeader }
|
||||
) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
headers: curr.request.headers.map((x, i) =>
|
||||
i === headerIndex ? updatedHeader : x
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
setQuery(curr: GQLSession, { newQuery }: { newQuery: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
query: newQuery,
|
||||
},
|
||||
}
|
||||
},
|
||||
setVariables(curr: GQLSession, { newVariables }: { newVariables: string }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
variables: newVariables,
|
||||
},
|
||||
}
|
||||
},
|
||||
setResponse(_: GQLSession, { newResponse }: { newResponse: string }) {
|
||||
return {
|
||||
response: newResponse,
|
||||
}
|
||||
},
|
||||
setAuth(curr: GQLSession, { newAuth }: { newAuth: HoppGQLAuth }) {
|
||||
return {
|
||||
request: {
|
||||
...curr.request,
|
||||
auth: newAuth,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const gqlSessionStore = new DispatchingStore(
|
||||
defaultGQLSession,
|
||||
dispatchers
|
||||
)
|
||||
|
||||
export function setGQLURL(newURL: string) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setURL",
|
||||
payload: {
|
||||
newURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setGQLHeaders(headers: GQLHeader[]) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setHeaders",
|
||||
payload: {
|
||||
headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function addGQLHeader(header: GQLHeader) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "addHeader",
|
||||
payload: {
|
||||
header,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function updateGQLHeader(headerIndex: number, updatedHeader: GQLHeader) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "updateHeader",
|
||||
payload: {
|
||||
headerIndex,
|
||||
updatedHeader,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function removeGQLHeader(headerIndex: number) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "removeHeader",
|
||||
payload: {
|
||||
headerIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function clearGQLHeaders() {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setHeaders",
|
||||
payload: {
|
||||
headers: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setGQLQuery(newQuery: string) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setQuery",
|
||||
payload: {
|
||||
newQuery,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setGQLVariables(newVariables: string) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setVariables",
|
||||
payload: {
|
||||
newVariables,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setGQLResponse(newResponse: string) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setResponse",
|
||||
payload: {
|
||||
newResponse,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getGQLSession() {
|
||||
return gqlSessionStore.value
|
||||
}
|
||||
|
||||
export function setGQLSession(session: GQLSession) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setSession",
|
||||
payload: {
|
||||
session,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGQLRequestName() {
|
||||
return useStream(gqlName$, gqlSessionStore.value.request.name, (newName) => {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setName",
|
||||
payload: { newName },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setGQLAuth(newAuth: HoppGQLAuth) {
|
||||
gqlSessionStore.dispatch({
|
||||
dispatcher: "setAuth",
|
||||
payload: {
|
||||
newAuth,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const gqlName$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("request", "name"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
export const gqlURL$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("request", "url"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
export const gqlQuery$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("request", "query"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
export const gqlVariables$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("request", "variables"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
export const gqlHeaders$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("request", "headers"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const gqlResponse$ = gqlSessionStore.subject$.pipe(
|
||||
pluck("response"),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
export const gqlAuth$ = gqlSessionStore.subject$.pipe(pluck("request", "auth"))
|
||||
@@ -48,8 +48,10 @@ import {
|
||||
loadTabsFromPersistedState,
|
||||
persistableTabState,
|
||||
} from "~/helpers/rest/tab"
|
||||
import { debounceTime } from "rxjs"
|
||||
import { gqlSessionStore, setGQLSession } from "./GQLSession"
|
||||
import {
|
||||
loadTabsFromPersistedState as loadGQLTabsFromPersistedState,
|
||||
persistableTabState as persistableGQLTabState,
|
||||
} from "~/helpers/graphql/tab"
|
||||
|
||||
function checkAndMigrateOldSettings() {
|
||||
if (window.localStorage.getItem("selectedEnvIndex")) {
|
||||
@@ -340,26 +342,27 @@ export function setupRESTTabsPersistence() {
|
||||
)
|
||||
}
|
||||
|
||||
// temporary persistence for GQL session
|
||||
export function setupGQLPersistence() {
|
||||
function setupGQLTabsPersistence() {
|
||||
try {
|
||||
const state = window.localStorage.getItem("gqlState")
|
||||
const state = window.localStorage.getItem("gqlTabState")
|
||||
if (state) {
|
||||
const data = JSON.parse(state)
|
||||
data["schema"] = ""
|
||||
data["response"] = ""
|
||||
setGQLSession(data)
|
||||
loadGQLTabsFromPersistedState(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed parsing persisted GraphQL state, state:`,
|
||||
window.localStorage.getItem("gqlState")
|
||||
`Failed parsing persisted tab state, state:`,
|
||||
window.localStorage.getItem("gqlTabState")
|
||||
)
|
||||
}
|
||||
|
||||
gqlSessionStore.subject$.pipe(debounceTime(500)).subscribe((state) => {
|
||||
window.localStorage.setItem("gqlState", JSON.stringify(state))
|
||||
})
|
||||
watchDebounced(
|
||||
persistableGQLTabState,
|
||||
(state) => {
|
||||
window.localStorage.setItem("gqlTabState", JSON.stringify(state))
|
||||
},
|
||||
{ debounce: 500, deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function setupLocalPersistence() {
|
||||
@@ -368,7 +371,9 @@ export function setupLocalPersistence() {
|
||||
setupLocalStatePersistence()
|
||||
setupSettingsPersistence()
|
||||
setupRESTTabsPersistence()
|
||||
setupGQLPersistence()
|
||||
|
||||
setupGQLTabsPersistence()
|
||||
|
||||
setupHistoryPersistence()
|
||||
setupCollectionsPersistence()
|
||||
setupGlobalEnvsPersistence()
|
||||
|
||||
@@ -1,48 +1,161 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="graphql">
|
||||
<template #primary>
|
||||
<GraphqlRequest :conn="gqlConn" />
|
||||
<GraphqlRequestOptions :conn="gqlConn" />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<GraphqlResponse :conn="gqlConn" />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<GraphqlSidebar :conn="gqlConn" />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
<div>
|
||||
<AppPaneLayout layout-id="graphql">
|
||||
<template #primary>
|
||||
<GraphqlRequest />
|
||||
|
||||
<HoppSmartWindows
|
||||
v-if="currentTabID"
|
||||
:id="'gql_windows'"
|
||||
v-model="currentTabID"
|
||||
@remove-tab="removeTab"
|
||||
@add-tab="addNewTab"
|
||||
@sort="sortTabs"
|
||||
>
|
||||
<HoppSmartWindow
|
||||
v-for="tab in tabs"
|
||||
:id="tab.id"
|
||||
:key="'removable_tab_' + tab.id"
|
||||
:label="tab.document.request.name"
|
||||
:is-removable="tabs.length > 1"
|
||||
:close-visibility="'hover'"
|
||||
>
|
||||
<template #tabhead>
|
||||
<div
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
||||
:title="tab.document.request.name"
|
||||
class="truncate px-2"
|
||||
>
|
||||
<span class="leading-8 px-2">
|
||||
{{ tab.document.request.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #suffix>
|
||||
<span
|
||||
v-if="tab.document.isDirty"
|
||||
class="flex items-center justify-center text-secondary group-hover:hidden w-4"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
class="h-1.5 w-1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="12" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<GraphqlRequestTab
|
||||
:model-value="tab"
|
||||
@update:model-value="onTabUpdate"
|
||||
/>
|
||||
</HoppSmartWindow>
|
||||
</HoppSmartWindows>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<GraphqlSidebar />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmingCloseForTabID !== null"
|
||||
:confirm="t('modal.close_unsaved_tab')"
|
||||
:title="t('confirm.close_unsaved_tab')"
|
||||
@hide-modal="onCloseConfirm"
|
||||
@resolve="onResolveConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageHead } from "@composables/head"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { GQLConnection } from "@helpers/GQLConnection"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { computed, onBeforeUnmount } from "vue"
|
||||
import { useService } from "dioc/vue"
|
||||
import { computed, onBeforeUnmount, ref } from "vue"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||
import { connection, disconnect } from "~/helpers/graphql/connection"
|
||||
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
|
||||
import {
|
||||
HoppGQLTab,
|
||||
closeTab,
|
||||
createNewTab,
|
||||
currentTabID,
|
||||
getActiveTabs,
|
||||
getTabRef,
|
||||
updateTab,
|
||||
updateTabOrdering,
|
||||
} from "~/helpers/graphql/tab"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const inspectionService = useService(InspectionService)
|
||||
|
||||
const confirmingCloseForTabID = ref<string | null>(null)
|
||||
|
||||
usePageHead({
|
||||
title: computed(() => t("navigation.graphql")),
|
||||
})
|
||||
|
||||
const gqlConn = new GQLConnection()
|
||||
const tabs = getActiveTabs()
|
||||
|
||||
const addNewTab = () => {
|
||||
const tab = createNewTab({
|
||||
request: getDefaultGQLRequest(),
|
||||
isDirty: false,
|
||||
})
|
||||
|
||||
currentTabID.value = tab.id
|
||||
}
|
||||
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
|
||||
updateTabOrdering(e.oldIndex, e.newIndex)
|
||||
}
|
||||
|
||||
const removeTab = (tabID: string) => {
|
||||
const tabState = getTabRef(tabID).value
|
||||
|
||||
if (tabState.document.isDirty) {
|
||||
confirmingCloseForTabID.value = tabID
|
||||
} else {
|
||||
closeTab(tabState.id)
|
||||
inspectionService.deleteTabInspectorResult(tabState.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is closed when the confirm tab is closed by some means (even saving triggers close)
|
||||
*/
|
||||
const onCloseConfirm = () => {
|
||||
confirmingCloseForTabID.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user confirms they want to save the tab
|
||||
*/
|
||||
const onResolveConfirm = () => {
|
||||
if (confirmingCloseForTabID.value) {
|
||||
closeTab(confirmingCloseForTabID.value)
|
||||
confirmingCloseForTabID.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const onTabUpdate = (tab: HoppGQLTab) => {
|
||||
updateTab(tab)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gqlConn.connected$.value) {
|
||||
gqlConn.disconnect()
|
||||
if (connection.state === "CONNECTED") {
|
||||
disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
defineActionHandler("gql.request.open", ({ request }) => {
|
||||
const session = getGQLSession()
|
||||
|
||||
setGQLSession({
|
||||
request: cloneDeep(request),
|
||||
schema: session.schema,
|
||||
response: session.response,
|
||||
createNewTab({
|
||||
request: request,
|
||||
isDirty: false,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
:label="tab.name"
|
||||
:is-removable="tab.removable"
|
||||
>
|
||||
<template #icon>
|
||||
<template #prefix>
|
||||
<icon-lucide-rss
|
||||
:style="{
|
||||
color: tab.color,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SpotlightService } from "../.."
|
||||
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
|
||||
import { defaultGQLSession } from "~/newstore/GQLSession"
|
||||
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
|
||||
|
||||
async function flushPromises() {
|
||||
return await new Promise((r) => setTimeout(r))
|
||||
@@ -230,7 +230,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
...getDefaultGQLRequest(),
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
@@ -267,7 +267,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
const historyEntry: GQLHistoryEntry = {
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
...getDefaultGQLRequest(),
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
@@ -302,7 +302,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
...getDefaultGQLRequest(),
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
@@ -351,7 +351,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
...getDefaultGQLRequest(),
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
@@ -398,7 +398,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
it("none of the history entries are show when neither of the open actions are registered", async () => {
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
...getDefaultGQLRequest(),
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
|
||||
@@ -16,6 +16,7 @@ import IconFolder from "~icons/lucide/folder"
|
||||
import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue"
|
||||
import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { createNewTab as createNewGQLTab } from "~/helpers/graphql/tab"
|
||||
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
|
||||
import { currentTabID } from "~/helpers/rest/tab"
|
||||
import {
|
||||
@@ -23,8 +24,6 @@ import {
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { setGQLSession } from "~/newstore/GQLSession"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { hoppWorkspaceStore } from "~/newstore/workspace"
|
||||
import { changeWorkspace } from "~/newstore/workspace"
|
||||
|
||||
@@ -305,11 +304,13 @@ export class CollectionsSpotlightSearcherService
|
||||
|
||||
if (!req) return
|
||||
|
||||
setGQLSession({
|
||||
request: cloneDeep(req),
|
||||
schema: "",
|
||||
response: "",
|
||||
})
|
||||
createNewGQLTab(
|
||||
{
|
||||
request: req,
|
||||
isDirty: false,
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user