feat: cleaner save context handling for graphql (#3282)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Andrew Bastin
2023-08-24 19:07:17 +05:30
committed by GitHub
parent a9a4ebf595
commit b08b63dc73
16 changed files with 365 additions and 42 deletions

View File

@@ -80,6 +80,7 @@ declare module 'vue' {
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default']
GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default']
@@ -89,7 +90,7 @@ declare module 'vue' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import("@hoppscotch/ui")["HoppSmartAutoComplete"]
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
@@ -151,6 +152,7 @@ declare module 'vue' {
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']

View File

@@ -37,6 +37,7 @@
@click="
emit('add-request', {
path: `${collectionIndex}`,
index: collection.requests.length,
})
"
/>
@@ -219,6 +220,7 @@ import {
moveGraphqlRequest,
} from "~/newstore/collections"
import { Picked } from "~/helpers/types/HoppPicked"
import { getTabsRefTo } from "~/helpers/graphql/tab"
const props = defineProps({
picked: { type: Object, default: null },
@@ -293,6 +295,22 @@ const removeCollection = () => {
emit("select", null)
}
const possibleTabs = getTabsRefTo((tab) => {
const ctx = tab.document.saveContext
if (!ctx) return false
return (
ctx.originLocation === "user-collection" &&
ctx.folderPath.startsWith(props.collectionIndex.toString())
)
})
for (const tab of possibleTabs) {
tab.value.document.saveContext = undefined
tab.value.document.isDirty = true
}
removeGraphqlCollection(props.collectionIndex, props.collection.id)
toast.success(`${t("state.deleted")}`)
}

View File

@@ -34,7 +34,12 @@
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-request', { path: folderPath })"
@click="
emit('add-request', {
path: folderPath,
index: folder.requests.length,
})
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -198,6 +203,7 @@ import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections"
import { computed, ref } from "vue"
import { getTabsRefTo } from "~/helpers/graphql/tab"
const toast = useToast()
const t = useI18n()
@@ -249,10 +255,8 @@ const collectionIcon = computed(() => {
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-folder",
folderPath: props.folderPath,
},
pickedType: "gql-my-folder",
folderPath: props.folderPath,
})
}
@@ -273,6 +277,22 @@ const removeFolder = () => {
emit("select", { picked: null })
}
const possibleTabs = getTabsRefTo((tab) => {
const ctx = tab.document.saveContext
if (!ctx) return false
return (
ctx.originLocation === "user-collection" &&
ctx.folderPath.startsWith(props.folderPath)
)
})
for (const tab of possibleTabs) {
tab.value.document.saveContext = undefined
tab.value.document.isDirty = true
}
removeGraphqlFolder(props.folderPath, props.folder.id)
toast.success(t("state.deleted"))
}

View File

@@ -20,22 +20,28 @@
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div class="flex">
<HoppButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
ref="options"
@@ -121,7 +127,6 @@
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFile from "~icons/lucide/file"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
@@ -132,7 +137,12 @@ import { useToast } from "@composables/toast"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { removeGraphqlRequest } from "~/newstore/collections"
import { createNewTab } from "~/helpers/graphql/tab"
import {
createNewTab,
getTabRefWithSaveContext,
currentTabID,
currentActiveTab,
} from "~/helpers/graphql/tab"
// Template refs
const tippyActions = ref<any | null>(null)
@@ -154,6 +164,18 @@ const props = defineProps({
requestIndex: { type: Number, default: null },
})
const isActive = computed(() => {
const saveCtx = currentActiveTab.value?.document.saveContext
if (!saveCtx) return false
return (
saveCtx.originLocation === "user-collection" &&
saveCtx.folderPath === props.folderPath &&
saveCtx.requestIndex === props.requestIndex
)
})
// TODO: Better types please
const emit = defineEmits(["select", "edit-request", "duplicate-request"])
@@ -179,7 +201,24 @@ const selectRequest = () => {
if (props.saveRequest) {
pick()
} else {
const possibleTab = getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
})
// Switch to that request if that request is open
if (possibleTab) {
currentTabID.value = possibleTab.value.id
return
}
createNewTab({
saveContext: {
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
},
request: cloneDeep(
makeGQLRequest({
name: props.request.name,
@@ -213,6 +252,18 @@ const removeRequest = () => {
emit("select", null)
}
// Detach the request from any of the tabs
const possibleTab = getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
})
if (possibleTab) {
possibleTab.value.document.saveContext = undefined
possibleTab.value.document.isDirty = true
}
removeGraphqlRequest(props.folderPath, props.requestIndex, props.request.id)
toast.success(`${t("state.deleted")}`)
}

View File

@@ -265,7 +265,7 @@ export default defineComponent({
this.$data.editingCollectionIndex = collectionIndex
this.displayModalEdit(true)
},
onAddRequest({ name, path }) {
onAddRequest({ name, path, index }) {
const newRequest = {
...currentActiveTab.value.document.request,
name,
@@ -274,6 +274,11 @@ export default defineComponent({
saveGraphqlRequestAs(path, newRequest)
createNewTab({
saveContext: {
originLocation: "user-collection",
folderPath: path,
requestIndex: index,
},
request: newRequest,
isDirty: false,
})

View File

@@ -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"
class="sticky top-sidebarPrimaryStickyFold z-10 flex items-center justify-between pl-4 border-y bg-primary border-dividerLight"
>
<span class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">

View File

@@ -1,6 +1,6 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
class="sticky top-sidebarPrimaryStickyFold z-10 flex items-center justify-between pl-4 border-y bg-primary border-dividerLight"
>
<label class="font-semibold text-secondaryLight">
{{ t("tab.headers") }}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight gqlRunQuery"
class="sticky top-sidebarPrimaryStickyFold z-10 flex items-center justify-between pl-4 border-y bg-primary border-dividerLight"
>
<label class="font-semibold text-secondaryLight">
{{ t("request.query") }}

View File

@@ -2,7 +2,7 @@
<div class="flex flex-col flex-1 h-full">
<HoppSmartTabs
v-model="selectedOptionTab"
styles="sticky bg-primary z-10"
styles="sticky top-0 bg-primary z-10 border-b-0"
:render-inactive-tabs="true"
>
<HoppSmartTab
@@ -67,6 +67,7 @@ import {
} from "~/helpers/graphql/connection"
import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service"
import { editGraphqlRequest } from "~/newstore/collections"
type OptionTabs = "query" | "headers" | "variables" | "authorization"
const selectedOptionTab = ref<OptionTabs>("query")
@@ -180,12 +181,29 @@ const hideRequestModal = () => {
showSaveRequestModal.value = false
}
const saveRequest = () => {
showSaveRequestModal.value = true
if (
currentActiveTab.value.document.saveContext &&
currentActiveTab.value.document.saveContext.originLocation ===
"user-collection"
) {
editGraphqlRequest(
currentActiveTab.value.document.saveContext.folderPath,
currentActiveTab.value.document.saveContext.requestIndex,
currentActiveTab.value.document.request
)
currentActiveTab.value.document.isDirty = false
} else {
showSaveRequestModal.value = true
}
}
const clearGQLQuery = () => {
request.value.query = ""
}
defineActionHandler("request.send-cancel", runQuery)
defineActionHandler("request.save", saveRequest)
defineActionHandler("request.save-as", () => {
showSaveRequestModal.value = true
})
defineActionHandler("request.reset", clearGQLQuery)
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:title="tab.document.request.name"
class="truncate px-2 flex items-center"
@dblclick="emit('open-rename-modal')"
@contextmenu.prevent="options?.tippy?.show()"
@click.middle="emit('close-tab')"
>
<tippy
ref="options"
trigger="manual"
interactive
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<span class="leading-8 px-2 truncate">
{{ tab.document.request.name }}
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="renameAction?.$el.click()"
@keyup.d="duplicateAction?.$el.click()"
@keyup.w="closeAction?.$el.click()"
@keyup.x="closeOthersAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="renameAction"
:icon="IconFileEdit"
:label="t('request.rename')"
:shortcut="['R']"
@click="
() => {
emit('open-rename-modal')
hide()
}
"
/>
<HoppSmartItem
ref="duplicateAction"
:icon="IconCopy"
:label="t('tab.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-tab')
hide()
}
"
/>
<HoppSmartItem
v-if="isRemovable"
ref="closeAction"
:icon="IconXCircle"
:label="t('tab.close')"
:shortcut="['W']"
@click="
() => {
emit('close-tab')
hide()
}
"
/>
<HoppSmartItem
v-if="isRemovable"
ref="closeOthersAction"
:icon="IconXSquare"
:label="t('tab.close_others')"
:shortcut="['X']"
@click="
() => {
emit('close-other-tabs')
hide()
}
"
/>
</div>
</template>
</tippy>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import IconFileEdit from "~icons/lucide/file-edit"
import IconCopy from "~icons/lucide/copy"
import { HoppGQLTab } from "~/helpers/graphql/tab"
const t = useI18n()
defineProps<{
tab: HoppGQLTab
isRemovable: boolean
}>()
const emit = defineEmits<{
(event: "open-rename-modal"): void
(event: "close-tab"): void
(event: "close-other-tabs"): void
(event: "duplicate-tab"): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const renameAction = ref<HTMLButtonElement | null>(null)
const closeAction = ref<HTMLButtonElement | null>(null)
const closeOthersAction = ref<HTMLButtonElement | null>(null)
const duplicateAction = ref<HTMLButtonElement | null>(null)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
class="sticky top-sidebarPrimaryStickyFold z-10 flex items-center justify-between pl-4 border-y bg-primary border-dividerLight"
>
<label class="font-semibold text-secondaryLight">
{{ t("request.variables") }}

View File

@@ -4,7 +4,7 @@
:title="tab.document.request.name"
class="truncate px-2 flex items-center"
@dblclick="emit('open-rename-modal')"
@contextmenu.prevent="options?.tippy.show()"
@contextmenu.prevent="options?.tippy?.show()"
@click.middle="emit('close-tab')"
>
<span

View File

@@ -7,6 +7,7 @@ import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppGQLSaveContext } from "./graphql/document"
export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
@@ -109,6 +110,7 @@ type HoppActionArgsMap = {
"gql.request.open": {
request: HoppGQLRequest
saveContext?: HoppGQLSaveContext
}
"modals.environment.add": {
envName: string

View File

@@ -197,3 +197,30 @@ export function getTabsRefTo(func: (tab: HoppGQLTab) => boolean) {
.filter(func)
.map((tab) => getTabRef(tab.id))
}
export function closeOtherTabs(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(
`The tab to close other tabs does not exist (tab id: ${tabID})`
)
return
}
tabOrdering.value = [tabID]
tabMap.forEach((_, id) => {
if (id !== tabID) tabMap.delete(id)
})
currentTabID.value = tabID
}
export function getDirtyTabsCount() {
let count = 0
for (const tab of tabMap.values()) {
if (tab.document.isDirty) count++
}
return count
}

View File

@@ -21,15 +21,14 @@
: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>
<GraphqlTabHead
:tab="tab"
:is-removable="tabs.length > 1"
@open-rename-modal="openReqRenameModal(tab)"
@close-tab="removeTab(tab.id)"
@close-other-tabs="closeOtherTabsAction(tab.id)"
@duplicate-tab="duplicateTab(tab)"
/>
</template>
<template #suffix>
@@ -59,7 +58,12 @@
<GraphqlSidebar />
</template>
</AppPaneLayout>
<CollectionsEditRequest
v-model="editReqModalReqName"
:show="showRenamingReqNameModalForTabID !== undefined"
@submit="renameReqName"
@hide-modal="showRenamingReqNameModalForTabID = undefined"
/>
<HoppSmartConfirmModal
:show="confirmingCloseForTabID !== null"
:confirm="t('modal.close_unsaved_tab')"
@@ -67,6 +71,13 @@
@hide-modal="onCloseConfirm"
@resolve="onResolveConfirm"
/>
<HoppSmartConfirmModal
:show="confirmingCloseAllTabs"
:confirm="t('modal.close_unsaved_tab')"
:title="t('confirm.close_unsaved_tabs', { count: unsavedTabsCount })"
@hide-modal="confirmingCloseAllTabs = false"
@resolve="onResolveConfirmCloseAllTabs"
/>
</div>
</template>
@@ -80,10 +91,12 @@ import { connection, disconnect } from "~/helpers/graphql/connection"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import {
HoppGQLTab,
closeOtherTabs,
closeTab,
createNewTab,
currentTabID,
getActiveTabs,
getDirtyTabsCount,
getTabRef,
updateTab,
updateTabOrdering,
@@ -142,6 +155,27 @@ const onResolveConfirm = () => {
}
}
const confirmingCloseAllTabs = ref(false)
const unsavedTabsCount = ref(0)
const exceptedTabID = ref<string | null>(null)
const closeOtherTabsAction = (tabID: string) => {
const dirtyTabCount = getDirtyTabsCount()
// If there are dirty tabs, show the confirm modal
if (dirtyTabCount > 0) {
confirmingCloseAllTabs.value = true
unsavedTabsCount.value = dirtyTabCount
exceptedTabID.value = tabID
} else {
closeOtherTabs(tabID)
}
}
const onResolveConfirmCloseAllTabs = () => {
if (exceptedTabID.value) closeOtherTabs(exceptedTabID.value)
confirmingCloseAllTabs.value = false
}
const onTabUpdate = (tab: HoppGQLTab) => {
updateTab(tab)
}
@@ -152,8 +186,34 @@ onBeforeUnmount(() => {
}
})
defineActionHandler("gql.request.open", ({ request }) => {
const editReqModalReqName = ref("")
const showRenamingReqNameModalForTabID = ref<string>()
const openReqRenameModal = (tab: HoppGQLTab) => {
editReqModalReqName.value = tab.document.request.name
showRenamingReqNameModalForTabID.value = tab.id
}
const renameReqName = () => {
const tab = getTabRef(showRenamingReqNameModalForTabID.value!)
if (tab.value) {
tab.value.document.request.name = editReqModalReqName.value
updateTab(tab.value)
}
showRenamingReqNameModalForTabID.value = undefined
}
const duplicateTab = (tab: HoppGQLTab) => {
const newTab = createNewTab({
request: tab.document.request,
isDirty: true,
})
currentTabID.value = newTab.id
}
defineActionHandler("gql.request.open", ({ request, saveContext }) => {
createNewTab({
saveContext,
request: request,
isDirty: false,
})

View File

@@ -304,13 +304,15 @@ export class CollectionsSpotlightSearcherService
if (!req) return
createNewGQLTab(
{
request: req,
isDirty: false,
createNewGQLTab({
saveContext: {
originLocation: "user-collection",
folderPath: folderPath.join("/"),
requestIndex: reqIndex,
},
true
)
request: req,
isDirty: false,
})
}
}
}