chore: split app to commons and web (squash commit)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center px-4 py-2 transition bg-error text-tiny group"
|
||||
role="alert"
|
||||
>
|
||||
<icon-lucide-info class="mr-2" />
|
||||
<span class="text-secondaryDark">
|
||||
<span class="md:hidden">
|
||||
{{ t("helpers.offline_short") }}
|
||||
</span>
|
||||
<span class="<md:hidden">
|
||||
{{ t("helpers.offline") }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('app.developer_option')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<p class="px-2 mb-4 text-secondaryLight">
|
||||
{{ t("app.developer_option_description") }}
|
||||
</p>
|
||||
<div class="flex flex-1">
|
||||
<ButtonSecondary
|
||||
outline
|
||||
filled
|
||||
:icon="copyIcon"
|
||||
:label="t('app.copy_user_id')"
|
||||
@click="copyUserAuthToken"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { authIdToken$ } from "~/helpers/fb/auth"
|
||||
|
||||
const userAuthToken = useReadonlyStream(authIdToken$, null)
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
// Copy user auth token to clipboard
|
||||
const copyUserAuthToken = () => {
|
||||
if (userAuthToken.value) {
|
||||
copyToClipboard(userAuthToken.value)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
} else {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
286
packages/hoppscotch-common/src/components/app/Footer.vue
Normal file
286
packages/hoppscotch-common/src/components/app/Footer.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between bg-primary">
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="EXPAND_NAVIGATION ? t('hide.sidebar') : t('show.sidebar')"
|
||||
:icon="IconSidebar"
|
||||
class="transform"
|
||||
:class="{ '-rotate-180': !EXPAND_NAVIGATION }"
|
||||
@click="EXPAND_NAVIGATION = !EXPAND_NAVIGATION"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${ZEN_MODE ? t('action.turn_off') : t('action.turn_on')} ${t(
|
||||
'layout.zen_mode'
|
||||
)}`"
|
||||
:icon="ZEN_MODE ? IconMinimize : IconMaximize"
|
||||
:class="{
|
||||
'!text-accent !focus-visible:text-accentDark !hover:text-accentDark':
|
||||
ZEN_MODE,
|
||||
}"
|
||||
@click="ZEN_MODE = !ZEN_MODE"
|
||||
/>
|
||||
<tippy interactive trigger="click" theme="popover">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('settings.interceptor')"
|
||||
:icon="IconShieldCheck"
|
||||
/>
|
||||
<template #content>
|
||||
<AppInterceptor />
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
:icon="IconHelpCircle"
|
||||
class="!rounded-none"
|
||||
:label="`${t('app.help')}`"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.d="documentation.$el.click()"
|
||||
@keyup.s="shortcuts.$el.click()"
|
||||
@keyup.c="chat.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="documentation"
|
||||
:icon="IconBook"
|
||||
:label="`${t('app.documentation')}`"
|
||||
to="https://docs.hoppscotch.io"
|
||||
blank
|
||||
:shortcut="['D']"
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="shortcuts"
|
||||
:icon="IconZap"
|
||||
:label="`${t('app.keyboard_shortcuts')}`"
|
||||
:shortcut="['S']"
|
||||
@click="
|
||||
() => {
|
||||
showShortcuts = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="chat"
|
||||
:icon="IconMessageCircle"
|
||||
:label="`${t('app.chat_with_us')}`"
|
||||
:shortcut="['C']"
|
||||
@click="
|
||||
() => {
|
||||
chatWithUs()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconGift"
|
||||
:label="`${t('app.whats_new')}`"
|
||||
to="https://docs.hoppscotch.io/changelog"
|
||||
blank
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconActivity"
|
||||
:label="t('app.status')"
|
||||
to="https://status.hoppscotch.io"
|
||||
blank
|
||||
@click="hide()"
|
||||
/>
|
||||
<hr />
|
||||
<SmartItem
|
||||
:icon="IconGithub"
|
||||
:label="`${t('app.github')}`"
|
||||
to="https://github.com/hoppscotch/hoppscotch"
|
||||
blank
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconTwitter"
|
||||
:label="`${t('app.twitter')}`"
|
||||
to="https://hoppscotch.io/twitter"
|
||||
blank
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconUserPlus"
|
||||
:label="`${t('app.invite')}`"
|
||||
@click="
|
||||
() => {
|
||||
showShare = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconLock"
|
||||
:label="`${t('app.terms_and_privacy')}`"
|
||||
to="https://docs.hoppscotch.io/privacy"
|
||||
blank
|
||||
@click="hide()"
|
||||
/>
|
||||
<div
|
||||
class="flex px-4 py-2 opacity-50"
|
||||
@dblclick="
|
||||
() => {
|
||||
showDeveloperOptionModal()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ `${t("app.name")} v${version}` }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconZap"
|
||||
:title="t('app.shortcuts')"
|
||||
@click="showShortcuts = true"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="navigatorShare"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconShare2"
|
||||
:title="t('request.share')"
|
||||
@click="nativeShare()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="COLUMN_LAYOUT ? t('layout.row') : t('layout.column')"
|
||||
:icon="IconColumns"
|
||||
class="transform"
|
||||
:class="{ 'rotate-90': !COLUMN_LAYOUT }"
|
||||
@click="COLUMN_LAYOUT = !COLUMN_LAYOUT"
|
||||
/>
|
||||
<span
|
||||
class="transition transform"
|
||||
:class="{
|
||||
'rotate-180': SIDEBAR_ON_LEFT,
|
||||
}"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="SIDEBAR ? t('hide.sidebar') : t('show.sidebar')"
|
||||
:icon="IconSidebarOpen"
|
||||
class="transform"
|
||||
:class="{ 'rotate-180': !SIDEBAR }"
|
||||
@click="SIDEBAR = !SIDEBAR"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
|
||||
<AppShare :show="showShare" @hide-modal="showShare = false" />
|
||||
<AppDeveloperOptions
|
||||
:show="showDeveloperOptions"
|
||||
@hide-modal="showDeveloperOptions = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { version } from "~/../package.json"
|
||||
import IconSidebar from "~icons/lucide/sidebar"
|
||||
import IconMinimize from "~icons/lucide/minimize"
|
||||
import IconMaximize from "~icons/lucide/maximize"
|
||||
import IconZap from "~icons/lucide/zap"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import IconColumns from "~icons/lucide/columns"
|
||||
import IconSidebarOpen from "~icons/lucide/sidebar-open"
|
||||
import IconShieldCheck from "~icons/lucide/shield-check"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconBook from "~icons/lucide/book"
|
||||
import IconMessageCircle from "~icons/lucide/message-circle"
|
||||
import IconGift from "~icons/lucide/gift"
|
||||
import IconActivity from "~icons/lucide/activity"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import IconTwitter from "~icons/lucide/twitter"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import IconLock from "~icons/lucide/lock"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { showChat } from "@modules/crisp"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import SmartItem from "@components/smart/Item.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const showShortcuts = ref(false)
|
||||
const showShare = ref(false)
|
||||
const showDeveloperOptions = ref(false)
|
||||
|
||||
defineActionHandler("flyouts.keybinds.toggle", () => {
|
||||
showShortcuts.value = !showShortcuts.value
|
||||
})
|
||||
|
||||
defineActionHandler("modals.share.toggle", () => {
|
||||
showShare.value = !showShare.value
|
||||
})
|
||||
|
||||
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
const SIDEBAR = useSetting("SIDEBAR")
|
||||
const ZEN_MODE = useSetting("ZEN_MODE")
|
||||
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
|
||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
|
||||
const navigatorShare = !!navigator.share
|
||||
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
watch(
|
||||
() => ZEN_MODE.value,
|
||||
() => {
|
||||
EXPAND_NAVIGATION.value = !ZEN_MODE.value
|
||||
}
|
||||
)
|
||||
|
||||
const nativeShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: "Hoppscotch",
|
||||
text: "Hoppscotch • Open source API development ecosystem - Helps you create requests faster, saving precious time on development.",
|
||||
url: "https://hoppscotch.io",
|
||||
})
|
||||
.catch(console.error)
|
||||
} else {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const chatWithUs = () => {
|
||||
showChat()
|
||||
}
|
||||
|
||||
const showDeveloperOptionModal = () => {
|
||||
if (currentUser.value) {
|
||||
showDeveloperOptions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
const documentation = ref<typeof SmartItem | null>(null)
|
||||
const shortcuts = ref<typeof SmartItem | null>(null)
|
||||
const chat = ref<typeof SmartItem | null>(null)
|
||||
</script>
|
||||
70
packages/hoppscotch-common/src/components/app/Fuse.vue
Normal file
70
packages/hoppscotch-common/src/components/app/Fuse.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
|
||||
<div class="flex flex-col">
|
||||
<AppPowerSearchEntry
|
||||
v-for="(shortcut, shortcutIndex) in searchResults"
|
||||
:key="`shortcut-${shortcutIndex}`"
|
||||
:active="shortcutIndex === selectedEntry"
|
||||
:shortcut="shortcut.item"
|
||||
@action="emit('action', shortcut.item.action)"
|
||||
@mouseover="selectedEntry = shortcutIndex"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="searchResults.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ search }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, onMounted } from "vue"
|
||||
import Fuse from "fuse.js"
|
||||
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
|
||||
import { HoppAction } from "~/helpers/actions"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
input: Record<string, any>[]
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: HoppAction): void
|
||||
}>()
|
||||
|
||||
const options = {
|
||||
keys: ["keys", "label", "action", "tags"],
|
||||
}
|
||||
|
||||
const fuse = new Fuse(props.input, options)
|
||||
|
||||
const searchResults = computed(() => fuse.search(props.search))
|
||||
|
||||
const searchResultsItems = computed(() =>
|
||||
searchResults.value.map((searchResult) => searchResult.item)
|
||||
)
|
||||
|
||||
const emitSearchAction = (action: HoppAction) => emit("action", action)
|
||||
|
||||
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
|
||||
useArrowKeysNavigation(searchResultsItems, {
|
||||
onEnter: emitSearchAction,
|
||||
stopPropagation: true,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
bindArrowKeysListeners()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unbindArrowKeysListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<GithubButton
|
||||
title="Star Hoppscotch on GitHub"
|
||||
href="https://github.com/hoppscotch/hoppscotch"
|
||||
:data-color-scheme="
|
||||
colorMode.value != 'light'
|
||||
? colorMode.value == 'black'
|
||||
? 'dark'
|
||||
: 'dark_dimmed'
|
||||
: 'light'
|
||||
"
|
||||
data-show-count="true"
|
||||
data-text="Star"
|
||||
aria-label="Star Hoppscotch on GitHub"
|
||||
:data-size="size"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GithubButton from "vue-github-button"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
199
packages/hoppscotch-common/src/components/app/Header.vue
Normal file
199
packages/hoppscotch-common/src/components/app/Header.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div>
|
||||
<header
|
||||
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
|
||||
>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<ButtonSecondary
|
||||
class="tracking-wide !font-bold !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark uppercase"
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
<AppGitHubStarButton class="mt-1.5 transition <sm:hidden" />
|
||||
</div>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<ButtonSecondary
|
||||
v-if="showInstallButton"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('header.install_pwa')"
|
||||
:icon="IconDownload"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="installPWA()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t('app.search')} <kbd>/</kbd>`"
|
||||
:icon="IconSearch"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.search.toggle')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${
|
||||
mdAndLarger ? t('support.title') : t('app.options')
|
||||
} <kbd>?</kbd>`"
|
||||
:icon="IconLifeBuoy"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.support.toggle')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="currentUser === null"
|
||||
:icon="IconUploadCloud"
|
||||
:label="t('header.save_workspace')"
|
||||
filled
|
||||
class="hidden md:flex"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
v-if="currentUser === null"
|
||||
:label="t('header.login')"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
<div v-else class="inline-flex items-center space-x-2">
|
||||
<ButtonPrimary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('team.invite_tooltip')"
|
||||
:label="t('team.invite')"
|
||||
:icon="IconUserPlus"
|
||||
class="!bg-green-500 !bg-opacity-15 !text-green-500 !hover:bg-opacity-10 !hover:bg-green-400 !hover:text-green-600"
|
||||
@click="showTeamsModal = true"
|
||||
/>
|
||||
<span class="px-2">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ProfilePicture
|
||||
v-if="currentUser.photoURL"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
}"
|
||||
:url="currentUser.photoURL"
|
||||
:alt="
|
||||
currentUser.displayName ||
|
||||
t('profile.default_hopp_displayname')
|
||||
"
|
||||
:title="
|
||||
currentUser.displayName ||
|
||||
currentUser.email ||
|
||||
t('profile.default_hopp_displayname')
|
||||
"
|
||||
indicator
|
||||
:indicator-styles="
|
||||
network.isOnline ? 'bg-green-500' : 'bg-red-500'
|
||||
"
|
||||
/>
|
||||
<ProfilePicture
|
||||
v-else
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
currentUser.displayName ||
|
||||
currentUser.email ||
|
||||
t('profile.default_hopp_displayname')
|
||||
"
|
||||
:initial="currentUser.displayName || currentUser.email"
|
||||
indicator
|
||||
:indicator-styles="
|
||||
network.isOnline ? 'bg-green-500' : 'bg-red-500'
|
||||
"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.p="profile.$el.click()"
|
||||
@keyup.s="settings.$el.click()"
|
||||
@keyup.l="logout.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<div class="flex flex-col px-2 text-tiny">
|
||||
<span class="inline-flex font-semibold truncate">
|
||||
{{
|
||||
currentUser.displayName ||
|
||||
t("profile.default_hopp_displayname")
|
||||
}}
|
||||
</span>
|
||||
<span class="inline-flex truncate text-secondaryLight">
|
||||
{{ currentUser.email }}
|
||||
</span>
|
||||
</div>
|
||||
<hr />
|
||||
<SmartItem
|
||||
ref="profile"
|
||||
to="/profile"
|
||||
:icon="IconUser"
|
||||
:label="t('navigation.profile')"
|
||||
:shortcut="['P']"
|
||||
@click="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="settings"
|
||||
to="/settings"
|
||||
:icon="IconSettings"
|
||||
:label="t('navigation.settings')"
|
||||
:shortcut="['S']"
|
||||
@click="hide()"
|
||||
/>
|
||||
<FirebaseLogout
|
||||
ref="logout"
|
||||
:shortcut="['L']"
|
||||
@confirm-logout="hide()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppAnnouncement v-if="!network.isOnline" />
|
||||
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
|
||||
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import IconUser from "~icons/lucide/user"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconSearch from "~icons/lucide/search"
|
||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||
import { probableUser$ } from "@helpers/fb/auth"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
/**
|
||||
* Once the PWA code is initialized, this holds a method
|
||||
* that can be called to show the user the installation
|
||||
* prompt.
|
||||
*/
|
||||
|
||||
const showInstallButton = computed(() => !!pwaDefferedPrompt.value)
|
||||
|
||||
const showLogin = ref(false)
|
||||
const showTeamsModal = ref(false)
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const mdAndLarger = breakpoints.greater("md")
|
||||
|
||||
const network = reactive(useNetwork())
|
||||
|
||||
const currentUser = useReadonlyStream(probableUser$, null)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const profile = ref<any | null>(null)
|
||||
const settings = ref<any | null>(null)
|
||||
const logout = ref<any | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="flex flex-col px-4 pt-2">
|
||||
<h2 class="inline-flex pb-1 font-semibold text-secondaryDark">
|
||||
{{ t("settings.interceptor") }}
|
||||
</h2>
|
||||
<p class="inline-flex text-tiny">
|
||||
{{ t("settings.interceptor_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<SmartRadioGroup v-model="interceptorSelection" :radios="interceptors" />
|
||||
<div
|
||||
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion"
|
||||
class="flex space-x-2"
|
||||
>
|
||||
<ButtonSecondary
|
||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||
blank
|
||||
:icon="IconChrome"
|
||||
label="Chrome"
|
||||
outline
|
||||
class="!flex-1"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||
blank
|
||||
:icon="IconFirefox"
|
||||
label="Firefox"
|
||||
outline
|
||||
class="!flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconChrome from "~icons/brands/chrome"
|
||||
import IconFirefox from "~icons/brands/firefox"
|
||||
import { computed } from "vue"
|
||||
import { applySetting, toggleSetting } from "~/newstore/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { extensionStatus$ } from "~/newstore/HoppExtension"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
|
||||
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
|
||||
|
||||
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
|
||||
|
||||
const extensionVersion = computed(() => {
|
||||
return currentExtensionStatus.value === "available"
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
|
||||
: null
|
||||
})
|
||||
|
||||
const interceptors = computed(() => [
|
||||
{ value: "BROWSER_ENABLED" as const, label: t("state.none") },
|
||||
{ value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
|
||||
{
|
||||
value: "EXTENSIONS_ENABLED" as const,
|
||||
label:
|
||||
`${t("settings.extensions")}: ` +
|
||||
(extensionVersion.value !== null
|
||||
? `v${extensionVersion.value.major}.${extensionVersion.value.minor}`
|
||||
: t("settings.extension_ver_not_reported")),
|
||||
},
|
||||
])
|
||||
|
||||
type InterceptorMode = typeof interceptors["value"][number]["value"]
|
||||
|
||||
const interceptorSelection = computed<InterceptorMode>({
|
||||
get() {
|
||||
if (PROXY_ENABLED.value) return "PROXY_ENABLED"
|
||||
if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
|
||||
return "BROWSER_ENABLED"
|
||||
},
|
||||
set(val) {
|
||||
if (val === "EXTENSIONS_ENABLED") {
|
||||
applySetting("EXTENSIONS_ENABLED", true)
|
||||
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
|
||||
}
|
||||
if (val === "PROXY_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", true)
|
||||
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
|
||||
}
|
||||
if (val === "BROWSER_ENABLED") {
|
||||
applySetting("PROXY_ENABLED", false)
|
||||
applySetting("EXTENSIONS_ENABLED", false)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
67
packages/hoppscotch-common/src/components/app/Logo.vue
Normal file
67
packages/hoppscotch-common/src/components/app/Logo.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512"
|
||||
fill="none"
|
||||
>
|
||||
<rect width="512" height="512" fill="#10B981" />
|
||||
<circle cx="197.76" cy="157.84" r="10" fill="white" fill-opacity="0.75" />
|
||||
<circle cx="259.76" cy="161.84" r="12" fill="white" fill-opacity="0.75" />
|
||||
<circle cx="319.76" cy="177.84" r="10" fill="white" fill-opacity="0.75" />
|
||||
<path
|
||||
d="M344.963 235.676C347.038 222.978 306.091 205.872 253.996 197.582C201.906 189.286 157.592 192.917 155.516 205.615C155.259 206.65 155.516 207.427 155.779 208.468C154.481 207.947 79.0651 419.68 79.0651 419.68H364.139C364.139 419.68 346.518 238.266 343.928 238.266C344.443 237.494 344.963 236.717 344.963 235.676Z"
|
||||
fill="url(#paint0_linear_106_2)"
|
||||
/>
|
||||
<path
|
||||
d="M314.902 227.386C313.604 235.419 284.063 237.231 248.559 231.788C213.312 226.088 185.577 214.945 186.875 207.17C187.396 204.58 190.763 202.505 196.206 201.47C178.065 202.247 166.144 205.615 165.11 211.315C163.555 221.943 199.836 236.454 246.483 243.972C293.13 251.484 332.265 248.637 334.077 238.272C335.118 232.046 324.747 225.311 307.646 218.833C312.569 221.68 315.159 224.79 314.902 227.386Z"
|
||||
fill="#A7F3D0"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M333.557 157.413C330.453 125.276 305.828 98.0618 272.657 92.8821C239.485 87.6965 208.126 105.837 194.908 135.121C216.159 136.419 238.965 138.752 262.812 142.639C288.208 146.527 312.049 151.713 333.557 157.413Z"
|
||||
fill="url(#paint1_radial_106_2)"
|
||||
/>
|
||||
<path
|
||||
d="M74.1425 158.002C71.5527 173.81 104.461 193.249 156.036 209.057C155.779 208.017 155.779 207.239 155.779 206.204C157.849 193.506 201.906 189.876 254.259 198.172C306.606 206.462 347.296 223.568 345.22 236.266C344.963 237.306 344.706 238.084 344.185 238.855C397.83 239.633 435.153 231.343 437.742 215.535C441.367 191.431 363.104 159.037 262.812 143.229C162.257 127.421 77.7672 133.898 74.1425 158.002ZM189.728 156.704C190.506 152.559 194.393 149.449 198.538 150.227C202.683 151.004 205.794 154.892 205.016 159.037C204.496 163.182 200.351 166.035 196.206 165.515C192.061 164.737 188.951 160.849 189.728 156.704ZM249.594 160.849C250.371 155.149 255.814 151.262 261.514 152.302C267.214 153.08 271.102 158.517 270.067 164.223C269.026 169.665 263.589 173.553 258.147 172.776C252.441 171.998 248.553 166.555 249.594 160.849ZM312.569 176.143C313.347 171.998 317.234 168.888 321.379 169.665C325.524 170.443 328.634 174.331 327.857 178.476C327.342 182.621 323.192 185.731 319.047 184.953C314.902 184.176 311.791 180.288 312.569 176.143Z"
|
||||
fill="url(#paint2_radial_106_2)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_106_2"
|
||||
x1="224.998"
|
||||
y1="157.606"
|
||||
x2="224.998"
|
||||
y2="403.696"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#86EFAC" stop-opacity="0.75" />
|
||||
<stop offset="0.635417" stop-color="white" stop-opacity="0.2" />
|
||||
<stop offset="1" stop-color="white" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="paint1_radial_106_2"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(264.232 124.706) rotate(90) scale(32.7063 69.3245)"
|
||||
>
|
||||
<stop stop-color="#047857" />
|
||||
<stop offset="1" stop-color="#064E3B" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint2_radial_106_2"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(255.837 186.754) scale(1389.61 1389.61)"
|
||||
>
|
||||
<stop stop-color="#047857" />
|
||||
<stop offset="0.114583" stop-color="#064E3B" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
221
packages/hoppscotch-common/src/components/app/Options.vue
Normal file
221
packages/hoppscotch-common/src/components/app/Options.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('app.options')"
|
||||
styles="sm:max-w-md"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<h2 class="p-4 font-semibold font-bold text-secondaryDark">
|
||||
{{ t("layout.name") }}
|
||||
</h2>
|
||||
<SmartItem
|
||||
:icon="IconSidebar"
|
||||
:label="EXPAND_NAVIGATION ? t('hide.sidebar') : t('show.sidebar')"
|
||||
:description="t('layout.collapse_sidebar')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="expandNavigation"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconSidebarOpen"
|
||||
:label="SIDEBAR ? t('hide.collection') : t('show.collection')"
|
||||
:description="t('layout.collapse_collection')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="expandCollection"
|
||||
/>
|
||||
<h2 class="p-4 font-semibold font-bold text-secondaryDark">
|
||||
{{ t("support.title") }}
|
||||
</h2>
|
||||
<SmartItem
|
||||
:icon="IconBook"
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io"
|
||||
:description="t('support.documentation')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
blank
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconGift"
|
||||
:label="t('app.whats_new')"
|
||||
to="https://docs.hoppscotch.io/changelog"
|
||||
:description="t('support.changelog')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
blank
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconActivity"
|
||||
:label="t('app.status')"
|
||||
to="https://status.hoppscotch.io"
|
||||
blank
|
||||
:description="t('app.status_description')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconLock"
|
||||
:label="`${t('app.terms_and_privacy')}`"
|
||||
to="https://docs.hoppscotch.io/privacy"
|
||||
blank
|
||||
:description="t('app.terms_and_privacy')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<h2 class="p-4 font-semibold font-bold text-secondaryDark">
|
||||
{{ t("settings.follow") }}
|
||||
</h2>
|
||||
<SmartItem
|
||||
:icon="IconDiscord"
|
||||
:label="t('app.discord')"
|
||||
to="https://hoppscotch.io/discord"
|
||||
blank
|
||||
:description="t('app.join_discord_community')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconTwitter"
|
||||
:label="t('app.twitter')"
|
||||
to="https://hoppscotch.io/twitter"
|
||||
blank
|
||||
:description="t('support.twitter')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconGithub"
|
||||
:label="`${t('app.github')}`"
|
||||
to="https://github.com/hoppscotch/hoppscotch"
|
||||
blank
|
||||
:description="t('support.github')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconMessageCircle"
|
||||
:label="t('app.chat_with_us')"
|
||||
:description="t('support.chat')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="chatWithUs()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconUserPlus"
|
||||
:label="`${t('app.invite')}`"
|
||||
:description="t('shortcut.miscellaneous.invite')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="expandInvite()"
|
||||
/>
|
||||
<SmartItem
|
||||
v-if="navigatorShare"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconShare2"
|
||||
:label="`${t('request.share')}`"
|
||||
:description="t('request.share_description')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="nativeShare()"
|
||||
/>
|
||||
</div>
|
||||
<AppShare :show="showShare" @hide-modal="showShare = false" />
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import IconSidebar from "~icons/lucide/sidebar"
|
||||
import IconSidebarOpen from "~icons/lucide/sidebar-open"
|
||||
import IconBook from "~icons/lucide/book"
|
||||
import IconGift from "~icons/lucide/gift"
|
||||
import IconActivity from "~icons/lucide/activity"
|
||||
import IconLock from "~icons/lucide/lock"
|
||||
import IconDiscord from "~icons/brands/discord"
|
||||
import IconTwitter from "~icons/brands/twitter"
|
||||
import IconGithub from "~icons/hopp/github"
|
||||
import IconMessageCircle from "~icons/lucide/message-circle"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import IconChevronRight from "~icons/lucide/chevron-right"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { showChat } from "@modules/crisp"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
const navigatorShare = !!navigator.share
|
||||
const showShare = ref(false)
|
||||
|
||||
const ZEN_MODE = useSetting("ZEN_MODE")
|
||||
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
const SIDEBAR = useSetting("SIDEBAR")
|
||||
|
||||
watch(
|
||||
() => ZEN_MODE.value,
|
||||
() => {
|
||||
EXPAND_NAVIGATION.value = !ZEN_MODE.value
|
||||
}
|
||||
)
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
defineActionHandler("modals.share.toggle", () => {
|
||||
showShare.value = !showShare.value
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const chatWithUs = () => {
|
||||
showChat()
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const expandNavigation = () => {
|
||||
EXPAND_NAVIGATION.value = !EXPAND_NAVIGATION.value
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const expandCollection = () => {
|
||||
SIDEBAR.value = !SIDEBAR.value
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const expandInvite = () => {
|
||||
showShare.value = true
|
||||
}
|
||||
|
||||
const nativeShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: "Hoppscotch",
|
||||
text: "Hoppscotch • Open source API development ecosystem - Helps you create requests faster, saving precious time on development.",
|
||||
url: "https://hoppscotch.io",
|
||||
})
|
||||
.catch(console.error)
|
||||
} else {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
121
packages/hoppscotch-common/src/components/app/PaneLayout.vue
Normal file
121
packages/hoppscotch-common/src/components/app/PaneLayout.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<Splitpanes
|
||||
class="smart-splitter"
|
||||
:rtl="SIDEBAR_ON_LEFT && mdAndLarger"
|
||||
:class="{
|
||||
'!flex-row-reverse': SIDEBAR_ON_LEFT && mdAndLarger,
|
||||
}"
|
||||
:horizontal="!mdAndLarger"
|
||||
@resize="setPaneEvent($event, 'vertical')"
|
||||
>
|
||||
<Pane
|
||||
:size="PANE_MAIN_SIZE"
|
||||
min-size="65"
|
||||
class="flex flex-col !overflow-auto"
|
||||
>
|
||||
<Splitpanes
|
||||
class="smart-splitter"
|
||||
:horizontal="COLUMN_LAYOUT"
|
||||
@resize="setPaneEvent($event, 'horizontal')"
|
||||
>
|
||||
<Pane :size="PANE_MAIN_TOP_SIZE" class="flex flex-col !overflow-auto">
|
||||
<slot name="primary" />
|
||||
</Pane>
|
||||
<Pane
|
||||
:size="PANE_MAIN_BOTTOM_SIZE"
|
||||
class="flex flex-col !overflow-auto"
|
||||
>
|
||||
<slot name="secondary" />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</Pane>
|
||||
<Pane
|
||||
v-if="SIDEBAR && hasSidebar"
|
||||
:size="PANE_SIDEBAR_SIZE"
|
||||
min-size="20"
|
||||
class="flex flex-col !overflow-auto bg-primaryContrast"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Splitpanes, Pane } from "splitpanes"
|
||||
|
||||
import "splitpanes/dist/splitpanes.css"
|
||||
|
||||
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
|
||||
import { computed, useSlots, ref } from "vue"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { setLocalConfig, getLocalConfig } from "~/newstore/localpersistence"
|
||||
|
||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const mdAndLarger = breakpoints.greater("md")
|
||||
|
||||
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
|
||||
|
||||
const SIDEBAR = useSetting("SIDEBAR")
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasSidebar = computed(() => !!slots.sidebar)
|
||||
|
||||
const props = defineProps({
|
||||
layoutId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
type PaneEvent = {
|
||||
max: number
|
||||
min: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const PANE_SIDEBAR_SIZE = ref(25)
|
||||
const PANE_MAIN_SIZE = ref(75)
|
||||
const PANE_MAIN_TOP_SIZE = ref(45)
|
||||
const PANE_MAIN_BOTTOM_SIZE = ref(65)
|
||||
|
||||
if (!COLUMN_LAYOUT.value) {
|
||||
PANE_MAIN_TOP_SIZE.value = 50
|
||||
PANE_MAIN_BOTTOM_SIZE.value = 50
|
||||
}
|
||||
|
||||
function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") {
|
||||
if (!props.layoutId) return
|
||||
const storageKey = `${props.layoutId}-pane-config-${type}`
|
||||
setLocalConfig(storageKey, JSON.stringify(event))
|
||||
}
|
||||
|
||||
function populatePaneEvent() {
|
||||
if (!props.layoutId) return
|
||||
|
||||
const verticalPaneData = getPaneData("vertical")
|
||||
if (verticalPaneData) {
|
||||
const [mainPane, sidebarPane] = verticalPaneData
|
||||
PANE_MAIN_SIZE.value = mainPane?.size
|
||||
PANE_SIDEBAR_SIZE.value = sidebarPane?.size
|
||||
}
|
||||
|
||||
const horizontalPaneData = getPaneData("horizontal")
|
||||
if (horizontalPaneData) {
|
||||
const [mainTopPane, mainBottomPane] = horizontalPaneData
|
||||
PANE_MAIN_TOP_SIZE.value = mainTopPane?.size
|
||||
PANE_MAIN_BOTTOM_SIZE.value = mainBottomPane?.size
|
||||
}
|
||||
}
|
||||
|
||||
function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null {
|
||||
const storageKey = `${props.layoutId}-pane-config-${type}`
|
||||
const paneEvent = getLocalConfig(storageKey)
|
||||
if (!paneEvent) return null
|
||||
return JSON.parse(paneEvent)
|
||||
}
|
||||
|
||||
populatePaneEvent()
|
||||
</script>
|
||||
122
packages/hoppscotch-common/src/components/app/PowerSearch.vue
Normal file
122
packages/hoppscotch-common/src/components/app/PowerSearch.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
styles="sm:max-w-lg"
|
||||
full-width
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col border-b transition border-dividerLight">
|
||||
<input
|
||||
id="command"
|
||||
v-model="search"
|
||||
v-focus
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
name="command"
|
||||
:placeholder="`${t('app.type_a_command_search')}`"
|
||||
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<kbd class="shortcut-key">↑</kbd>
|
||||
<kbd class="shortcut-key">↓</kbd>
|
||||
<span class="ml-2 truncate">
|
||||
{{ t("action.to_navigate") }}
|
||||
</span>
|
||||
<kbd class="shortcut-key">↩</kbd>
|
||||
<span class="ml-2 truncate">
|
||||
{{ t("action.to_select") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<kbd class="shortcut-key">ESC</kbd>
|
||||
<span class="ml-2 truncate">
|
||||
{{ t("action.to_close") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppFuse
|
||||
v-if="search && show"
|
||||
:input="fuse"
|
||||
:search="search"
|
||||
@action="runAction"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
|
||||
>
|
||||
<div
|
||||
v-for="(map, mapIndex) in mappings"
|
||||
:key="`map-${mapIndex}`"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h5 class="px-6 py-2 my-2 text-secondaryLight">
|
||||
{{ t(map.section) }}
|
||||
</h5>
|
||||
<AppPowerSearchEntry
|
||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
||||
:shortcut="shortcut"
|
||||
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
|
||||
@action="runAction"
|
||||
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { HoppAction, invokeAction } from "~/helpers/actions"
|
||||
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
|
||||
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const search = ref("")
|
||||
|
||||
const hideModal = () => {
|
||||
search.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const runAction = (command: HoppAction) => {
|
||||
invokeAction(command)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const shortcutsItems = computed(() =>
|
||||
mappings.reduce(
|
||||
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
|
||||
useArrowKeysNavigation(shortcutsItems, {
|
||||
onEnter: runAction,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) bindArrowKeysListeners()
|
||||
else unbindArrowKeysListeners()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center flex-1 px-6 py-3 font-medium cursor-pointer transition search-entry focus:outline-none"
|
||||
:class="{ active: active }"
|
||||
tabindex="-1"
|
||||
@click="emit('action', shortcut.action)"
|
||||
@keydown.enter="emit('action', shortcut.action)"
|
||||
>
|
||||
<component
|
||||
:is="shortcut.icon"
|
||||
class="mr-4 opacity-50 transition svg-icons"
|
||||
:class="{ 'opacity-100 text-secondaryDark': active }"
|
||||
/>
|
||||
<span
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
{{ t(shortcut.label) }}
|
||||
</span>
|
||||
<kbd
|
||||
v-for="(key, keyIndex) in shortcut.keys"
|
||||
:key="`key-${String(keyIndex)}`"
|
||||
class="shortcut-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
shortcut: {
|
||||
label: string
|
||||
keys: string[]
|
||||
action: string
|
||||
icon: object | Component
|
||||
}
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-entry {
|
||||
@apply relative;
|
||||
|
||||
&::after {
|
||||
@apply absolute;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply bottom-0;
|
||||
@apply bg-transparent;
|
||||
@apply z-2;
|
||||
@apply w-0.5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-primaryLight;
|
||||
|
||||
&::after {
|
||||
@apply bg-accentLight;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
packages/hoppscotch-common/src/components/app/Share.vue
Normal file
141
packages/hoppscotch-common/src/components/app/Share.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('app.invite_your_friends')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<a
|
||||
v-for="(platform, index) in platforms"
|
||||
:key="`platform-${index}`"
|
||||
:href="platform.link"
|
||||
target="_blank"
|
||||
class="share-link"
|
||||
tabindex="0"
|
||||
>
|
||||
<component :is="platform.icon" class="w-6 h-6" />
|
||||
<span class="mt-3">
|
||||
{{ platform.name }}
|
||||
</span>
|
||||
</a>
|
||||
<button class="share-link" @click="copyAppLink">
|
||||
<component :is="copyIcon" class="w-6 h-6 text-xl" />
|
||||
<span class="mt-3">
|
||||
{{ t("app.copy") }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("app.invite_description") }}
|
||||
</p>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconMail from "~icons/lucide/mail"
|
||||
import IconTwitter from "~icons/brands/twitter"
|
||||
import IconFacebook from "~icons/brands/facebook"
|
||||
import IconReddit from "~icons/brands/reddit"
|
||||
import IconLinkedIn from "~icons/brands/linkedin"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const url = "https://hoppscotch.io"
|
||||
const text = "Hoppscotch - Open source API development ecosystem."
|
||||
const description =
|
||||
"Helps you create requests faster, saving precious time on development."
|
||||
const subject = "Checkout Hoppscotch - an open source API development ecosystem"
|
||||
const summary = `Hi there!%0D%0A%0D%0AI thought you'll like this new platform that I joined called Hoppscotch - https://hoppscotch.io.%0D%0AIt is a simple and intuitive interface for creating and managing your APIs. You can build, test, document, and share your APIs.%0D%0A%0D%0AThe best part about Hoppscotch is that it is open source and free to get started.%0D%0A%0D%0A`
|
||||
const twitter = "hoppscotch_io"
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
name: "Email",
|
||||
icon: IconMail,
|
||||
link: `mailto:?subject=${subject}&body=${summary}`,
|
||||
},
|
||||
{
|
||||
name: "Twitter",
|
||||
icon: IconTwitter,
|
||||
link: `https://twitter.com/intent/tweet?text=${text} ${description}&url=${url}&via=${twitter}`,
|
||||
},
|
||||
{
|
||||
name: "Facebook",
|
||||
icon: IconFacebook,
|
||||
link: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
|
||||
},
|
||||
{
|
||||
name: "Reddit",
|
||||
icon: IconReddit,
|
||||
link: `https://www.reddit.com/submit?url=${url}&title=${text}`,
|
||||
},
|
||||
{
|
||||
name: "LinkedIn",
|
||||
icon: IconLinkedIn,
|
||||
link: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
||||
},
|
||||
]
|
||||
|
||||
const copyAppLink = () => {
|
||||
copyToClipboard(url)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.share-link {
|
||||
@apply border border-dividerLight;
|
||||
@apply rounded;
|
||||
@apply flex-col flex;
|
||||
@apply p-4;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply font-semibold;
|
||||
@apply hover: (bg-primaryLight text-secondaryDark);
|
||||
@apply focus: outline-none;
|
||||
@apply focus-visible: border-divider;
|
||||
|
||||
svg {
|
||||
@apply opacity-80;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
112
packages/hoppscotch-common/src/components/app/Shortcuts.vue
Normal file
112
packages/hoppscotch-common/src/components/app/Shortcuts.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<SmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()">
|
||||
<template #content>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
|
||||
>
|
||||
<div class="flex flex-col px-6 py-4 border-b border-dividerLight">
|
||||
<input
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
|
||||
<details
|
||||
v-for="(map, mapIndex) in searchResults"
|
||||
:key="`map-${mapIndex}`"
|
||||
class="flex flex-col"
|
||||
open
|
||||
>
|
||||
<summary
|
||||
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
|
||||
>
|
||||
<icon-lucide-chevron-right class="mr-2 indicator" />
|
||||
<span
|
||||
class="font-semibold truncate capitalize-first text-secondaryDark"
|
||||
>
|
||||
{{ t(map.item.section) }}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||
<AppShortcutsEntry
|
||||
v-for="(shortcut, index) in map.item.shortcuts"
|
||||
:key="`shortcut-${index}`"
|
||||
:shortcut="shortcut"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
v-if="searchResults.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
||||
<details
|
||||
v-for="(map, mapIndex) in mappings"
|
||||
:key="`map-${mapIndex}`"
|
||||
class="flex flex-col"
|
||||
open
|
||||
>
|
||||
<summary
|
||||
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
|
||||
>
|
||||
<icon-lucide-chevron-right class="mr-2 indicator" />
|
||||
<span
|
||||
class="font-semibold truncate capitalize-first text-secondaryDark"
|
||||
>
|
||||
{{ t(map.section) }}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||
<AppShortcutsEntry
|
||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
||||
:shortcut="shortcut"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</SmartSlideOver>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import Fuse from "fuse.js"
|
||||
import mappings from "~/helpers/shortcuts"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const options = {
|
||||
keys: ["shortcuts.label"],
|
||||
}
|
||||
|
||||
const fuse = new Fuse(mappings, options)
|
||||
|
||||
const filterText = ref("")
|
||||
|
||||
const searchResults = computed(() => fuse.search(filterText.value))
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void
|
||||
}>()
|
||||
|
||||
const close = () => {
|
||||
filterText.value = ""
|
||||
emit("close")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="flex items-center py-1">
|
||||
<span class="flex flex-1 mr-4">
|
||||
{{ t(shortcut.label) }}
|
||||
</span>
|
||||
<kbd
|
||||
v-for="(key, index) in shortcut.keys"
|
||||
:key="`key-${String(index)}`"
|
||||
class="shortcut-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
shortcut: {
|
||||
label: string
|
||||
keys: string[]
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center text-secondaryLight">
|
||||
<div class="flex pb-4 my-4 space-x-2">
|
||||
<div class="flex flex-col items-end space-y-4 text-right">
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.request.send_request") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.show_all") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.command_menu") }}
|
||||
</span>
|
||||
<span class="flex items-center flex-1">
|
||||
{{ t("shortcut.general.help_menu") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">↩</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">?</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
:label="`${t('app.documentation')}`"
|
||||
to="https://docs.hoppscotch.io/features/response"
|
||||
:icon="IconExternalLink"
|
||||
blank
|
||||
outline
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
</script>
|
||||
139
packages/hoppscotch-common/src/components/app/Sidenav.vue
Normal file
139
packages/hoppscotch-common/src/components/app/Sidenav.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<aside class="flex justify-between h-full md:flex-col">
|
||||
<nav class="flex flex-1 flex-nowrap md:flex-col md:flex-none bg-primary">
|
||||
<SmartLink
|
||||
v-for="(navigation, index) in primaryNavigation"
|
||||
:key="`navigation-${index}`"
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
placement: mdAndLarger ? 'right' : 'bottom',
|
||||
content: !EXPAND_NAVIGATION ? t(navigation.title) : null,
|
||||
}"
|
||||
:to="navigation.target"
|
||||
class="nav-link"
|
||||
tabindex="0"
|
||||
:exact="navigation.exact"
|
||||
>
|
||||
<div v-if="navigation.svg">
|
||||
<component :is="navigation.svg" class="svg-icons" />
|
||||
</div>
|
||||
<span v-if="EXPAND_NAVIGATION" class="nav-title">
|
||||
{{ t(navigation.title) }}
|
||||
</span>
|
||||
</SmartLink>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
|
||||
import IconLink2 from "~icons/lucide/link-2"
|
||||
import IconGraphql from "~icons/hopp/graphql"
|
||||
import IconGlobe from "~icons/lucide/globe"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const mdAndLarger = breakpoints.greater("md")
|
||||
|
||||
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
|
||||
const primaryNavigation = [
|
||||
{
|
||||
target: "/",
|
||||
svg: IconLink2,
|
||||
title: "navigation.rest",
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
target: "/graphql",
|
||||
svg: IconGraphql,
|
||||
title: "navigation.graphql",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
target: "/realtime",
|
||||
svg: IconGlobe,
|
||||
title: "navigation.realtime",
|
||||
exact: false,
|
||||
},
|
||||
{
|
||||
target: "/settings",
|
||||
svg: IconSettings,
|
||||
title: "navigation.settings",
|
||||
exact: false,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-link {
|
||||
@apply relative;
|
||||
@apply p-4;
|
||||
@apply flex flex-col flex-1;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply hover: (bg-primaryDark text-secondaryDark);
|
||||
@apply focus-visible: text-secondaryDark;
|
||||
|
||||
&::after {
|
||||
@apply absolute;
|
||||
@apply inset-x-0;
|
||||
@apply md: inset-x-auto;
|
||||
@apply md: inset-y-0;
|
||||
@apply bottom-0;
|
||||
@apply md: bottom-auto;
|
||||
@apply md: left-0;
|
||||
@apply z-2;
|
||||
@apply h-0.5;
|
||||
@apply md: h-full;
|
||||
@apply w-full;
|
||||
@apply md: w-0.5;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&:focus::after {
|
||||
@apply bg-divider;
|
||||
}
|
||||
|
||||
.svg-icons {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
@apply mt-2;
|
||||
@apply text-tiny;
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
@apply text-secondaryDark;
|
||||
@apply bg-primaryLight;
|
||||
@apply hover: text-secondaryDark;
|
||||
|
||||
.svg-icons {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bg-accent;
|
||||
}
|
||||
}
|
||||
|
||||
&.exact-active-link {
|
||||
@apply text-secondaryDark;
|
||||
@apply bg-primaryLight;
|
||||
@apply hover: text-secondaryDark;
|
||||
|
||||
.svg-icons {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bg-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
packages/hoppscotch-common/src/components/app/Support.vue
Normal file
118
packages/hoppscotch-common/src/components/app/Support.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('support.title')"
|
||||
styles="sm:max-w-md"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<SmartItem
|
||||
:icon="IconBook"
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io"
|
||||
:description="t('support.documentation')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
blank
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconZap"
|
||||
:label="t('app.keyboard_shortcuts')"
|
||||
:description="t('support.shortcuts')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="showShortcuts()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconGift"
|
||||
:label="t('app.whats_new')"
|
||||
to="https://docs.hoppscotch.io/changelog"
|
||||
:description="t('support.changelog')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
blank
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconMessageCircle"
|
||||
:label="t('app.chat_with_us')"
|
||||
:description="t('support.chat')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="chatWithUs()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconGitHub"
|
||||
:label="t('app.github')"
|
||||
to="https://hoppscotch.io/github"
|
||||
blank
|
||||
:description="t('support.github')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconDiscord"
|
||||
:label="t('app.join_discord_community')"
|
||||
to="https://hoppscotch.io/discord"
|
||||
blank
|
||||
:description="t('support.community')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconTwitter"
|
||||
:label="t('app.twitter')"
|
||||
to="https://hoppscotch.io/twitter"
|
||||
blank
|
||||
:description="t('support.twitter')"
|
||||
:info-icon="IconChevronRight"
|
||||
active
|
||||
@click="hideModal()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTwitter from "~icons/brands/twitter"
|
||||
import IconDiscord from "~icons/brands/discord"
|
||||
import IconGitHub from "~icons/hopp/github"
|
||||
import IconMessageCircle from "~icons/lucide/message-circle"
|
||||
import IconGift from "~icons/lucide/gift"
|
||||
import IconZap from "~icons/lucide/zap"
|
||||
import IconBook from "~icons/lucide/book"
|
||||
import IconChevronRight from "~icons/lucide/chevron-right"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
import { showChat } from "@modules/crisp"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const chatWithUs = () => {
|
||||
showChat()
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const showShortcuts = () => {
|
||||
invokeAction("flyouts.keybinds.toggle")
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
101
packages/hoppscotch-common/src/components/button/Primary.vue
Normal file
101
packages/hoppscotch-common/src/components/button/Primary.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<SmartLink
|
||||
:to="to"
|
||||
:blank="blank"
|
||||
class="relative inline-flex items-center justify-center py-2 font-bold transition focus:outline-none focus-visible:bg-accentDark"
|
||||
:class="[
|
||||
color
|
||||
? `text-${color}-800 bg-${color}-200 hover:(text-${color}-900 bg-${color}-300) focus-visible:(text-${color}-900 bg-${color}-300)`
|
||||
: `text-accentContrast bg-accent hover:bg-accentDark focus-visible:bg-accentDark`,
|
||||
label ? 'px-4' : 'px-2',
|
||||
rounded ? 'rounded-full' : 'rounded',
|
||||
{ 'opacity-75 cursor-not-allowed': disabled },
|
||||
{ 'pointer-events-none': loading },
|
||||
{ 'px-6 py-4 text-lg': large },
|
||||
{ 'shadow-lg hover:shadow-xl': shadow },
|
||||
{
|
||||
'text-white bg-gradient-to-tr from-gradientFrom via-gradientVia to-gradientTo':
|
||||
gradient,
|
||||
},
|
||||
{
|
||||
'border border-accent hover:border-accentDark focus-visible:border-accentDark':
|
||||
outline,
|
||||
},
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:tabindex="loading ? '-1' : '0'"
|
||||
role="button"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center whitespace-nowrap"
|
||||
:class="[{ 'flex-row-reverse': reverse }, { 'opacity-50': loading }]"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
class="svg-icons"
|
||||
:class="[
|
||||
{ '!text-2xl': large },
|
||||
label ? (reverse ? 'ml-2' : 'mr-2') : '',
|
||||
]"
|
||||
/>
|
||||
{{ label }}
|
||||
<div v-if="shortcut.length" class="<sm:hidden">
|
||||
<kbd
|
||||
v-for="(key, index) in shortcut"
|
||||
:key="`key-${index}`"
|
||||
class="shortcut-key !bg-accentDark !border-accentLight"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<SmartSpinner />
|
||||
</span>
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
|
||||
interface Props {
|
||||
to?: string
|
||||
exact?: boolean
|
||||
blank?: boolean
|
||||
label?: string
|
||||
icon?: object | null | Component // It is a component!
|
||||
svg?: object | null | Component // It is a component!
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
large?: boolean
|
||||
shadow?: boolean
|
||||
reverse?: boolean
|
||||
rounded?: boolean
|
||||
gradient?: boolean
|
||||
outline?: boolean
|
||||
shortcut?: string[]
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: "",
|
||||
exact: true,
|
||||
blank: false,
|
||||
label: "",
|
||||
icon: null,
|
||||
svg: null,
|
||||
color: "",
|
||||
disabled: false,
|
||||
loading: false,
|
||||
large: false,
|
||||
shadow: false,
|
||||
reverse: false,
|
||||
rounded: false,
|
||||
gradient: false,
|
||||
outline: false,
|
||||
shortcut: () => [],
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<SmartLink
|
||||
:to="to"
|
||||
:exact="exact"
|
||||
:blank="blank"
|
||||
class="inline-flex items-center justify-center py-2 font-semibold transition whitespace-nowrap focus:outline-none"
|
||||
:class="[
|
||||
color
|
||||
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
|
||||
: 'text-secondary hover:text-secondaryDark focus-visible:text-secondaryDark',
|
||||
{ 'pointer-events-none': loading },
|
||||
label ? 'rounded px-4' : 'px-2',
|
||||
{ 'rounded-full': rounded },
|
||||
{ 'opacity-75 cursor-not-allowed': disabled },
|
||||
{ 'flex-row-reverse': reverse },
|
||||
{ 'px-6 py-4 text-lg': large },
|
||||
{
|
||||
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
|
||||
outline,
|
||||
},
|
||||
{
|
||||
'bg-primaryLight hover:bg-primaryDark focus-visible:bg-primaryDark':
|
||||
filled,
|
||||
},
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:tabindex="loading ? '-1' : '0'"
|
||||
role="button"
|
||||
>
|
||||
<span
|
||||
v-if="!loading"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap"
|
||||
:class="{ 'flex-row-reverse': reverse }"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon"
|
||||
class="svg-icons"
|
||||
:class="[
|
||||
{ '!text-2xl': large },
|
||||
label ? (reverse ? 'ml-2' : 'mr-2') : '',
|
||||
]"
|
||||
/>
|
||||
{{ label }}
|
||||
<div v-if="shortcut.length" class="<sm:hidden">
|
||||
<kbd
|
||||
v-for="(key, index) in shortcut"
|
||||
:key="`key-${index}`"
|
||||
class="shortcut-key !bg-inherit"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</span>
|
||||
<SmartSpinner v-else />
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
|
||||
interface Props {
|
||||
to?: string
|
||||
exact?: boolean
|
||||
blank?: boolean
|
||||
label?: string
|
||||
icon?: object | null | Component // It is a component!
|
||||
svg?: object | null | Component // It is a component!
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
reverse?: boolean
|
||||
rounded?: boolean
|
||||
large?: boolean
|
||||
outline?: boolean
|
||||
shortcut?: string[]
|
||||
filled?: boolean
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
to: "",
|
||||
exact: true,
|
||||
blank: false,
|
||||
label: "",
|
||||
icon: null,
|
||||
svg: null,
|
||||
color: "",
|
||||
disabled: false,
|
||||
loading: false,
|
||||
reverse: false,
|
||||
rounded: false,
|
||||
large: false,
|
||||
outline: false,
|
||||
shortcut: () => [],
|
||||
filled: false,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('collection.new')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelAdd"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addNewCollection"
|
||||
/>
|
||||
<label for="selectLabelAdd">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="addNewCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(isShowing: boolean) {
|
||||
if (!isShowing) {
|
||||
this.name = null
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addNewCollection() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.new')"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelAddFolder"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addFolder"
|
||||
/>
|
||||
<label for="selectLabelAddFolder">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="addFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["hide-modal", "add-folder"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(isShowing: boolean) {
|
||||
if (!isShowing) this.name = null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("add-folder", {
|
||||
name: this.name,
|
||||
folder: this.folder,
|
||||
path: this.folderPath || `${this.collectionIndex}`,
|
||||
})
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('request.new')"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelAddRequest"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addRequest"
|
||||
/>
|
||||
<label for="selectLabelAddRequest">{{ t("action.label") }}</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="addRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { getRESTRequest } from "~/newstore/RESTSession"
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
folder?: object
|
||||
folderPath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(
|
||||
e: "add-request",
|
||||
v: {
|
||||
name: string
|
||||
folder: object | undefined
|
||||
path: string | undefined
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
name.value = getRESTRequest().name
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const addRequest = () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
}
|
||||
emit("add-request", {
|
||||
name: name.value,
|
||||
folder: props.folder,
|
||||
path: props.folderPath,
|
||||
})
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div>
|
||||
<SmartTabs
|
||||
:id="'collections_tab'"
|
||||
v-model="selectedCollectionTab"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'my-collections'"
|
||||
:label="`${t('collection.my_collections')}`"
|
||||
/>
|
||||
<SmartTab
|
||||
:id="'team-collections'"
|
||||
:label="`${t('collection.team_collections')}`"
|
||||
:disabled="!currentUser"
|
||||
>
|
||||
<SmartIntersection @intersecting="onTeamSelectIntersect">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
placement="bottom"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('collection.select_team')}`"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam"
|
||||
:icon="IconUsers"
|
||||
:label="collectionsType.selectedTeam.name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('collection.select_team')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(team, index) in myTeams"
|
||||
:key="`team-${index}`"
|
||||
:label="team.name"
|
||||
:info-icon="
|
||||
team.id === collectionsType.selectedTeam?.id
|
||||
? IconDone
|
||||
: undefined
|
||||
"
|
||||
:active-info-icon="
|
||||
team.id === collectionsType.selectedTeam?.id
|
||||
"
|
||||
:icon="IconUsers"
|
||||
@click="
|
||||
() => {
|
||||
updateSelectedTeam(team)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</SmartIntersection>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconUsers from "~icons/lucide/users"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import { ref, watch } from "vue"
|
||||
import { GetMyTeamsQuery, Team } from "~/helpers/backend/graphql"
|
||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
|
||||
type TeamData = GetMyTeamsQuery["myTeams"][number]
|
||||
|
||||
type CollectionTabs = "my-collections" | "team-collections"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
|
||||
|
||||
defineProps<{
|
||||
collectionsType: {
|
||||
type: "my-collections" | "team-collections"
|
||||
selectedTeam: Team | undefined
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update-collection-type", tabID: string): void
|
||||
(e: "update-selected-team", team: TeamData | undefined): void
|
||||
}>()
|
||||
|
||||
const currentUser = useReadonlyStream(currentUserInfo$, null)
|
||||
|
||||
const adapter = new TeamListAdapter(true)
|
||||
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
let teamListFetched = false
|
||||
|
||||
watch(myTeams, (teams) => {
|
||||
if (teams && !teamListFetched) {
|
||||
teamListFetched = true
|
||||
if (REMEMBERED_TEAM_ID.value && currentUser) {
|
||||
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) updateSelectedTeam(team)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentUser.value,
|
||||
(user) => {
|
||||
if (!user) {
|
||||
selectedCollectionTab.value = "my-collections"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onTeamSelectIntersect = () => {
|
||||
// Load team data as soon as intersection
|
||||
adapter.fetchList()
|
||||
}
|
||||
|
||||
const updateCollectionsType = (tabID: string) => {
|
||||
emit("update-collection-type", tabID)
|
||||
}
|
||||
|
||||
const updateSelectedTeam = (team: TeamData | undefined) => {
|
||||
REMEMBERED_TEAM_ID.value = team?.id
|
||||
emit("update-selected-team", team)
|
||||
}
|
||||
|
||||
watch(selectedCollectionTab, (newValue: string) => {
|
||||
updateCollectionsType(newValue)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('collection.edit')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelEdit"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveCollection"
|
||||
/>
|
||||
<label for="selectLabelEdit">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="saveCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingCollectionName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingCollectionName(val) {
|
||||
this.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveCollection() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.edit')"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelEditFolder"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="editFolder"
|
||||
/>
|
||||
<label for="selectLabelEditFolder">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="editFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingFolderName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingFolderName(val) {
|
||||
this.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('modal.edit_request')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelEditReq"
|
||||
v-model="requestUpdateData.name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequest"
|
||||
/>
|
||||
<label for="selectLabelEditReq">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="saveRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingRequestName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
requestUpdateData: {
|
||||
name: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingRequestName(val) {
|
||||
this.requestUpdateData.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveRequest() {
|
||||
if (!this.requestUpdateData.name) {
|
||||
this.toast.error(this.t("request.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.requestUpdateData)
|
||||
},
|
||||
hideModal() {
|
||||
this.requestUpdateData = { name: null }
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('modal.collections')}`"
|
||||
styles="sm:max-w-md"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonSecondary
|
||||
v-if="importerType !== null"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.go_back')"
|
||||
:icon="IconArrowLeft"
|
||||
@click="resetImport"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="importerType !== null" class="flex flex-col">
|
||||
<div class="flex flex-col pb-4">
|
||||
<div
|
||||
v-for="(step, index) in importerSteps"
|
||||
:key="`step-${index}`"
|
||||
class="flex flex-col space-y-8"
|
||||
>
|
||||
<div v-if="step.name === 'FILE_IMPORT'" class="space-y-4">
|
||||
<p class="flex items-center">
|
||||
<span
|
||||
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
|
||||
:class="{
|
||||
'!text-green-500': hasFile,
|
||||
}"
|
||||
>
|
||||
<icon-lucide-check-circle class="svg-icons" />
|
||||
</span>
|
||||
<span>
|
||||
{{ t(`${step.metadata.caption}`) }}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark"
|
||||
>
|
||||
<input
|
||||
id="inputChooseFileToImportFrom"
|
||||
ref="inputChooseFileToImportFrom"
|
||||
name="inputChooseFileToImportFrom"
|
||||
type="file"
|
||||
class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
|
||||
:accept="step.metadata.acceptedFileTypes"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4">
|
||||
<p class="flex items-center">
|
||||
<span
|
||||
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
|
||||
:class="{
|
||||
'!text-green-500': hasGist,
|
||||
}"
|
||||
>
|
||||
<icon-lucide-check-circle class="svg-icons" />
|
||||
</span>
|
||||
<span>
|
||||
{{ t(`${step.metadata.caption}`) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="flex flex-col ml-10">
|
||||
<input
|
||||
v-model="inputChooseGistToImportFrom"
|
||||
type="url"
|
||||
class="input"
|
||||
:placeholder="`${t('import.gist_url')}`"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="step.name === 'TARGET_MY_COLLECTION'"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div class="select-wrapper">
|
||||
<select
|
||||
v-model="mySelectedCollectionID"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="select"
|
||||
autofocus
|
||||
>
|
||||
<option :key="undefined" :value="undefined" disabled selected>
|
||||
{{ t("collection.select") }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(collection, collectionIndex) in myCollections"
|
||||
:key="`collection-${collectionIndex}`"
|
||||
:value="collectionIndex"
|
||||
>
|
||||
{{ collection.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
:label="t('import.title')"
|
||||
:disabled="enableImportButton"
|
||||
:loading="importingMyCollections"
|
||||
@click="finishImport"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<SmartExpand>
|
||||
<template #body>
|
||||
<SmartItem
|
||||
v-for="(importer, index) in importerModules"
|
||||
:key="`importer-${index}`"
|
||||
:icon="importer.icon"
|
||||
:label="t(`${importer.name}`)"
|
||||
@click="importerType = index"
|
||||
/>
|
||||
</template>
|
||||
</SmartExpand>
|
||||
<hr />
|
||||
<div class="flex flex-col space-y-2">
|
||||
<SmartItem
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.as_json')"
|
||||
@click="exportJSON"
|
||||
/>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
!currentUser
|
||||
? `${t('export.require_github')}`
|
||||
: currentUser.provider !== 'github.com'
|
||||
? `${t('export.require_github')}`
|
||||
: undefined
|
||||
"
|
||||
class="flex"
|
||||
>
|
||||
<SmartItem
|
||||
:disabled="
|
||||
!currentUser
|
||||
? true
|
||||
: currentUser.provider !== 'github.com'
|
||||
? true
|
||||
: false
|
||||
"
|
||||
:icon="IconGithub"
|
||||
:label="t('export.create_secret_gist')"
|
||||
@click="
|
||||
() => {
|
||||
createCollectionGist()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconArrowLeft from "~icons/lucide/arrow-left"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
|
||||
import axios from "axios"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
|
||||
import { StepReturnValue } from "~/helpers/import-export/steps"
|
||||
import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
ExportAsJsonDocument,
|
||||
ImportFromJsonDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
collectionsType:
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
| { type: "my-collections" }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "update-team-collections"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const myCollections = useReadonlyStream(restCollections$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
// Template refs
|
||||
const mode = ref("import_export")
|
||||
const mySelectedCollectionID = ref<undefined | number>(undefined)
|
||||
const collectionJson = ref("")
|
||||
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
|
||||
const inputChooseGistToImportFrom = ref<string>("")
|
||||
|
||||
const getJSONCollection = async () => {
|
||||
if (props.collectionsType.type === "my-collections") {
|
||||
collectionJson.value = JSON.stringify(myCollections.value, null, 2)
|
||||
} else {
|
||||
collectionJson.value = pipe(
|
||||
await runGQLQuery({
|
||||
query: ExportAsJsonDocument,
|
||||
variables: {
|
||||
teamID: props.collectionsType.selectedTeam.id,
|
||||
},
|
||||
}),
|
||||
E.matchW(
|
||||
// TODO: Handle error case gracefully ?
|
||||
() => {
|
||||
throw new Error("Error exporting collection to JSON")
|
||||
},
|
||||
(x) => x.exportCollectionsToJSON
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return collectionJson.value
|
||||
}
|
||||
|
||||
const createCollectionGist = async () => {
|
||||
if (!currentUser.value) {
|
||||
toast.error(t("profile.no_permission").toString())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await getJSONCollection()
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
"https://api.github.com/gists",
|
||||
{
|
||||
files: {
|
||||
"hoppscotch-collections.json": {
|
||||
content: collectionJson.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${currentUser.value.accessToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
toast.success(t("export.gist_created").toString())
|
||||
window.open(res.html_url)
|
||||
} catch (e) {
|
||||
toast.error(t("error.something_went_wrong").toString())
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fileImported = () => {
|
||||
toast.success(t("state.file_imported").toString())
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const failedImport = () => {
|
||||
toast.error(t("import.failed").toString())
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
mode.value = "import_export"
|
||||
mySelectedCollectionID.value = undefined
|
||||
resetImport()
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const stepResults = ref<StepReturnValue[]>([])
|
||||
|
||||
watch(mySelectedCollectionID, (newValue) => {
|
||||
if (newValue === undefined) return
|
||||
stepResults.value = []
|
||||
stepResults.value.push(newValue)
|
||||
})
|
||||
|
||||
const importingMyCollections = ref(false)
|
||||
|
||||
const importToTeams = async (content: HoppCollection<HoppRESTRequest>) => {
|
||||
importingMyCollections.value = true
|
||||
if (props.collectionsType.type !== "team-collections") return
|
||||
|
||||
const result = await runMutation(ImportFromJsonDocument, {
|
||||
jsonString: JSON.stringify(content),
|
||||
teamID: props.collectionsType.selectedTeam.id,
|
||||
})()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error(result.left)
|
||||
} else {
|
||||
emit("update-team-collections")
|
||||
}
|
||||
|
||||
importingMyCollections.value = false
|
||||
}
|
||||
|
||||
const exportJSON = async () => {
|
||||
await getJSONCollection()
|
||||
|
||||
const dataToWrite = collectionJson.value
|
||||
const file = new Blob([dataToWrite], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
// TODO: get uri from meta
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
toast.success(t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const importerModules = computed(() =>
|
||||
RESTCollectionImporters.filter(
|
||||
(i) => i.applicableTo?.includes(props.collectionsType.type) ?? true
|
||||
)
|
||||
)
|
||||
|
||||
const importerType = ref<number | null>(null)
|
||||
|
||||
const importerModule = computed(() =>
|
||||
importerType.value !== null ? importerModules.value[importerType.value] : null
|
||||
)
|
||||
|
||||
const importerSteps = computed(() => importerModule.value?.steps ?? null)
|
||||
|
||||
const finishImport = async () => {
|
||||
await importerAction(stepResults.value)
|
||||
}
|
||||
|
||||
const importerAction = async (stepResults: any[]) => {
|
||||
if (!importerModule.value) return
|
||||
const result = await importerModule.value?.importer(stepResults as any)()
|
||||
if (E.isLeft(result)) {
|
||||
failedImport()
|
||||
console.error("error", result.left)
|
||||
} else if (E.isRight(result)) {
|
||||
if (props.collectionsType.type === "team-collections") {
|
||||
importToTeams(result.right)
|
||||
fileImported()
|
||||
} else {
|
||||
appendRESTCollections(result.right)
|
||||
fileImported()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasFile = ref(false)
|
||||
const hasGist = ref(false)
|
||||
|
||||
watch(inputChooseGistToImportFrom, (v) => {
|
||||
stepResults.value = []
|
||||
if (v === "") {
|
||||
hasGist.value = false
|
||||
} else {
|
||||
hasGist.value = true
|
||||
stepResults.value.push(inputChooseGistToImportFrom.value)
|
||||
}
|
||||
})
|
||||
|
||||
const onFileChange = () => {
|
||||
stepResults.value = []
|
||||
if (!inputChooseFileToImportFrom.value[0]) {
|
||||
hasFile.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!inputChooseFileToImportFrom.value[0].files ||
|
||||
inputChooseFileToImportFrom.value[0].files.length === 0
|
||||
) {
|
||||
inputChooseFileToImportFrom.value[0].value = ""
|
||||
hasFile.value = false
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({ target }) => {
|
||||
const content = target!.result as string | null
|
||||
if (!content) {
|
||||
hasFile.value = false
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
stepResults.value.push(content)
|
||||
hasFile.value = !!content?.length
|
||||
}
|
||||
reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
|
||||
}
|
||||
|
||||
const enableImportButton = computed(
|
||||
() => !(stepResults.value.length === importerSteps.value?.length)
|
||||
)
|
||||
|
||||
const resetImport = () => {
|
||||
importerType.value = null
|
||||
stepResults.value = []
|
||||
inputChooseFileToImportFrom.value = ""
|
||||
hasFile.value = false
|
||||
inputChooseGistToImportFrom.value = ""
|
||||
hasGist.value = false
|
||||
mySelectedCollectionID.value = undefined
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('collection.save_as')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<div class="relative flex">
|
||||
<input
|
||||
id="selectLabelSaveReq"
|
||||
v-model="requestName"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequestAs"
|
||||
/>
|
||||
<label for="selectLabelSaveReq">
|
||||
{{ t("request.name") }}
|
||||
</label>
|
||||
</div>
|
||||
<label class="p-4">
|
||||
{{ t("collection.select_location") }}
|
||||
</label>
|
||||
<CollectionsGraphql
|
||||
v-if="mode === 'graphql'"
|
||||
:show-coll-actions="false"
|
||||
:picked="picked"
|
||||
:saving-mode="true"
|
||||
@select="onSelect"
|
||||
/>
|
||||
<Collections
|
||||
v-else
|
||||
:picked="picked"
|
||||
:save-request="true"
|
||||
@select="onSelect"
|
||||
@update-collection="updateColl"
|
||||
@update-coll-type="onUpdateCollType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="saveRequestAs"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
editGraphqlRequest,
|
||||
editRESTRequest,
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
|
||||
import {
|
||||
getRESTRequest,
|
||||
setRESTSaveContext,
|
||||
useRESTRequestName,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateRequestInCollectionDocument,
|
||||
UpdateRequestDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "my-collections"
|
||||
}
|
||||
| {
|
||||
type: "team-collections"
|
||||
// TODO: Figure this type out
|
||||
selectedTeam: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
type Picked =
|
||||
| {
|
||||
pickedType: "my-request"
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "my-folder"
|
||||
folderPath: string
|
||||
}
|
||||
| {
|
||||
pickedType: "my-collection"
|
||||
collectionIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-request"
|
||||
requestID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-folder"
|
||||
folderID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "teams-collection"
|
||||
collectionID: string
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-request"
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-folder"
|
||||
folderPath: string
|
||||
}
|
||||
| {
|
||||
pickedType: "gql-my-collection"
|
||||
collectionIndex: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mode: "rest" | "graphql"
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// TODO: Use a better implementation with computed ?
|
||||
// This implementation can't work across updates to mode prop (which won't happen tho)
|
||||
const requestName =
|
||||
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
|
||||
|
||||
const requestData = reactive({
|
||||
name: requestName,
|
||||
collectionIndex: undefined as number | undefined,
|
||||
folderName: undefined as number | undefined,
|
||||
requestIndex: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const collectionsType = ref<CollectionType>({
|
||||
type: "my-collections",
|
||||
})
|
||||
|
||||
// TODO: Figure this type out
|
||||
const picked = ref<Picked | null>(null)
|
||||
|
||||
// Resets
|
||||
watch(
|
||||
() => requestData.collectionIndex,
|
||||
() => {
|
||||
requestData.folderName = undefined
|
||||
requestData.requestIndex = undefined
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => requestData.folderName,
|
||||
() => {
|
||||
requestData.requestIndex = undefined
|
||||
}
|
||||
)
|
||||
|
||||
// All the methods
|
||||
const onUpdateCollType = (newCollType: CollectionType) => {
|
||||
collectionsType.value = newCollType
|
||||
}
|
||||
|
||||
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
|
||||
picked.value = pickedVal
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
picked.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const saveRequestAs = async () => {
|
||||
if (!requestName.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
}
|
||||
if (picked.value === null) {
|
||||
toast.error(`${t("collection.select")}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Clone Deep because objects are shared by reference so updating
|
||||
// just one bit will update other referenced shared instances
|
||||
const requestUpdated =
|
||||
props.mode === "rest"
|
||||
? cloneDeep(getRESTRequest())
|
||||
: cloneDeep(getGQLSession().request)
|
||||
|
||||
// // Filter out all REST file inputs
|
||||
// if (this.mode === "rest" && requestUpdated.bodyParams) {
|
||||
// requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
|
||||
// param?.value?.[0] instanceof File ? { ...param, value: "" } : param
|
||||
// )
|
||||
// }
|
||||
|
||||
if (picked.value.pickedType === "my-request") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
editRESTRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "my-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
picked.value.folderPath,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "my-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "teams-request") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: picked.value.requestID,
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
title: requestUpdated.name,
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
throw new Error(`${result.left}`)
|
||||
} else {
|
||||
requestSaved()
|
||||
}
|
||||
})
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: picked.value.requestID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
} else if (picked.value.pickedType === "teams-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: picked.value.folderID,
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
title: requestUpdated.name,
|
||||
},
|
||||
})()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
console.error(result.left)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.folderID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
} else if (picked.value.pickedType === "teams-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
if (collectionsType.value.type !== "team-collections")
|
||||
throw new Error("Collections Type mismatch")
|
||||
|
||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: picked.value.collectionID,
|
||||
data: {
|
||||
title: requestUpdated.name,
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
},
|
||||
})()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
console.error(result.left)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: result.right.createRequestInCollection.id,
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
collectionID: picked.value.collectionID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
} else if (picked.value.pickedType === "gql-my-request") {
|
||||
// TODO: Check for GQL request ?
|
||||
editGraphqlRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "gql-my-folder") {
|
||||
// TODO: Check for GQL request ?
|
||||
saveGraphqlRequestAs(
|
||||
picked.value.folderPath,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "gql-my-collection") {
|
||||
// TODO: Check for GQL request ?
|
||||
saveGraphqlRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated as HoppGQLRequest
|
||||
)
|
||||
|
||||
requestSaved()
|
||||
}
|
||||
}
|
||||
|
||||
const requestSaved = () => {
|
||||
toast.success(`${t("request.added")}`)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const updateColl = (ev: CollectionType["type"]) => {
|
||||
collectionsType.value.type = ev
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('collection.new')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlAdd"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addNewCollection"
|
||||
/>
|
||||
<label for="selectLabelGqlAdd">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="addNewCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { HoppGQLRequest, makeCollection } from "@hoppscotch/data"
|
||||
import { addGraphqlCollection } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
},
|
||||
emits: ["hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null as string | null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addNewCollection() {
|
||||
if (!this.name) {
|
||||
this.toast.error(`${this.t("collection.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
addGraphqlCollection(
|
||||
makeCollection<HoppGQLRequest>({
|
||||
name: this.name,
|
||||
folders: [],
|
||||
requests: [],
|
||||
})
|
||||
)
|
||||
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.new')"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlAddFolder"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addFolder"
|
||||
/>
|
||||
<label for="selectLabelGqlAddFolder">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary :label="t('action.save')" outline @click="addFolder" />
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folderPath: { type: String, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
},
|
||||
emits: ["hide-modal", "add-folder"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(`${this.t("folder.name_length_insufficient")}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit("add-folder", {
|
||||
name: this.name,
|
||||
path: this.folderPath || `${this.collectionIndex}`,
|
||||
})
|
||||
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('request.new')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlAddRequest"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="addRequest"
|
||||
/>
|
||||
<label for="selectLabelGqlAddRequest">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary :label="t('action.save')" outline @click="addRequest" />
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { getGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
folderPath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(
|
||||
e: "add-request",
|
||||
v: {
|
||||
name: string
|
||||
path: string | undefined
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
name.value = getGQLSession().request.name
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const addRequest = () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
}
|
||||
emit("add-request", {
|
||||
name: name.value,
|
||||
path: props.folderPath,
|
||||
})
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="collectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collection.name }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
emit('add-folder', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="`${t('request.new')}`"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="`${t('folder.new')}`"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
emit('add-folder', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-collection')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<CollectionsGraphqlFolder
|
||||
v-for="(folder, index) in collection.folders"
|
||||
:key="`folder-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${String(index)}`"
|
||||
:collection-index="collectionIndex"
|
||||
:is-filtered="isFiltered"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<CollectionsGraphqlRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
:folder-name="collection.name"
|
||||
:folder-path="`${collectionIndex}`"
|
||||
:request-index="index"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
collection.folders.length === 0 && collection.requests.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_collection')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeCollection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
removeGraphqlCollection,
|
||||
moveGraphqlRequest,
|
||||
} from "~/newstore/collections"
|
||||
|
||||
const props = defineProps({
|
||||
picked: { type: Object, default: null },
|
||||
// Whether the viewing context is related to picking (activates 'select' events)
|
||||
savingMode: { type: Boolean, default: false },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
})
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
// TODO: improve types plz
|
||||
const emit = defineEmits<{
|
||||
(e: "select", i: { picked: any }): void
|
||||
(e: "edit-request", i: any): void
|
||||
(e: "duplicate-request", i: any): void
|
||||
(e: "add-request", i: any): void
|
||||
(e: "add-folder", i: any): void
|
||||
(e: "edit-folder", i: any): void
|
||||
(e: "edit-collection"): void
|
||||
}>()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const requestAction = ref<any | null>(null)
|
||||
const folderAction = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const showChildren = ref(false)
|
||||
const dragging = ref(false)
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked?.pickedType === "gql-my-collection" &&
|
||||
props.picked?.collectionIndex === props.collectionIndex
|
||||
)
|
||||
const collectionIcon = computed(() => {
|
||||
if (isSelected.value) return IconCheckCircle
|
||||
else if (!showChildren.value && !props.isFiltered) return IconFolder
|
||||
else if (!showChildren.value || props.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
})
|
||||
|
||||
const pick = () => {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "gql-my-collection",
|
||||
collectionIndex: props.collectionIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toggleShowChildren = () => {
|
||||
if (props.savingMode) {
|
||||
pick()
|
||||
}
|
||||
|
||||
showChildren.value = !showChildren.value
|
||||
}
|
||||
|
||||
const removeCollection = () => {
|
||||
// Cancel pick if picked collection is deleted
|
||||
if (
|
||||
props.picked?.pickedType === "gql-my-collection" &&
|
||||
props.picked?.collectionIndex === props.collectionIndex
|
||||
) {
|
||||
emit("select", { picked: null })
|
||||
}
|
||||
removeGraphqlCollection(props.collectionIndex)
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
}
|
||||
|
||||
const dropEvent = ({ dataTransfer }: any) => {
|
||||
dragging.value = !dragging.value
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveGraphqlRequest(folderPath, requestIndex, `${props.collectionIndex}`)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('collection.edit')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlEdit"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveCollection"
|
||||
/>
|
||||
<label for="selectLabelGqlEdit">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="saveCollection"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { editGraphqlCollection } from "~/newstore/collections"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
editingCollection: { type: Object, default: () => ({}) },
|
||||
editingCollectionIndex: { type: Number, default: null },
|
||||
editingCollectionName: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const name = ref<string | null>()
|
||||
|
||||
watch(
|
||||
() => props.editingCollectionName,
|
||||
(val) => {
|
||||
name.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const saveCollection = () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("collection.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Better typechecking here ?
|
||||
const collectionUpdated = {
|
||||
...(props.editingCollection as any),
|
||||
name: name.value,
|
||||
}
|
||||
|
||||
editGraphqlCollection(props.editingCollectionIndex, collectionUpdated)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('folder.edit')}`"
|
||||
@close="$emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlEditFolder"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="editFolder"
|
||||
/>
|
||||
<label for="selectLabelGqlEditFolder">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="editFolder"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { editGraphqlFolder } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
editingFolderName: { type: String, default: null },
|
||||
},
|
||||
emits: ["hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: "",
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingFolderName(val) {
|
||||
this.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(`${this.t("collection.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
editGraphqlFolder(this.folderPath, {
|
||||
...(this.folder as any),
|
||||
name: this.name,
|
||||
})
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.name = ""
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('modal.edit_request')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelGqlEditReq"
|
||||
v-model="requestUpdateData.name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequest"
|
||||
/>
|
||||
<label for="selectLabelGqlEditReq">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="saveRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
import { editGraphqlRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folderPath: { type: String, default: null },
|
||||
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
|
||||
requestIndex: { type: Number, default: null },
|
||||
editingRequestName: { type: String, default: null },
|
||||
},
|
||||
emits: ["hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
requestUpdateData: {
|
||||
name: null as any | null,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingRequestName(val) {
|
||||
this.requestUpdateData.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveRequest() {
|
||||
if (!this.requestUpdateData.name) {
|
||||
this.toast.error(`${this.t("collection.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Type safety goes brrrr. Proper typing plz
|
||||
const requestUpdated = {
|
||||
...this.$props.request,
|
||||
name: this.$data.requestUpdateData.name || this.$props.request.name,
|
||||
}
|
||||
|
||||
editGraphqlRequest(this.folderPath, this.requestIndex, requestUpdated)
|
||||
|
||||
this.hideModal()
|
||||
},
|
||||
hideModal() {
|
||||
this.requestUpdateData = { name: null }
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="collectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ folder.name ? folder.name : folder.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="emit('add-request', { path: folderPath })"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="emit('add-folder', { folder, path: folderPath })"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="`${t('request.new')}`"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
emit('add-request', { path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="`${t('folder.new')}`"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
emit('add-folder', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-folder', { folder, folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<!-- Referring to this component only (this is recursive) -->
|
||||
<Folder
|
||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
||||
:key="`subFolder-${String(subFolderIndex)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
|
||||
:collection-index="collectionIndex"
|
||||
:is-filtered="isFiltered"
|
||||
@add-request="emit('add-request', $event)"
|
||||
@add-folder="emit('add-folder', $event)"
|
||||
@edit-folder="emit('edit-folder', $event)"
|
||||
@edit-request="emit('edit-request', $event)"
|
||||
@duplicate-request="emit('duplicate-request', $event)"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
<CollectionsGraphqlRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
:folder-path="folderPath"
|
||||
:folder-name="folder.name"
|
||||
:request-index="index"
|
||||
@edit-request="emit('edit-request', $event)"
|
||||
@duplicate-request="emit('duplicate-request', $event)"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
folder.folders &&
|
||||
folder.folders.length === 0 &&
|
||||
folder.requests &&
|
||||
folder.requests.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_folder')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections"
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = defineProps({
|
||||
picked: { type: Object, default: null },
|
||||
// Whether the request is in a selectable mode (activates 'select' event)
|
||||
savingMode: { type: Boolean, default: false },
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
folderPath: { type: String, default: null },
|
||||
isFiltered: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
"select",
|
||||
"add-request",
|
||||
"edit-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"duplicate-request",
|
||||
])
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const requestAction = ref<any | null>(null)
|
||||
const folderAction = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const showChildren = ref(false)
|
||||
const dragging = ref(false)
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked?.pickedType === "gql-my-folder" &&
|
||||
props.picked?.folderPath === props.folderPath
|
||||
)
|
||||
const collectionIcon = computed(() => {
|
||||
if (isSelected.value) return IconCheckCircle
|
||||
else if (!showChildren.value && !props.isFiltered) return IconFolder
|
||||
else if (showChildren.value || !props.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
})
|
||||
|
||||
const pick = () => {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "gql-my-folder",
|
||||
folderPath: props.folderPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toggleShowChildren = () => {
|
||||
if (props.savingMode) {
|
||||
pick()
|
||||
}
|
||||
|
||||
showChildren.value = !showChildren.value
|
||||
}
|
||||
|
||||
const removeFolder = () => {
|
||||
// Cancel pick if the picked folder is deleted
|
||||
if (
|
||||
props.picked?.pickedType === "gql-my-folder" &&
|
||||
props.picked?.folderPath === props.folderPath
|
||||
) {
|
||||
emit("select", { picked: null })
|
||||
}
|
||||
|
||||
removeGraphqlFolder(props.folderPath)
|
||||
toast.success(t("state.deleted"))
|
||||
}
|
||||
|
||||
const dropEvent = ({ dataTransfer }: any) => {
|
||||
dragging.value = !dragging.value
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveGraphqlRequest(folderPath, requestIndex, props.folderPath)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('modal.collections')}`"
|
||||
styles="sm:max-w-md"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #actions>
|
||||
<span>
|
||||
<tippy interactive trigger="click" theme="popover">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:icon="IconGithub"
|
||||
:label="t('import.from_gist')"
|
||||
@click="
|
||||
() => {
|
||||
readCollectionGist()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
!currentUser
|
||||
? `${t('export.require_github')}`
|
||||
: currentUser.provider !== 'github.com'
|
||||
? `${t('export.require_github')}`
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<SmartItem
|
||||
:disabled="
|
||||
!currentUser
|
||||
? true
|
||||
: currentUser.provider !== 'github.com'
|
||||
? true
|
||||
: false
|
||||
"
|
||||
:icon="IconGithub"
|
||||
:label="t('export.create_secret_gist')"
|
||||
@click="
|
||||
() => {
|
||||
createCollectionGist()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<SmartItem
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('import.from_json')"
|
||||
@click="openDialogChooseFileToImportFrom"
|
||||
/>
|
||||
<input
|
||||
ref="inputChooseFileToImportFrom"
|
||||
class="input"
|
||||
type="file"
|
||||
accept="application/json"
|
||||
@change="importFromJSON"
|
||||
/>
|
||||
<hr />
|
||||
<SmartItem
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.as_json')"
|
||||
@click="exportJSON"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axios from "axios"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import { computed, ref } from "vue"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
graphqlCollections$,
|
||||
setGraphqlCollections,
|
||||
appendGraphqlCollections,
|
||||
} from "~/newstore/collections"
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const collections = useReadonlyStream(graphqlCollections$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const inputChooseFileToImportFrom = ref<HTMLInputElement>()
|
||||
|
||||
const collectionJson = computed(() => {
|
||||
return JSON.stringify(collections.value, null, 2)
|
||||
})
|
||||
|
||||
const createCollectionGist = async () => {
|
||||
if (!currentUser.value) {
|
||||
toast.error(t("profile.no_permission").toString())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
"https://api.github.com/gists",
|
||||
{
|
||||
files: {
|
||||
"hoppscotch-collections.json": {
|
||||
content: collectionJson.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${currentUser.value.accessToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
toast.success(t("export.gist_created").toString())
|
||||
window.open(res.data.html_url)
|
||||
} catch (e) {
|
||||
toast.error(t("error.something_went_wrong").toString())
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fileImported = () => {
|
||||
toast.success(t("state.file_imported").toString())
|
||||
}
|
||||
|
||||
const failedImport = () => {
|
||||
toast.error(t("import.failed").toString())
|
||||
}
|
||||
|
||||
const readCollectionGist = async () => {
|
||||
const gist = prompt(t("import.gist_url").toString())
|
||||
if (!gist) return
|
||||
|
||||
try {
|
||||
const { files } = (await axios.get(
|
||||
`https://api.github.com/gists/${gist.split("/").pop()}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
}
|
||||
)) as {
|
||||
files: {
|
||||
[fileName: string]: {
|
||||
content: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collections = JSON.parse(Object.values(files)[0].content)
|
||||
setGraphqlCollections(collections)
|
||||
fileImported()
|
||||
} catch (e) {
|
||||
failedImport()
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const openDialogChooseFileToImportFrom = () => {
|
||||
if (inputChooseFileToImportFrom.value)
|
||||
inputChooseFileToImportFrom.value.click()
|
||||
}
|
||||
|
||||
const importFromJSON = () => {
|
||||
if (!inputChooseFileToImportFrom.value) return
|
||||
|
||||
if (
|
||||
!inputChooseFileToImportFrom.value.files ||
|
||||
inputChooseFileToImportFrom.value.files.length === 0
|
||||
) {
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = ({ target }) => {
|
||||
const content = target!.result as string | null
|
||||
|
||||
if (!content) {
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const collections = JSON.parse(content)
|
||||
if (collections[0]) {
|
||||
const [name, folders, requests] = Object.keys(collections[0])
|
||||
if (name === "name" && folders === "folders" && requests === "requests") {
|
||||
// Do nothing
|
||||
}
|
||||
} else {
|
||||
failedImport()
|
||||
return
|
||||
}
|
||||
appendGraphqlCollections(collections)
|
||||
fileImported()
|
||||
}
|
||||
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
|
||||
inputChooseFileToImportFrom.value.value = ""
|
||||
}
|
||||
|
||||
const exportJSON = () => {
|
||||
const dataToWrite = collectionJson.value
|
||||
const file = new Blob([dataToWrite], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
// TODO: get uri from meta
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
toast.success(t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover.stop
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<component
|
||||
:is="isSelected ? IconCheckCircle : IconFile"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex 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>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="!savingMode"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="selectRequest()"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.d="duplicate.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-request', {
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="`${t('action.duplicate')}`"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('duplicate-request', {
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_request')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeRequest"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { PropType, computed, ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
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"
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const duplicate = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
// Whether the object is selected (show the tick mark)
|
||||
picked: { type: Object, default: null },
|
||||
// Whether the request is being saved (activate 'select' event)
|
||||
savingMode: { type: Boolean, default: false },
|
||||
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
requestIndex: { type: Number, default: null },
|
||||
})
|
||||
|
||||
// TODO: Better types please
|
||||
const emit = defineEmits(["select", "edit-request", "duplicate-request"])
|
||||
|
||||
const dragging = ref(false)
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked?.pickedType === "gql-my-request" &&
|
||||
props.picked?.folderPath === props.folderPath &&
|
||||
props.picked?.requestIndex === props.requestIndex
|
||||
)
|
||||
|
||||
const pick = () => {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "gql-my-request",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const selectRequest = () => {
|
||||
if (props.savingMode) {
|
||||
pick()
|
||||
} else {
|
||||
setGQLSession({
|
||||
request: cloneDeep(
|
||||
makeGQLRequest({
|
||||
name: props.request.name,
|
||||
url: props.request.url,
|
||||
query: props.request.query,
|
||||
headers: props.request.headers,
|
||||
variables: props.request.variables,
|
||||
auth: props.request.auth,
|
||||
})
|
||||
),
|
||||
schema: "",
|
||||
response: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dragStart = ({ dataTransfer }: any) => {
|
||||
dragging.value = !dragging.value
|
||||
|
||||
dataTransfer.setData("folderPath", props.folderPath)
|
||||
dataTransfer.setData("requestIndex", props.requestIndex)
|
||||
}
|
||||
|
||||
const removeRequest = () => {
|
||||
// Cancel pick if the picked request is deleted
|
||||
if (
|
||||
props.picked &&
|
||||
props.picked.pickedType === "gql-my-request" &&
|
||||
props.picked.folderPath === props.folderPath &&
|
||||
props.picked.requestIndex === props.requestIndex
|
||||
) {
|
||||
emit("select", { picked: null })
|
||||
}
|
||||
|
||||
removeGraphqlRequest(props.folderPath, props.requestIndex)
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div :class="{ 'rounded border border-divider': savingMode }">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y divide-dividerLight border-dividerLight"
|
||||
:class="{ 'bg-primary': !savingMode }"
|
||||
>
|
||||
<input
|
||||
v-if="showCollActions"
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="t('action.search')"
|
||||
class="flex px-4 py-2 bg-transparent"
|
||||
/>
|
||||
<div class="flex justify-between flex-1">
|
||||
<ButtonSecondary
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/collections"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="showCollActions"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('modal.import_export')"
|
||||
:icon="IconArchive"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<CollectionsGraphqlCollection
|
||||
v-for="(collection, index) in filteredCollections"
|
||||
:key="`collection-${index}`"
|
||||
:picked="picked"
|
||||
:name="collection.name"
|
||||
:collection-index="index"
|
||||
:collection="collection"
|
||||
:is-filtered="filterText.length > 0"
|
||||
:saving-mode="savingMode"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@edit-folder="editFolder($event)"
|
||||
@edit-request="editRequest($event)"
|
||||
@duplicate-request="duplicateRequest($event)"
|
||||
@select-collection="$emit('use-collection', collection)"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="collections.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="t('empty.collections')"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.collections") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
outline
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
<CollectionsGraphqlAdd
|
||||
:show="showModalAdd"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
<CollectionsGraphqlEdit
|
||||
:show="showModalEdit"
|
||||
:editing-collection="editingCollection"
|
||||
:editing-collection-index="editingCollectionIndex"
|
||||
:editing-collection-name="editingCollection ? editingCollection.name : ''"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<CollectionsGraphqlAddRequest
|
||||
:show="showModalAddRequest"
|
||||
:folder-path="editingFolderPath"
|
||||
@add-request="onAddRequest($event)"
|
||||
@hide-modal="displayModalAddRequest(false)"
|
||||
/>
|
||||
<CollectionsGraphqlAddFolder
|
||||
:show="showModalAddFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
@add-folder="onAddFolder($event)"
|
||||
@hide-modal="displayModalAddFolder(false)"
|
||||
/>
|
||||
<CollectionsGraphqlEditFolder
|
||||
:show="showModalEditFolder"
|
||||
:collection-index="editingCollectionIndex"
|
||||
:folder="editingFolder"
|
||||
:folder-index="editingFolderIndex"
|
||||
:folder-path="editingFolderPath"
|
||||
:editing-folder-name="editingFolder ? editingFolder.name : ''"
|
||||
@hide-modal="displayModalEditFolder(false)"
|
||||
/>
|
||||
<CollectionsGraphqlEditRequest
|
||||
:show="showModalEditRequest"
|
||||
:folder-path="editingFolderPath"
|
||||
:request="editingRequest"
|
||||
:request-index="editingRequestIndex"
|
||||
:editing-request-name="editingRequest ? editingRequest.name : ''"
|
||||
@hide-modal="displayModalEditRequest(false)"
|
||||
/>
|
||||
<CollectionsGraphqlImportExport
|
||||
:show="showModalImportExport"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// TODO: TypeScript + Script Setup this :)
|
||||
import { defineComponent } from "vue"
|
||||
import { cloneDeep, clone } from "lodash-es"
|
||||
import {
|
||||
graphqlCollections$,
|
||||
addGraphqlFolder,
|
||||
saveGraphqlRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
// Whether to activate the ability to pick items (activates 'select' events)
|
||||
savingMode: { type: Boolean, default: false },
|
||||
picked: { type: Object, default: null },
|
||||
// Whether to show the 'New' and 'Import/Export' actions
|
||||
showCollActions: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ["select", "use-collection"],
|
||||
setup() {
|
||||
const collections = useReadonlyStream(graphqlCollections$, [], "deep")
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
return {
|
||||
collections,
|
||||
colorMode,
|
||||
t,
|
||||
IconPlus,
|
||||
IconHelpCircle,
|
||||
IconArchive,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showModalAdd: false,
|
||||
showModalEdit: false,
|
||||
showModalImportExport: false,
|
||||
showModalAddRequest: false,
|
||||
showModalAddFolder: false,
|
||||
showModalEditFolder: false,
|
||||
showModalEditRequest: false,
|
||||
editingCollection: undefined,
|
||||
editingCollectionIndex: undefined,
|
||||
editingFolder: undefined,
|
||||
editingFolderName: undefined,
|
||||
editingFolderIndex: undefined,
|
||||
editingFolderPath: undefined,
|
||||
editingRequest: undefined,
|
||||
editingRequestIndex: undefined,
|
||||
filterText: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredCollections() {
|
||||
const collections = clone(this.collections)
|
||||
|
||||
if (!this.filterText) return collections
|
||||
|
||||
const filterText = this.filterText.toLowerCase()
|
||||
const filteredCollections = []
|
||||
|
||||
for (const collection of collections) {
|
||||
const filteredRequests = []
|
||||
const filteredFolders = []
|
||||
for (const request of collection.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredRequests.push(request)
|
||||
}
|
||||
for (const folder of collection.folders) {
|
||||
const filteredFolderRequests = []
|
||||
for (const request of folder.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredFolderRequests.push(request)
|
||||
}
|
||||
if (filteredFolderRequests.length > 0) {
|
||||
const filteredFolder = Object.assign({}, folder)
|
||||
filteredFolder.requests = filteredFolderRequests
|
||||
filteredFolders.push(filteredFolder)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredRequests.length + filteredFolders.length > 0) {
|
||||
const filteredCollection = Object.assign({}, collection)
|
||||
filteredCollection.requests = filteredRequests
|
||||
filteredCollection.folders = filteredFolders
|
||||
filteredCollections.push(filteredCollection)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredCollections
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
displayModalAdd(shouldDisplay) {
|
||||
this.showModalAdd = shouldDisplay
|
||||
},
|
||||
displayModalEdit(shouldDisplay) {
|
||||
this.showModalEdit = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalImportExport(shouldDisplay) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
displayModalAddRequest(shouldDisplay) {
|
||||
this.showModalAddRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalAddFolder(shouldDisplay) {
|
||||
this.showModalAddFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditFolder(shouldDisplay) {
|
||||
this.showModalEditFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditRequest(shouldDisplay) {
|
||||
this.showModalEditRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
editCollection(collection, collectionIndex) {
|
||||
this.$data.editingCollection = collection
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.displayModalEdit(true)
|
||||
},
|
||||
onAddRequest({ name, path }) {
|
||||
const newRequest = {
|
||||
...getGQLSession().request,
|
||||
name,
|
||||
}
|
||||
|
||||
saveGraphqlRequestAs(path, newRequest)
|
||||
setGQLSession({
|
||||
request: newRequest,
|
||||
schema: "",
|
||||
response: "",
|
||||
})
|
||||
|
||||
this.displayModalAddRequest(false)
|
||||
},
|
||||
addRequest(payload) {
|
||||
const { path } = payload
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddRequest(true)
|
||||
},
|
||||
onAddFolder({ name, path }) {
|
||||
addGraphqlFolder(name, path)
|
||||
this.displayModalAddFolder(false)
|
||||
},
|
||||
addFolder(payload) {
|
||||
const { path } = payload
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddFolder(true)
|
||||
},
|
||||
editFolder(payload) {
|
||||
const { folder, folderPath } = payload
|
||||
this.editingFolder = folder
|
||||
this.editingFolderPath = folderPath
|
||||
this.displayModalEditFolder(true)
|
||||
},
|
||||
editRequest(payload) {
|
||||
const {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
} = payload
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingFolderIndex = folderIndex
|
||||
this.$data.editingFolderName = folderName
|
||||
this.$data.editingRequest = request
|
||||
this.$data.editingRequestIndex = requestIndex
|
||||
this.displayModalEditRequest(true)
|
||||
},
|
||||
resetSelectedData() {
|
||||
this.$data.editingCollection = undefined
|
||||
this.$data.editingCollectionIndex = undefined
|
||||
this.$data.editingFolder = undefined
|
||||
this.$data.editingFolderIndex = undefined
|
||||
this.$data.editingRequest = undefined
|
||||
this.$data.editingRequestIndex = undefined
|
||||
},
|
||||
duplicateRequest({ folderPath, request }) {
|
||||
saveGraphqlRequestAs(folderPath, {
|
||||
...cloneDeep(request),
|
||||
name: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
954
packages/hoppscotch-common/src/components/collections/index.vue
Normal file
954
packages/hoppscotch-common/src/components/collections/index.vue
Normal file
@@ -0,0 +1,954 @@
|
||||
<template>
|
||||
<div :class="{ 'rounded border border-divider': saveRequest }">
|
||||
<div
|
||||
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b rounded-t bg-primary border-dividerLight"
|
||||
:style="
|
||||
saveRequest ? 'top: calc(-1.35 * var(--font-size-body))' : 'top: 0'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col border-b border-dividerLight">
|
||||
<input
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="t('action.search')"
|
||||
class="py-2 pl-4 pr-2 bg-transparent"
|
||||
:disabled="collectionsType.type == 'team-collections'"
|
||||
/>
|
||||
</div>
|
||||
<CollectionsChooseType
|
||||
:collections-type="collectionsType"
|
||||
@update-collection-type="updateCollectionType"
|
||||
@update-selected-team="updateSelectedTeam"
|
||||
/>
|
||||
<div class="flex justify-between flex-1">
|
||||
<ButtonSecondary
|
||||
v-if="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
(collectionsType.selectedTeam == undefined ||
|
||||
collectionsType.selectedTeam.myRole == 'VIEWER')
|
||||
"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
:icon="IconPlus"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<span class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/collections"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:disabled="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
collectionsType.selectedTeam == undefined
|
||||
"
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<component
|
||||
:is="
|
||||
collectionsType.type == 'my-collections'
|
||||
? 'CollectionsMyCollection'
|
||||
: 'CollectionsTeamsCollection'
|
||||
"
|
||||
v-for="(collection, index) in filteredCollections"
|
||||
:key="`collection-${index}`"
|
||||
:collection-index="parseInt(index)"
|
||||
:collection="collection"
|
||||
:is-filtered="filterText.length > 0"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@edit-folder="editFolder($event)"
|
||||
@edit-request="editRequest($event)"
|
||||
@duplicate-request="duplicateRequest($event)"
|
||||
@update-team-collections="updateTeamCollections"
|
||||
@select-collection="$emit('use-collection', collection)"
|
||||
@unselect-collection="$emit('remove-collection', collection)"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-collection="removeCollection"
|
||||
@remove-request="removeRequest"
|
||||
@remove-folder="removeFolder"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes('root')"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredCollections.length === 0 && filterText.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="t('empty.collections')"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.collections") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-if="
|
||||
collectionsType.type == 'team-collections' &&
|
||||
(collectionsType.selectedTeam == undefined ||
|
||||
collectionsType.selectedTeam.myRole == 'VIEWER')
|
||||
"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('add.new')"
|
||||
class="mb-4"
|
||||
filled
|
||||
outline
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
class="mb-4"
|
||||
outline
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filterText.length !== 0 && filteredCollections.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||
</span>
|
||||
</div>
|
||||
<CollectionsAdd
|
||||
:show="showModalAdd"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="addNewRootCollection"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
<CollectionsEdit
|
||||
:show="showModalEdit"
|
||||
:editing-collection-name="
|
||||
editingCollection
|
||||
? editingCollection.name || editingCollection.title
|
||||
: ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
@submit="updateEditingCollection"
|
||||
/>
|
||||
<CollectionsAddRequest
|
||||
:show="showModalAddRequest"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-request="onAddRequest($event)"
|
||||
@hide-modal="displayModalAddRequest(false)"
|
||||
/>
|
||||
<CollectionsAddFolder
|
||||
:show="showModalAddFolder"
|
||||
:folder="editingFolder"
|
||||
:folder-path="editingFolderPath"
|
||||
:loading-state="modalLoadingState"
|
||||
@add-folder="onAddFolder($event)"
|
||||
@hide-modal="displayModalAddFolder(false)"
|
||||
/>
|
||||
<CollectionsEditFolder
|
||||
:show="showModalEditFolder"
|
||||
:editing-folder-name="
|
||||
editingFolder ? editingFolder.name || editingFolder.title : ''
|
||||
"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingFolder"
|
||||
@hide-modal="displayModalEditFolder(false)"
|
||||
/>
|
||||
<CollectionsEditRequest
|
||||
:show="showModalEditRequest"
|
||||
:editing-request-name="editingRequest ? editingRequest.name : ''"
|
||||
:loading-state="modalLoadingState"
|
||||
@submit="updateEditingRequest"
|
||||
@hide-modal="displayModalEditRequest(false)"
|
||||
/>
|
||||
<CollectionsImportExport
|
||||
:show="showModalImportExport"
|
||||
:collections-type="collectionsType"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
@update-team-collections="updateTeamCollections"
|
||||
/>
|
||||
<SmartConfirmModal
|
||||
:show="showConfirmModal"
|
||||
:title="confirmModalTitle"
|
||||
:loading-state="modalLoadingState"
|
||||
@hide-modal="showConfirmModal = false"
|
||||
@resolve="resolveConfirmModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { defineComponent, markRaw } from "vue"
|
||||
import { makeCollection } from "@hoppscotch/data"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import * as E from "fp-ts/Either"
|
||||
import CollectionsMyCollection from "./my/Collection.vue"
|
||||
import CollectionsTeamsCollection from "./teams/Collection.vue"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
|
||||
import {
|
||||
restCollections$,
|
||||
addRESTCollection,
|
||||
editRESTCollection,
|
||||
addRESTFolder,
|
||||
removeRESTCollection,
|
||||
removeRESTFolder,
|
||||
editRESTFolder,
|
||||
removeRESTRequest,
|
||||
editRESTRequest,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import {
|
||||
setRESTRequest,
|
||||
getRESTRequest,
|
||||
getRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateChildCollectionDocument,
|
||||
CreateNewRootCollectionDocument,
|
||||
CreateRequestInCollectionDocument,
|
||||
DeleteCollectionDocument,
|
||||
DeleteRequestDocument,
|
||||
RenameCollectionDocument,
|
||||
UpdateRequestDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CollectionsMyCollection,
|
||||
CollectionsTeamsCollection,
|
||||
},
|
||||
props: {
|
||||
saveRequest: Boolean,
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"update-collection",
|
||||
"update-coll-type",
|
||||
"update-team-collections",
|
||||
"select-request",
|
||||
"select",
|
||||
"use-collection",
|
||||
"remove-collection",
|
||||
],
|
||||
setup() {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
return {
|
||||
subscribeTo: subscribeToStream,
|
||||
|
||||
collections: useReadonlyStream(restCollections$, [], "deep"),
|
||||
currentUser: useReadonlyStream(currentUser$, null),
|
||||
colorMode: useColorMode(),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
IconArchive: markRaw(IconArchive),
|
||||
IconHelpCircle: markRaw(IconHelpCircle),
|
||||
IconPlus: markRaw(IconPlus),
|
||||
showModalAdd: false,
|
||||
showModalEdit: false,
|
||||
showModalImportExport: false,
|
||||
showModalAddRequest: false,
|
||||
showModalAddFolder: false,
|
||||
showModalEditFolder: false,
|
||||
showModalEditRequest: false,
|
||||
showConfirmModal: false,
|
||||
modalLoadingState: false,
|
||||
editingCollection: undefined,
|
||||
editingCollectionIndex: undefined,
|
||||
editingCollectionID: undefined,
|
||||
editingFolder: undefined,
|
||||
editingFolderName: undefined,
|
||||
editingFolderIndex: undefined,
|
||||
editingFolderPath: undefined,
|
||||
editingRequest: undefined,
|
||||
editingRequestIndex: undefined,
|
||||
confirmModalTitle: undefined,
|
||||
filterText: "",
|
||||
collectionsType: {
|
||||
type: "my-collections",
|
||||
selectedTeam: undefined,
|
||||
},
|
||||
teamCollectionAdapter: new TeamCollectionAdapter(null),
|
||||
teamCollectionsNew: [],
|
||||
loadingCollectionIDs: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredCollections() {
|
||||
const collections =
|
||||
this.collectionsType.type === "my-collections"
|
||||
? this.collections
|
||||
: this.teamCollectionsNew
|
||||
|
||||
if (!this.filterText) {
|
||||
return collections
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "team-collections") {
|
||||
return []
|
||||
}
|
||||
|
||||
const filterText = this.filterText.toLowerCase()
|
||||
const filteredCollections = []
|
||||
|
||||
for (const collection of collections) {
|
||||
const filteredRequests = []
|
||||
const filteredFolders = []
|
||||
for (const request of collection.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredRequests.push(request)
|
||||
}
|
||||
for (const folder of this.collectionsType.type === "team-collections"
|
||||
? collection.children
|
||||
: collection.folders) {
|
||||
const filteredFolderRequests = []
|
||||
for (const request of folder.requests) {
|
||||
if (request.name.toLowerCase().includes(filterText))
|
||||
filteredFolderRequests.push(request)
|
||||
}
|
||||
if (filteredFolderRequests.length > 0) {
|
||||
const filteredFolder = Object.assign({}, folder)
|
||||
filteredFolder.requests = filteredFolderRequests
|
||||
filteredFolders.push(filteredFolder)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filteredRequests.length + filteredFolders.length > 0 ||
|
||||
collection.name.toLowerCase().includes(filterText)
|
||||
) {
|
||||
const filteredCollection = Object.assign({}, collection)
|
||||
filteredCollection.requests = filteredRequests
|
||||
filteredCollection.folders = filteredFolders
|
||||
filteredCollections.push(filteredCollection)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredCollections
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
"collectionsType.type": function emitstuff() {
|
||||
this.$emit("update-collection", this.$data.collectionsType.type)
|
||||
},
|
||||
"collectionsType.selectedTeam"(value) {
|
||||
if (value?.id) this.teamCollectionAdapter.changeTeamID(value.id)
|
||||
},
|
||||
currentUser(newValue) {
|
||||
if (!newValue) this.updateCollectionType("my-collections")
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.teamCollectionAdapter.unsubscribeSubscriptions()
|
||||
},
|
||||
mounted() {
|
||||
this.subscribeTo(this.teamCollectionAdapter.collections$, (colls) => {
|
||||
this.teamCollectionsNew = cloneDeep(colls)
|
||||
})
|
||||
this.subscribeTo(
|
||||
this.teamCollectionAdapter.loadingCollections$,
|
||||
(collectionsIDs) => {
|
||||
this.loadingCollectionIDs = collectionsIDs
|
||||
}
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
updateTeamCollections() {
|
||||
// TODO: Remove this at some point
|
||||
},
|
||||
updateSelectedTeam(newSelectedTeam) {
|
||||
this.collectionsType.selectedTeam = newSelectedTeam
|
||||
this.$emit("update-coll-type", this.collectionsType)
|
||||
},
|
||||
updateCollectionType(newCollectionType) {
|
||||
this.collectionsType.type = newCollectionType
|
||||
this.$emit("update-coll-type", this.collectionsType)
|
||||
},
|
||||
// Intented to be called by the CollectionAdd modal submit event
|
||||
addNewRootCollection(name) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
addRESTCollection(
|
||||
makeCollection({
|
||||
name,
|
||||
folders: [],
|
||||
requests: [],
|
||||
})
|
||||
)
|
||||
|
||||
this.displayModalAdd(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateNewRootCollectionDocument, {
|
||||
title: name,
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("collection.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("collection.created"))
|
||||
this.displayModalAdd(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intented to be called by CollectionEdit modal submit event
|
||||
updateEditingCollection(newName) {
|
||||
if (!newName) {
|
||||
this.toast.error(this.t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
const collectionUpdated = {
|
||||
...this.editingCollection,
|
||||
name: newName,
|
||||
}
|
||||
|
||||
editRESTCollection(this.editingCollectionIndex, collectionUpdated)
|
||||
this.displayModalEdit(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingCollection.id,
|
||||
newTitle: newName,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("collection.renamed"))
|
||||
this.displayModalEdit(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intended to be called by CollectionEditFolder modal submit event
|
||||
updateEditingFolder(name) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
editRESTFolder(this.editingFolderPath, { ...this.editingFolder, name })
|
||||
this.displayModalEditFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
runMutation(RenameCollectionDocument, {
|
||||
collectionID: this.editingFolder.id,
|
||||
newTitle: name,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("folder.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("folder.renamed"))
|
||||
this.displayModalEditFolder(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
// Intented to by called by CollectionsEditRequest modal submit event
|
||||
updateEditingRequest(requestUpdateData) {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
|
||||
const requestUpdated = {
|
||||
...this.editingRequest,
|
||||
name: requestUpdateData.name || this.editingRequest.name,
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Update REST Session with the updated state
|
||||
if (
|
||||
saveCtx &&
|
||||
saveCtx.originLocation === "user-collection" &&
|
||||
saveCtx.requestIndex === this.editingRequestIndex &&
|
||||
saveCtx.folderPath === this.editingFolderPath
|
||||
) {
|
||||
setRESTRequest({
|
||||
...getRESTRequest(),
|
||||
name: requestUpdateData.name,
|
||||
})
|
||||
}
|
||||
|
||||
editRESTRequest(
|
||||
this.editingFolderPath,
|
||||
this.editingRequestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
this.displayModalEditRequest(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
|
||||
const requestName = requestUpdateData.name || this.editingRequest.name
|
||||
|
||||
// Update REST Session with the updated state
|
||||
if (
|
||||
saveCtx &&
|
||||
saveCtx.originLocation === "team-collection" &&
|
||||
saveCtx.requestID === this.editingRequestIndex
|
||||
) {
|
||||
setRESTRequest({
|
||||
...getRESTRequest(),
|
||||
name: requestUpdateData.name,
|
||||
})
|
||||
}
|
||||
|
||||
runMutation(UpdateRequestDocument, {
|
||||
data: {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
title: requestName,
|
||||
},
|
||||
requestID: this.editingRequestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("request.renamed"))
|
||||
this.$emit("update-team-collections")
|
||||
this.displayModalEditRequest(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
displayModalAdd(shouldDisplay) {
|
||||
this.showModalAdd = shouldDisplay
|
||||
},
|
||||
displayModalEdit(shouldDisplay) {
|
||||
this.showModalEdit = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalImportExport(shouldDisplay) {
|
||||
this.showModalImportExport = shouldDisplay
|
||||
},
|
||||
displayModalAddRequest(shouldDisplay) {
|
||||
this.showModalAddRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalAddFolder(shouldDisplay) {
|
||||
this.showModalAddFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditFolder(shouldDisplay) {
|
||||
this.showModalEditFolder = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayModalEditRequest(shouldDisplay) {
|
||||
this.showModalEditRequest = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
displayConfirmModal(shouldDisplay) {
|
||||
this.showConfirmModal = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) this.resetSelectedData()
|
||||
},
|
||||
editCollection(collection, collectionIndex) {
|
||||
this.$data.editingCollection = collection
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.displayModalEdit(true)
|
||||
},
|
||||
onAddFolder({ name, folder, path }) {
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
addRESTFolder(name, path)
|
||||
this.displayModalAddFolder(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateChildCollectionDocument, {
|
||||
childTitle: name,
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
if (result.left.error === "team_coll/short_title")
|
||||
this.toast.error(this.t("folder.name_length_insufficient"))
|
||||
else this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("folder.created"))
|
||||
this.displayModalAddFolder(false)
|
||||
this.$emit("update-team-collections")
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
addFolder(payload) {
|
||||
const { folder, path } = payload
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddFolder(true)
|
||||
},
|
||||
editFolder(payload) {
|
||||
const { collectionIndex, folder, folderIndex, folderPath } = payload
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderIndex = folderIndex
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.$data.collectionsType = this.collectionsType
|
||||
this.displayModalEditFolder(true)
|
||||
},
|
||||
editRequest(payload) {
|
||||
const {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
} = payload
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingFolderIndex = folderIndex
|
||||
this.$data.editingFolderName = folderName
|
||||
this.$data.editingRequest = request
|
||||
this.$data.editingRequestIndex = requestIndex
|
||||
this.editingFolderPath = folderPath
|
||||
this.$emit("select-request", requestIndex)
|
||||
this.displayModalEditRequest(true)
|
||||
},
|
||||
resetSelectedData() {
|
||||
this.$data.editingCollection = undefined
|
||||
this.$data.editingCollectionIndex = undefined
|
||||
this.$data.editingCollectionID = undefined
|
||||
this.$data.editingFolder = undefined
|
||||
this.$data.editingFolderPath = undefined
|
||||
this.$data.editingFolderIndex = undefined
|
||||
this.$data.editingRequest = undefined
|
||||
this.$data.editingRequestIndex = undefined
|
||||
|
||||
this.$data.confirmModalTitle = undefined
|
||||
},
|
||||
expandCollection(collectionID) {
|
||||
this.teamCollectionAdapter.expandCollection(collectionID)
|
||||
},
|
||||
removeCollection({ collectionIndex, collectionID }) {
|
||||
this.$data.editingCollectionIndex = collectionIndex
|
||||
this.$data.editingCollectionID = collectionID
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_collection")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveCollection() {
|
||||
const collectionIndex = this.$data.editingCollectionIndex
|
||||
const collectionID = this.$data.editingCollectionID
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if picked collection is deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-collection" &&
|
||||
this.picked.collectionIndex === collectionIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
removeRESTCollection(collectionIndex)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
|
||||
// Cancel pick if picked collection is deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-collection" &&
|
||||
this.picked.collectionID === collectionID
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(DeleteCollectionDocument, {
|
||||
collectionID,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
removeFolder({ collectionID, folder, folderPath }) {
|
||||
this.$data.editingCollectionID = collectionID
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_folder")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveFolder() {
|
||||
const folder = this.$data.editingFolder
|
||||
const folderPath = this.$data.editingFolderPath
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if picked folder was deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-folder" &&
|
||||
this.picked.folderPath === folderPath
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
removeRESTFolder(folderPath)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
|
||||
// Cancel pick if picked collection folder was deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-folder" &&
|
||||
this.picked.folderID === folder.id
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
if (this.collectionsType.selectedTeam.myRole !== "VIEWER") {
|
||||
runMutation(DeleteCollectionDocument, {
|
||||
collectionID: folder.id,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(`${this.t("state.deleted")}`)
|
||||
this.displayConfirmModal(false)
|
||||
|
||||
this.updateTeamCollections()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
removeRequest({ requestIndex, folderPath }) {
|
||||
this.$data.editingRequestIndex = requestIndex
|
||||
this.$data.editingFolderPath = folderPath
|
||||
this.confirmModalTitle = `${this.t("confirm.remove_request")}`
|
||||
|
||||
this.displayConfirmModal(true)
|
||||
},
|
||||
onRemoveRequest() {
|
||||
const requestIndex = this.$data.editingRequestIndex
|
||||
const folderPath = this.$data.editingFolderPath
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-request" &&
|
||||
this.picked.folderPath === folderPath &&
|
||||
this.picked.requestIndex === requestIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
removeRESTRequest(folderPath, requestIndex)
|
||||
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
} else if (this.collectionsType.type === "team-collections") {
|
||||
this.modalLoadingState = true
|
||||
// Cancel pick if the picked item is being deleted
|
||||
if (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-request" &&
|
||||
this.picked.requestID === requestIndex
|
||||
) {
|
||||
this.$emit("select", { picked: null })
|
||||
}
|
||||
|
||||
runMutation(DeleteRequestDocument, {
|
||||
requestID: requestIndex,
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
this.toast.success(this.t("state.deleted"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
addRequest(payload) {
|
||||
// TODO: check if the request being worked on
|
||||
// is being overwritten (selected or not)
|
||||
const { folder, path } = payload
|
||||
this.$data.editingFolder = folder
|
||||
this.$data.editingFolderPath = path
|
||||
this.displayModalAddRequest(true)
|
||||
},
|
||||
onAddRequest({ name, folder, path }) {
|
||||
const newRequest = {
|
||||
...cloneDeep(getRESTRequest()),
|
||||
name,
|
||||
}
|
||||
|
||||
if (this.collectionsType.type === "my-collections") {
|
||||
const insertionIndex = saveRESTRequestAs(path, newRequest)
|
||||
// point to it
|
||||
setRESTRequest(newRequest, {
|
||||
originLocation: "user-collection",
|
||||
folderPath: path,
|
||||
requestIndex: insertionIndex,
|
||||
})
|
||||
|
||||
this.displayModalAddRequest(false)
|
||||
} else if (
|
||||
this.collectionsType.type === "team-collections" &&
|
||||
this.collectionsType.selectedTeam.myRole !== "VIEWER"
|
||||
) {
|
||||
this.modalLoadingState = true
|
||||
runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: folder.id,
|
||||
data: {
|
||||
request: JSON.stringify(newRequest),
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
title: name,
|
||||
},
|
||||
})().then((result) => {
|
||||
this.modalLoadingState = false
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
console.error(result.left.error)
|
||||
} else {
|
||||
const { createRequestInCollection } = result.right
|
||||
// point to it
|
||||
setRESTRequest(newRequest, {
|
||||
originLocation: "team-collection",
|
||||
requestID: createRequestInCollection.id,
|
||||
collectionID: createRequestInCollection.collection.id,
|
||||
teamID: createRequestInCollection.collection.team.id,
|
||||
})
|
||||
this.displayModalAddRequest(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
duplicateRequest({ folderPath, request, collectionID }) {
|
||||
if (this.collectionsType.type === "team-collections") {
|
||||
const newReq = {
|
||||
...cloneDeep(request),
|
||||
name: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
}
|
||||
|
||||
// Error handling ?
|
||||
runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID,
|
||||
data: {
|
||||
request: JSON.stringify(newReq),
|
||||
teamID: this.collectionsType.selectedTeam.id,
|
||||
title: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
},
|
||||
})()
|
||||
} else if (this.collectionsType.type === "my-collections") {
|
||||
saveRESTRequestAs(folderPath, {
|
||||
...cloneDeep(request),
|
||||
name: `${request.name} - ${this.t("action.duplicate")}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
resolveConfirmModal(title) {
|
||||
if (title === `${this.t("confirm.remove_collection")}`)
|
||||
this.onRemoveCollection()
|
||||
else if (title === `${this.t("confirm.remove_request")}`)
|
||||
this.onRemoveRequest()
|
||||
else if (title === `${this.t("confirm.remove_folder")}`)
|
||||
this.onRemoveFolder()
|
||||
else {
|
||||
console.error(
|
||||
`Confirm modal title ${title} is not handled by the component`
|
||||
)
|
||||
this.toast.error(this.t("error.something_went_wrong"))
|
||||
this.displayConfirmModal(false)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
// request inside folder is not being deleted, you dumb fuck
|
||||
</script>
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collection.name }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-collection')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
@click="
|
||||
() => {
|
||||
exportCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<CollectionsMyFolder
|
||||
v-for="(folder, index) in collection.folders"
|
||||
:key="`folder-${index}`"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${index}`"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
:folder-name="collection.name"
|
||||
:folder-path="`${collectionIndex}`"
|
||||
:request-index="index"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
(collection.folders == undefined ||
|
||||
collection.folders.length === 0) &&
|
||||
(collection.requests == undefined ||
|
||||
collection.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { defineComponent, ref, markRaw } from "vue"
|
||||
import { moveRESTRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collectionIndex: { type: Number, default: null },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
saveRequest: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"select",
|
||||
"expand-collection",
|
||||
"add-collection",
|
||||
"remove-collection",
|
||||
"add-folder",
|
||||
"add-request",
|
||||
"edit-folder",
|
||||
"edit-request",
|
||||
"duplicate-request",
|
||||
"remove-folder",
|
||||
"remove-request",
|
||||
"select-collection",
|
||||
"unselect-collection",
|
||||
"edit-collection",
|
||||
],
|
||||
setup() {
|
||||
return {
|
||||
colorMode: useColorMode(),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
IconCircle: markRaw(IconCircle),
|
||||
IconCheckCircle: markRaw(IconCheckCircle),
|
||||
IconFilePlus: markRaw(IconFilePlus),
|
||||
IconFolderPlus: markRaw(IconFolderPlus),
|
||||
IconMoreVertical: markRaw(IconMoreVertical),
|
||||
IconEdit: markRaw(IconEdit),
|
||||
IconDownload: markRaw(IconDownload),
|
||||
IconTrash2: markRaw(IconTrash2),
|
||||
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-collection" &&
|
||||
this.picked.collectionIndex === this.collectionIndex
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
exportCollection() {
|
||||
const collectionJSON = JSON.stringify(this.collection)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${this.collection.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-collection",
|
||||
collectionIndex: this.collectionIndex,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.collection.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
},
|
||||
dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveRESTRequest(folderPath, requestIndex, `${this.collectionIndex}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ folder.name ? folder.name : folder.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-request', { path: folderPath })"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction.$el.click()"
|
||||
@keyup.n="folderAction.$el.click()"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.x="exportAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', { path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-folder', {
|
||||
folder,
|
||||
folderIndex,
|
||||
collectionIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
@click="
|
||||
() => {
|
||||
exportFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<!-- Referring to this component only (this is recursive) -->
|
||||
<Folder
|
||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
||||
:key="`subFolder-${subFolderIndex}`"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
/>
|
||||
<CollectionsMyRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
:folder-name="folder.name"
|
||||
:folder-path="folderPath"
|
||||
:request-index="index"
|
||||
:picked="picked"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
folder.folders &&
|
||||
folder.folders.length === 0 &&
|
||||
folder.requests &&
|
||||
folder.requests.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { moveRESTRequest } from "~/newstore/collections"
|
||||
|
||||
export default defineComponent({
|
||||
name: "Folder",
|
||||
props: {
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
folderPath: { type: String, default: null },
|
||||
saveRequest: Boolean,
|
||||
isFiltered: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
},
|
||||
emits: [
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"update-team",
|
||||
"remove-folder",
|
||||
"edit-request",
|
||||
"duplicate-request",
|
||||
"select",
|
||||
"remove-request",
|
||||
"update-team-collections",
|
||||
],
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<any | null>(null),
|
||||
options: ref<any | null>(null),
|
||||
requestAction: ref<any | null>(null),
|
||||
folderAction: ref<any | null>(null),
|
||||
edit: ref<any | null>(null),
|
||||
deleteAction: ref<any | null>(null),
|
||||
exportAction: ref<any | null>(null),
|
||||
t,
|
||||
toast: useToast(),
|
||||
colorMode: useColorMode(),
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconMoreVertical,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "my-folder" &&
|
||||
this.picked.folderPath === this.folderPath
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
exportFolder() {
|
||||
const folderJSON = JSON.stringify(this.folder)
|
||||
|
||||
const file = new Blob([folderJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${this.folder.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-folder",
|
||||
collectionIndex: this.collectionIndex,
|
||||
folderName: this.folder.name,
|
||||
folderPath: this.folderPath,
|
||||
},
|
||||
})
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
this.$emit("remove-folder", {
|
||||
folder: this.folder,
|
||||
folderPath: this.folderPath,
|
||||
})
|
||||
},
|
||||
dropEvent({ dataTransfer }) {
|
||||
this.dragging = !this.dragging
|
||||
const folderPath = dataTransfer.getData("folderPath")
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
moveRESTRequest(folderPath, requestIndex, this.folderPath)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover.stop
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
:class="getRequestLabelColor(request.method)"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<component
|
||||
:is="IconCheckCircle"
|
||||
v-if="isSelected"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
<span v-else class="font-semibold truncate text-tiny">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
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">
|
||||
<ButtonSecondary
|
||||
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"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.d="duplicate.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="t('action.duplicate')"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
emit('duplicate-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
folderPath,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeRequest()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@save-change="saveRequestChange"
|
||||
@discard-change="discardRequestChange"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import { ref, computed } from "vue"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
safelyExtractRESTRequest,
|
||||
translateToNewRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
getDefaultRESTRequest,
|
||||
getRESTRequest,
|
||||
restSaveContext$,
|
||||
setRESTRequest,
|
||||
setRESTSaveContext,
|
||||
getRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
|
||||
const props = defineProps<{
|
||||
request: HoppRESTRequest
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
saveRequest: boolean
|
||||
collectionsType: object
|
||||
folderPath: string
|
||||
picked?: {
|
||||
pickedType: string
|
||||
collectionIndex: number
|
||||
folderPath: string
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "select",
|
||||
data:
|
||||
| {
|
||||
picked: {
|
||||
pickedType: string
|
||||
collectionIndex: number
|
||||
folderPath: string
|
||||
folderName: string
|
||||
requestIndex: number
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
): void
|
||||
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "duplicate-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
request: HoppRESTRequest
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "edit-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string
|
||||
request: HoppRESTRequest
|
||||
folderPath: string
|
||||
requestIndex: number
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const dragging = ref(false)
|
||||
const requestMethodLabels = {
|
||||
get: "text-green-500",
|
||||
post: "text-yellow-500",
|
||||
put: "text-blue-500",
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const duplicate = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const active = useReadonlyStream(restSaveContext$, null)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked &&
|
||||
props.picked.pickedType === "my-request" &&
|
||||
props.picked.folderPath === props.folderPath &&
|
||||
props.picked.requestIndex === props.requestIndex
|
||||
)
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
active.value &&
|
||||
active.value.originLocation === "user-collection" &&
|
||||
active.value.folderPath === props.folderPath &&
|
||||
active.value.requestIndex === props.requestIndex
|
||||
)
|
||||
|
||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
if (dataTransfer) {
|
||||
dragging.value = !dragging.value
|
||||
dataTransfer.setData("folderPath", props.folderPath)
|
||||
dataTransfer.setData("requestIndex", props.requestIndex.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const getRequestLabelColor = (method: string) =>
|
||||
requestMethodLabels[
|
||||
method.toLowerCase() as keyof typeof requestMethodLabels
|
||||
] || requestMethodLabels.default
|
||||
|
||||
const setRestReq = (request: any) => {
|
||||
setRESTRequest(
|
||||
cloneDeep(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
)
|
||||
),
|
||||
{
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: cloneDeep(request),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
|
||||
const selectRequest = () => {
|
||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
||||
if (props.saveRequest) {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "my-request",
|
||||
collectionIndex: props.collectionIndex,
|
||||
folderPath: props.folderPath,
|
||||
folderName: props.folderName,
|
||||
requestIndex: props.requestIndex,
|
||||
},
|
||||
})
|
||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
||||
confirmChange.value = false
|
||||
setRestReq(props.request)
|
||||
} else if (!active.value) {
|
||||
// If the current request is the same as the request to be loaded in, there is no data loss
|
||||
const currentReq = getRESTRequest()
|
||||
|
||||
if (isEqualHoppRESTRequest(currentReq, props.request)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
const currentReqWithNoChange = active.value.req
|
||||
const currentFullReq = getRESTRequest()
|
||||
|
||||
// Check if whether user clicked the same request or not
|
||||
if (!isActive.value && currentReqWithNoChange !== undefined) {
|
||||
// Check if there is any changes done on the current request
|
||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current request to the collection */
|
||||
const saveRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
saveCurrentRequest(saveCtx)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
/** Discard changes and change the current request and context */
|
||||
const discardRequestChange = () => {
|
||||
setRestReq(props.request)
|
||||
if (!isActive.value) {
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
req: cloneDeep(props.request),
|
||||
})
|
||||
}
|
||||
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRestReq(props.request)
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
saveCurrentRequest(saveCtx)
|
||||
}
|
||||
} else if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
setRestReq(props.request)
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options!.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ collection.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-request', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction!.$el.click()"
|
||||
@keyup.n="folderAction!.$el.click()"
|
||||
@keyup.e="edit!.$el.click()"
|
||||
@keyup.delete="deleteAction!.$el.click()"
|
||||
@keyup.x="exportAction!.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', {
|
||||
folder: collection,
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-collection')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
:loading="exportLoading"
|
||||
@click="exportCollection"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeCollection()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<CollectionsTeamsFolder
|
||||
v-for="(folder, index) in collection.children"
|
||||
:key="`folder-${index}`"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${index}`"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:is-filtered="isFiltered"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request.request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
:folder-name="collection.name"
|
||||
:request-index="request.id"
|
||||
:save-request="saveRequest"
|
||||
:collection-i-d="collection.id"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
@edit-request="editRequest($event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes(collection.id)"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(collection.children == undefined ||
|
||||
collection.children.length === 0) &&
|
||||
(collection.requests == undefined ||
|
||||
collection.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import SmartItem from "@components/smart/Item.vue"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collectionIndex: { type: Number, default: null },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
saveRequest: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: [
|
||||
"edit-collection",
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"edit-request",
|
||||
"remove-folder",
|
||||
"select",
|
||||
"remove-request",
|
||||
"duplicate-request",
|
||||
"expand-collection",
|
||||
"remove-collection",
|
||||
],
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<TippyComponent | null>(null),
|
||||
options: ref<TippyComponent | null>(null),
|
||||
requestAction: ref<typeof SmartItem | null>(null),
|
||||
folderAction: ref<typeof SmartItem | null>(null),
|
||||
edit: ref<typeof SmartItem | null>(null),
|
||||
deleteAction: ref<typeof SmartItem | null>(null),
|
||||
exportAction: ref<typeof SmartItem | null>(null),
|
||||
exportLoading: ref<boolean>(false),
|
||||
t,
|
||||
toast: useToast(),
|
||||
colorMode: useColorMode(),
|
||||
|
||||
IconCheckCircle,
|
||||
IconCircle,
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
IconMoreVertical,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
selectedFolder: {},
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
pageNo: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-collection" &&
|
||||
this.picked.collectionID === this.collection.id
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async exportCollection() {
|
||||
this.exportLoading = true
|
||||
|
||||
const result = await getCompleteCollectionTree(this.collection.id)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
||||
console.log(result.left)
|
||||
this.exportLoading = false
|
||||
this.options!.tippy.hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
||||
|
||||
const collectionJSON = JSON.stringify(hoppColl)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${hoppColl.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
this.exportLoading = false
|
||||
|
||||
this.options!.tippy.hide()
|
||||
},
|
||||
editRequest(event: any) {
|
||||
this.$emit("edit-request", event)
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-collection",
|
||||
collectionID: this.collection.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-collection",
|
||||
collectionID: this.collection.id,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.collection.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeCollection() {
|
||||
this.$emit("remove-collection", {
|
||||
collectionIndex: this.collectionIndex,
|
||||
collectionID: this.collection.id,
|
||||
})
|
||||
},
|
||||
expandCollection(collectionID: string) {
|
||||
this.$emit("expand-collection", collectionID)
|
||||
},
|
||||
async dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
const moveRequestResult = await moveRESTTeamRequest(
|
||||
requestIndex,
|
||||
this.collection.id
|
||||
)()
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropEvent"
|
||||
@dragover="dragging = true"
|
||||
@drop="dragging = false"
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options!.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<component
|
||||
:is="getCollectionIcon"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="toggleShowChildren()"
|
||||
>
|
||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||
{{ folder.name ? folder.name : folder.title }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFilePlus"
|
||||
:title="t('request.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-request', { folder, path: folderPath })"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconFolderPlus"
|
||||
:title="t('folder.new')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.r="requestAction!.$el.click()"
|
||||
@keyup.n="folderAction!.$el.click()"
|
||||
@keyup.e="edit!.$el.click()"
|
||||
@keyup.delete="deleteAction!.$el.click()"
|
||||
@keyup.x="exportAction!.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="requestAction"
|
||||
:icon="IconFilePlus"
|
||||
:label="t('request.new')"
|
||||
:shortcut="['R']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-request', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="folderAction"
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('folder.new')"
|
||||
:shortcut="['N']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('add-folder', { folder, path: folderPath })
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
$emit('edit-folder', {
|
||||
folder,
|
||||
folderIndex,
|
||||
collectionIndex,
|
||||
folderPath: '',
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="exportAction"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.title')"
|
||||
:shortcut="['X']"
|
||||
:loading="exportLoading"
|
||||
@click="exportFolder"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeFolder()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showChildren || isFiltered" class="flex">
|
||||
<div
|
||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||
@click="toggleShowChildren()"
|
||||
></div>
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
<!-- Referring to this component only (this is recursive) -->
|
||||
<Folder
|
||||
v-for="(subFolder, subFolderIndex) in folder.children"
|
||||
:key="`subFolder-${subFolderIndex}`"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:collection-index="collectionIndex"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
||||
:picked="picked"
|
||||
:loading-collection-i-ds="loadingCollectionIDs"
|
||||
@add-request="$emit('add-request', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@edit-folder="$emit('edit-folder', $event)"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@update-team-collections="$emit('update-team-collections')"
|
||||
@select="$emit('select', $event)"
|
||||
@expand-collection="expandCollection"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@remove-folder="$emit('remove-folder', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<CollectionsTeamsRequest
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${index}`"
|
||||
:request="request.request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
:folder-name="folder.name"
|
||||
:request-index="request.id"
|
||||
:save-request="saveRequest"
|
||||
:collections-type="collectionsType"
|
||||
:picked="picked"
|
||||
:collection-i-d="folder.id"
|
||||
@edit-request="$emit('edit-request', $event)"
|
||||
@select="$emit('select', $event)"
|
||||
@remove-request="$emit('remove-request', $event)"
|
||||
@duplicate-request="$emit('duplicate-request', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="loadingCollectionIDs.includes(folder.id)"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
(folder.children == undefined || folder.children.length === 0) &&
|
||||
(folder.requests == undefined || folder.requests.length === 0)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.folder')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
{{ t("empty.folder") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { defineComponent, ref } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import SmartItem from "@components/smart/Item.vue"
|
||||
|
||||
export default defineComponent({
|
||||
name: "Folder",
|
||||
props: {
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
folderPath: { type: String, default: null },
|
||||
saveRequest: Boolean,
|
||||
isFiltered: Boolean,
|
||||
collectionsType: { type: Object, default: () => ({}) },
|
||||
picked: { type: Object, default: () => ({}) },
|
||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: [
|
||||
"add-request",
|
||||
"add-folder",
|
||||
"edit-folder",
|
||||
"update-team-collections",
|
||||
"edit-request",
|
||||
"remove-request",
|
||||
"duplicate-request",
|
||||
"select",
|
||||
"remove-folder",
|
||||
"expand-collection",
|
||||
],
|
||||
setup() {
|
||||
return {
|
||||
// Template refs
|
||||
tippyActions: ref<TippyComponent | null>(null),
|
||||
options: ref<TippyComponent | null>(null),
|
||||
requestAction: ref<typeof SmartItem | null>(null),
|
||||
folderAction: ref<typeof SmartItem | null>(null),
|
||||
edit: ref<typeof SmartItem | null>(null),
|
||||
deleteAction: ref<typeof SmartItem | null>(null),
|
||||
exportAction: ref<typeof SmartItem | null>(null),
|
||||
exportLoading: ref<boolean>(false),
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
colorMode: useColorMode(),
|
||||
IconFilePlus,
|
||||
IconFolderPlus,
|
||||
IconCheckCircle,
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconMoreVertical,
|
||||
IconEdit,
|
||||
IconDownload,
|
||||
IconTrash2,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showChildren: false,
|
||||
dragging: false,
|
||||
prevCursor: "",
|
||||
cursor: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSelected(): boolean {
|
||||
return (
|
||||
this.picked &&
|
||||
this.picked.pickedType === "teams-folder" &&
|
||||
this.picked.folderID === this.folder.id
|
||||
)
|
||||
},
|
||||
getCollectionIcon() {
|
||||
if (this.isSelected) return IconCheckCircle
|
||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
||||
else return IconFolder
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async exportFolder() {
|
||||
this.exportLoading = true
|
||||
|
||||
const result = await getCompleteCollectionTree(this.folder.id)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
||||
console.log(result.left)
|
||||
this.exportLoading = false
|
||||
this.options!.tippy.hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
||||
|
||||
const collectionJSON = JSON.stringify(hoppColl)
|
||||
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
a.download = `${hoppColl.name}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
this.toast.success(this.t("state.download_started").toString())
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
this.exportLoading = false
|
||||
|
||||
this.options!.tippy.hide()
|
||||
},
|
||||
toggleShowChildren() {
|
||||
if (this.$props.saveRequest)
|
||||
this.$emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-folder",
|
||||
folderID: this.folder.id,
|
||||
},
|
||||
})
|
||||
|
||||
this.$emit("expand-collection", this.$props.folder.id)
|
||||
this.showChildren = !this.showChildren
|
||||
},
|
||||
removeFolder() {
|
||||
this.$emit("remove-folder", {
|
||||
collectionsType: this.collectionsType,
|
||||
folder: this.folder,
|
||||
})
|
||||
},
|
||||
expandCollection(collectionID: number) {
|
||||
this.$emit("expand-collection", collectionID)
|
||||
},
|
||||
async dropEvent({ dataTransfer }: any) {
|
||||
this.dragging = !this.dragging
|
||||
const requestIndex = dataTransfer.getData("requestIndex")
|
||||
const moveRequestResult = await moveRESTTeamRequest(
|
||||
requestIndex,
|
||||
this.folder.id
|
||||
)()
|
||||
if (E.isLeft(moveRequestResult))
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@dragover.stop
|
||||
@dragleave="dragging = false"
|
||||
@dragend="dragging = false"
|
||||
@contextmenu.prevent="options.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
:class="getRequestLabelColor(request.method)"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<component
|
||||
:is="IconCheckCircle"
|
||||
v-if="isSelected"
|
||||
class="svg-icons"
|
||||
:class="{ 'text-accent': isSelected }"
|
||||
/>
|
||||
<span v-else class="font-semibold truncate text-tiny">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
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">
|
||||
<ButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="selectRequest()"
|
||||
/>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.e="edit.$el.click()"
|
||||
@keyup.d="duplicate.$el.click()"
|
||||
@keyup.delete="deleteAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-request', {
|
||||
collectionIndex,
|
||||
folderIndex,
|
||||
folderName,
|
||||
request,
|
||||
requestIndex,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="t('action.duplicate')"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
emit('duplicate-request', {
|
||||
request,
|
||||
requestIndex,
|
||||
collectionID,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
removeRequest()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@save-change="saveRequestChange"
|
||||
@discard-change="discardRequestChange"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { ref, computed } from "vue"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
safelyExtractRESTRequest,
|
||||
translateToNewRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
getDefaultRESTRequest,
|
||||
restSaveContext$,
|
||||
setRESTRequest,
|
||||
setRESTSaveContext,
|
||||
getRESTSaveContext,
|
||||
getRESTRequest,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { Team, UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
|
||||
const props = defineProps<{
|
||||
request: HoppRESTRequest
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName?: string
|
||||
requestIndex: string
|
||||
saveRequest: boolean
|
||||
collectionsType: {
|
||||
type: "my-collections" | "team-collections"
|
||||
selectedTeam: Team | undefined
|
||||
}
|
||||
collectionID: string
|
||||
picked?: {
|
||||
pickedType: string
|
||||
requestID: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "select",
|
||||
data:
|
||||
| {
|
||||
picked: {
|
||||
pickedType: string
|
||||
requestID: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
): void
|
||||
|
||||
(
|
||||
e: "remove-request",
|
||||
data: {
|
||||
folderPath: string | undefined
|
||||
requestIndex: string
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "edit-request",
|
||||
data: {
|
||||
collectionIndex: number
|
||||
folderIndex: number
|
||||
folderName: string | undefined
|
||||
requestIndex: string
|
||||
request: HoppRESTRequest
|
||||
}
|
||||
): void
|
||||
|
||||
(
|
||||
e: "duplicate-request",
|
||||
data: {
|
||||
collectionID: number | string
|
||||
requestIndex: string
|
||||
request: HoppRESTRequest
|
||||
}
|
||||
): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const dragging = ref(false)
|
||||
const requestMethodLabels = {
|
||||
get: "text-green-500",
|
||||
post: "text-yellow-500",
|
||||
put: "text-blue-500",
|
||||
delete: "text-red-500",
|
||||
default: "text-gray-500",
|
||||
}
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const options = ref<any | null>(null)
|
||||
const edit = ref<any | null>(null)
|
||||
const duplicate = ref<any | null>(null)
|
||||
const deleteAction = ref<any | null>(null)
|
||||
|
||||
const active = useReadonlyStream(restSaveContext$, null)
|
||||
|
||||
const isSelected = computed(
|
||||
() =>
|
||||
props.picked &&
|
||||
props.picked.pickedType === "teams-request" &&
|
||||
props.picked.requestID === props.requestIndex
|
||||
)
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
active.value &&
|
||||
active.value.originLocation === "team-collection" &&
|
||||
active.value.requestID === props.requestIndex
|
||||
)
|
||||
|
||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||
if (dataTransfer) {
|
||||
dragging.value = !dragging.value
|
||||
dataTransfer.setData("requestIndex", props.requestIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const removeRequest = () => {
|
||||
emit("remove-request", {
|
||||
folderPath: props.folderName,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const getRequestLabelColor = (method: string): string => {
|
||||
return (
|
||||
(requestMethodLabels as any)[method.toLowerCase()] ||
|
||||
requestMethodLabels.default
|
||||
)
|
||||
}
|
||||
|
||||
const setRestReq = (request: HoppRESTRequest) => {
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(
|
||||
translateToNewRequest(request),
|
||||
getDefaultRESTRequest()
|
||||
),
|
||||
{
|
||||
originLocation: "team-collection",
|
||||
requestID: props.requestIndex,
|
||||
req: request,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const selectRequest = () => {
|
||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
||||
if (props.saveRequest) {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "teams-request",
|
||||
requestID: props.requestIndex,
|
||||
},
|
||||
})
|
||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
||||
confirmChange.value = false
|
||||
setRestReq(props.request)
|
||||
} else if (!active.value) {
|
||||
confirmChange.value = true
|
||||
} else {
|
||||
const currentReqWithNoChange = active.value.req
|
||||
const currentFullReq = getRESTRequest()
|
||||
|
||||
// Check if whether user clicked the same request or not
|
||||
if (!isActive.value && currentReqWithNoChange) {
|
||||
// Check if there is any changes done on the current request
|
||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
||||
setRestReq(props.request)
|
||||
} else {
|
||||
confirmChange.value = true
|
||||
}
|
||||
} else {
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current request to the collection */
|
||||
const saveRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
saveCurrentRequest(saveCtx)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
/** Discard changes and change the current request and context */
|
||||
const discardRequestChange = () => {
|
||||
setRestReq(props.request)
|
||||
if (!isActive.value) {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: props.requestIndex,
|
||||
req: props.request,
|
||||
})
|
||||
}
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
setRestReq(props.request)
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
}
|
||||
} else if (saveCtx.originLocation === "user-collection") {
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRestReq(props.request)
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
saveCurrentRequest(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<SmartTabs
|
||||
:id="'environments_tab'"
|
||||
v-model="selectedEnvironmentTab"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'my-environments'"
|
||||
:label="`${t('environment.my_environments')}`"
|
||||
/>
|
||||
<SmartTab
|
||||
:id="'team-environments'"
|
||||
:label="`${t('environment.team_environments')}`"
|
||||
:disabled="!currentUser"
|
||||
>
|
||||
<SmartIntersection @intersecting="onTeamSelectIntersect">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
placement="bottom"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('collection.select_team')}`"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="environmentType.selectedTeam"
|
||||
:icon="IconUsers"
|
||||
:label="environmentType.selectedTeam.name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('collection.select_team')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(team, index) in myTeams"
|
||||
:key="`team-${index}`"
|
||||
:label="team.name"
|
||||
:info-icon="
|
||||
team.id === environmentType.selectedTeam?.id
|
||||
? IconDone
|
||||
: undefined
|
||||
"
|
||||
:active-info-icon="
|
||||
team.id === environmentType.selectedTeam?.id
|
||||
"
|
||||
:icon="IconUsers"
|
||||
@click="
|
||||
() => {
|
||||
updateSelectedTeam(team)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</SmartIntersection>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconUsers from "~icons/lucide/users"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type TeamData = GetMyTeamsQuery["myTeams"][number]
|
||||
|
||||
type SelectedTeam = TeamData | undefined
|
||||
|
||||
type EnvironmentTabs = "my-environments" | "team-environments"
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const selectedEnvironmentTab = ref<EnvironmentTabs>("my-environments")
|
||||
|
||||
defineProps<{
|
||||
environmentType: {
|
||||
type: "my-environments" | "team-environments"
|
||||
selectedTeam: SelectedTeam
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update-environment-type", tabID: EnvironmentTabs): void
|
||||
(e: "update-selected-team", team: SelectedTeam): void
|
||||
}>()
|
||||
|
||||
const currentUser = useReadonlyStream(currentUserInfo$, null)
|
||||
|
||||
const adapter = new TeamListAdapter(true)
|
||||
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
let teamListFetched = false
|
||||
|
||||
watch(myTeams, (teams) => {
|
||||
if (teams && !teamListFetched) {
|
||||
teamListFetched = true
|
||||
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
|
||||
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) updateSelectedTeam(team)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentUser.value,
|
||||
(user) => {
|
||||
if (!user) {
|
||||
selectedEnvironmentTab.value = "my-environments"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
})
|
||||
|
||||
const onTeamSelectIntersect = () => {
|
||||
// Load team data as soon as intersection
|
||||
adapter.fetchList()
|
||||
}
|
||||
|
||||
const updateEnvironmentType = (tabID: EnvironmentTabs) => {
|
||||
emit("update-environment-type", tabID)
|
||||
}
|
||||
|
||||
const updateSelectedTeam = (team: SelectedTeam) => {
|
||||
REMEMBERED_TEAM_ID.value = team?.id
|
||||
emit("update-selected-team", team)
|
||||
}
|
||||
|
||||
watch(selectedEnvironmentTab, (newValue: EnvironmentTabs) => {
|
||||
updateEnvironmentType(newValue)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('environment.title')}`"
|
||||
styles="sm:max-w-md"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #actions>
|
||||
<span>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:icon="IconGithub"
|
||||
:label="t('import.from_gist')"
|
||||
@click="
|
||||
() => {
|
||||
readEnvironmentGist()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
!currentUser
|
||||
? `${t('export.require_github')}`
|
||||
: currentUser.provider !== 'github.com'
|
||||
? `${t('export.require_github')}`
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<SmartItem
|
||||
:disabled="
|
||||
!currentUser
|
||||
? true
|
||||
: currentUser.provider !== 'github.com'
|
||||
? true
|
||||
: false
|
||||
"
|
||||
:icon="IconGithub"
|
||||
:label="t('export.create_secret_gist')"
|
||||
@click="
|
||||
() => {
|
||||
createEnvironmentGist()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col space-y-2">
|
||||
<SmartItem
|
||||
:icon="IconFolderPlus"
|
||||
:label="t('import.from_json')"
|
||||
@click="openDialogChooseFileToImportFrom"
|
||||
/>
|
||||
<input
|
||||
ref="inputChooseFileToImportFrom"
|
||||
class="input"
|
||||
type="file"
|
||||
accept="application/json"
|
||||
@change="importFromJSON"
|
||||
/>
|
||||
<hr />
|
||||
<SmartItem
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="IconDownload"
|
||||
:label="t('export.as_json')"
|
||||
@click="exportJSON"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import { computed, ref } from "vue"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import axios from "axios"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
environments$,
|
||||
replaceEnvironments,
|
||||
appendEnvironments,
|
||||
} from "~/newstore/environments"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
teamEnvironments?: TeamEnvironment[]
|
||||
teamId?: string | undefined
|
||||
environmentType: "MY_ENV" | "TEAM_ENV"
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const myEnvironments = useReadonlyStream(environments$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
const inputChooseFileToImportFrom = ref<HTMLInputElement>()
|
||||
|
||||
const environmentJson = computed(() => {
|
||||
if (
|
||||
props.environmentType === "TEAM_ENV" &&
|
||||
props.teamEnvironments !== undefined
|
||||
) {
|
||||
const teamEnvironments = props.teamEnvironments.map(
|
||||
(x) => x.environment as Environment
|
||||
)
|
||||
return JSON.stringify(teamEnvironments, null, 2)
|
||||
} else {
|
||||
return JSON.stringify(myEnvironments.value, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
const createEnvironmentGist = async () => {
|
||||
if (!currentUser.value) {
|
||||
toast.error(t("profile.no_permission").toString())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
"https://api.github.com/gists",
|
||||
{
|
||||
files: {
|
||||
"hoppscotch-environments.json": {
|
||||
content: environmentJson.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `token ${currentUser.value.accessToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
toast.success(t("export.gist_created").toString())
|
||||
window.open(res.html_url)
|
||||
} catch (e) {
|
||||
toast.error(t("error.something_went_wrong").toString())
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fileImported = () => {
|
||||
toast.success(t("state.file_imported").toString())
|
||||
}
|
||||
|
||||
const failedImport = () => {
|
||||
toast.error(t("import.failed").toString())
|
||||
}
|
||||
|
||||
const readEnvironmentGist = async () => {
|
||||
const gist = prompt(t("import.gist_url").toString())
|
||||
if (!gist) return
|
||||
|
||||
try {
|
||||
const { files } = (await axios.get(
|
||||
`https://api.github.com/gists/${gist.split("/").pop()}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
}
|
||||
)) as {
|
||||
files: {
|
||||
[fileName: string]: {
|
||||
content: any
|
||||
}
|
||||
}
|
||||
}
|
||||
const environments = JSON.parse(Object.values(files)[0].content)
|
||||
|
||||
if (props.environmentType === "MY_ENV") {
|
||||
replaceEnvironments(environments)
|
||||
fileImported()
|
||||
} else {
|
||||
importToTeams(environments)
|
||||
}
|
||||
} catch (e) {
|
||||
failedImport()
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const openDialogChooseFileToImportFrom = () => {
|
||||
if (inputChooseFileToImportFrom.value)
|
||||
inputChooseFileToImportFrom.value.click()
|
||||
}
|
||||
|
||||
const importToTeams = async (content: Environment[]) => {
|
||||
loading.value = true
|
||||
for (const [i, env] of content.entries()) {
|
||||
if (i === content.length - 1) {
|
||||
await pipe(
|
||||
createTeamEnvironment(
|
||||
JSON.stringify(env.variables),
|
||||
props.teamId as string,
|
||||
env.name
|
||||
),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
loading.value = false
|
||||
hideModal()
|
||||
fileImported()
|
||||
}
|
||||
)
|
||||
)()
|
||||
} else {
|
||||
await pipe(
|
||||
createTeamEnvironment(
|
||||
JSON.stringify(env.variables),
|
||||
props.teamId as string,
|
||||
env.name
|
||||
),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
// wait for all the environments to be created then fire the toast
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const importFromJSON = () => {
|
||||
if (!inputChooseFileToImportFrom.value) return
|
||||
|
||||
if (
|
||||
!inputChooseFileToImportFrom.value.files ||
|
||||
inputChooseFileToImportFrom.value.files.length === 0
|
||||
) {
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = ({ target }) => {
|
||||
const content = target!.result as string | null
|
||||
|
||||
if (!content) {
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const environments = JSON.parse(content)
|
||||
|
||||
if (
|
||||
environments._postman_variable_scope === "environment" ||
|
||||
environments._postman_variable_scope === "globals"
|
||||
) {
|
||||
importFromPostman(environments)
|
||||
} else if (environments[0]) {
|
||||
const [name, variables] = Object.keys(environments[0])
|
||||
if (name === "name" && variables === "variables") {
|
||||
// Do nothing
|
||||
}
|
||||
importFromHoppscotch(environments)
|
||||
} else {
|
||||
failedImport()
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
|
||||
inputChooseFileToImportFrom.value.value = ""
|
||||
}
|
||||
|
||||
const importFromHoppscotch = (environments: Environment[]) => {
|
||||
if (props.environmentType === "MY_ENV") {
|
||||
appendEnvironments(environments)
|
||||
fileImported()
|
||||
} else {
|
||||
importToTeams(environments)
|
||||
}
|
||||
}
|
||||
|
||||
const importFromPostman = ({
|
||||
name,
|
||||
values,
|
||||
}: {
|
||||
name: string
|
||||
values: { key: string; value: string }[]
|
||||
}) => {
|
||||
const environment: Environment = { name, variables: [] }
|
||||
values.forEach(({ key, value }) => environment.variables.push({ key, value }))
|
||||
const environments = [environment]
|
||||
importFromHoppscotch(environments)
|
||||
}
|
||||
|
||||
const exportJSON = () => {
|
||||
const dataToWrite = environmentJson.value
|
||||
const file = new Blob([dataToWrite], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
|
||||
// TODO: get uri from meta
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
toast.success(t("state.download_started").toString())
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
379
packages/hoppscotch-common/src/components/environments/index.vue
Normal file
379
packages/hoppscotch-common/src/components/environments/index.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
|
||||
>
|
||||
<tippy
|
||||
v-if="environmentType.type === 'my-environments'"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
arrow
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="
|
||||
selectedEnv.type === 'MY_ENV' && selectedEnv.index !== undefined
|
||||
"
|
||||
:label="myEnvironments[selectedEnv.index].name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('environment.select')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
role="menu"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="
|
||||
selectedEnvironmentIndex.type !== 'MY_ENV'
|
||||
? IconCheck
|
||||
: undefined
|
||||
"
|
||||
:active-info-icon="selectedEnvironmentIndex.type !== 'MY_ENV'"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = { type: 'NO_ENV_SELECTED' }
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<hr v-if="myEnvironments.length > 0" />
|
||||
<SmartItem
|
||||
v-for="(gen, index) in myEnvironments"
|
||||
:key="`gen-${index}`"
|
||||
:label="gen.name"
|
||||
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
|
||||
:active-info-icon="index === selectedEnv.index"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<tippy v-else interactive trigger="click" theme="popover" arrow>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="selectedEnv.name"
|
||||
:label="selectedEnv.name"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('environment.select')}`"
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
class="flex flex-col"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="
|
||||
selectedEnvironmentIndex.type !== 'TEAM_ENV'
|
||||
? IconCheck
|
||||
: undefined
|
||||
"
|
||||
:active-info-icon="selectedEnvironmentIndex.type !== 'TEAM_ENV'"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = { type: 'NO_ENV_SELECTED' }
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<hr v-if="teamEnvironmentList.length > 0" />
|
||||
<div
|
||||
v-if="environmentType.selectedTeam !== undefined"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(gen, index) in teamEnvironmentList"
|
||||
:key="`gen-team-${index}`"
|
||||
:label="gen.environment.name"
|
||||
:info-icon="
|
||||
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
|
||||
"
|
||||
:active-info-icon="gen.id === selectedEnv.teamEnvID"
|
||||
@click="
|
||||
() => {
|
||||
selectedEnvironmentIndex = {
|
||||
type: 'TEAM_ENV',
|
||||
teamEnvID: gen.id,
|
||||
teamID: gen.teamID,
|
||||
environment: gen.environment,
|
||||
}
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && adapterError"
|
||||
class="flex flex-col items-center py-4"
|
||||
>
|
||||
<i class="mb-4 material-icons">help_outline</i>
|
||||
{{ getErrorMessage(adapterError) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<EnvironmentsMyEnvironment
|
||||
environment-index="Global"
|
||||
:environment="globalEnvironment"
|
||||
class="border-b border-dividerLight"
|
||||
@edit-environment="editEnvironment('Global')"
|
||||
/>
|
||||
<EnvironmentsChooseType
|
||||
:environment-type="environmentType"
|
||||
@update-environment-type="updateEnvironmentType"
|
||||
@update-selected-team="updateSelectedTeam"
|
||||
/>
|
||||
</div>
|
||||
<EnvironmentsMy v-if="environmentType.type === 'my-environments'" />
|
||||
<EnvironmentsTeams
|
||||
v-if="environmentType.type === 'team-environments'"
|
||||
:team="environmentType.selectedTeam"
|
||||
:team-environments="teamEnvironmentList"
|
||||
:loading="loading"
|
||||
:adapter-error="adapterError"
|
||||
/>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
:editing-environment-index="editingEnvironmentIndex"
|
||||
:editing-variable-name="editingVariableName"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { Team } from "~/helpers/backend/graphql"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
environments$,
|
||||
globalEnv$,
|
||||
selectedEnvironmentIndex$,
|
||||
setSelectedEnvironmentIndex,
|
||||
} from "~/newstore/environments"
|
||||
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type EnvironmentType = "my-environments" | "team-environments"
|
||||
|
||||
type SelectedTeam = Team | undefined
|
||||
|
||||
type EnvironmentsChooseType = {
|
||||
type: EnvironmentType
|
||||
selectedTeam: SelectedTeam
|
||||
}
|
||||
|
||||
const environmentType = ref<EnvironmentsChooseType>({
|
||||
type: "my-environments",
|
||||
selectedTeam: undefined,
|
||||
})
|
||||
|
||||
const globalEnv = useReadonlyStream(globalEnv$, [])
|
||||
|
||||
const globalEnvironment = computed(() => ({
|
||||
name: "Global",
|
||||
variables: globalEnv.value,
|
||||
}))
|
||||
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
|
||||
environmentType.value.selectedTeam = newSelectedTeam
|
||||
}
|
||||
const updateEnvironmentType = (newEnvironmentType: EnvironmentType) => {
|
||||
environmentType.value.type = newEnvironmentType
|
||||
}
|
||||
|
||||
const adapter = new TeamEnvironmentAdapter(undefined)
|
||||
const adapterLoading = useReadonlyStream(adapter.loading$, false)
|
||||
const adapterError = useReadonlyStream(adapter.error$, null)
|
||||
const teamEnvironmentList = useReadonlyStream(adapter.teamEnvironmentList$, [])
|
||||
|
||||
const loading = computed(
|
||||
() => adapterLoading.value && teamEnvironmentList.value.length === 0
|
||||
)
|
||||
|
||||
watch(
|
||||
() => environmentType.value.selectedTeam?.id,
|
||||
(newTeamID) => {
|
||||
adapter.changeTeamID(newTeamID)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => currentUser.value,
|
||||
(newValue) => {
|
||||
if (!newValue) {
|
||||
updateEnvironmentType("my-environments")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<"Global" | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
showModalDetails.value = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) resetSelectedData()
|
||||
}
|
||||
|
||||
const editEnvironment = (environmentIndex: "Global") => {
|
||||
editingEnvironmentIndex.value = environmentIndex
|
||||
action.value = "edit"
|
||||
displayModalEdit(true)
|
||||
}
|
||||
|
||||
const resetSelectedData = () => {
|
||||
editingEnvironmentIndex.value = null
|
||||
}
|
||||
|
||||
defineActionHandler(
|
||||
"modals.my.environment.edit",
|
||||
({ envName, variableName }) => {
|
||||
editingVariableName.value = variableName
|
||||
envName === "Global" && editEnvironment("Global")
|
||||
}
|
||||
)
|
||||
|
||||
const myEnvironments = useReadonlyStream(environments$, [])
|
||||
|
||||
const selectedEnvironmentIndex = useStream(
|
||||
selectedEnvironmentIndex$,
|
||||
{ type: "NO_ENV_SELECTED" },
|
||||
setSelectedEnvironmentIndex
|
||||
)
|
||||
|
||||
/* Checking if there are any changes in the selected team environment when there are any updates
|
||||
in the selected team environment list */
|
||||
watch(
|
||||
() => teamEnvironmentList.value,
|
||||
(newTeamEnvironmentList) => {
|
||||
if (
|
||||
newTeamEnvironmentList.length > 0 &&
|
||||
selectedEnvironmentIndex.value.type === "TEAM_ENV"
|
||||
) {
|
||||
const selectedEnv = newTeamEnvironmentList.find(
|
||||
(env) =>
|
||||
env.id ===
|
||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID)
|
||||
)
|
||||
|
||||
if (selectedEnv) {
|
||||
// Checking if the currently selected environment is still the same after the new list is loaded
|
||||
const isChange = !isEqual(
|
||||
selectedEnvironmentIndex.value.environment,
|
||||
selectedEnv.environment
|
||||
)
|
||||
|
||||
if (isChange) {
|
||||
selectedEnvironmentIndex.value = {
|
||||
type: "TEAM_ENV",
|
||||
teamEnvID: selectedEnv.id,
|
||||
teamID: selectedEnv.teamID,
|
||||
environment: selectedEnv.environment,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const selectedEnv = computed(() => {
|
||||
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: selectedEnvironmentIndex.value.index,
|
||||
}
|
||||
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
|
||||
const teamEnv = teamEnvironmentList.value.find(
|
||||
(env) =>
|
||||
env.id ===
|
||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID)
|
||||
)
|
||||
if (teamEnv) {
|
||||
return {
|
||||
type: "TEAM_ENV",
|
||||
name: teamEnv.environment.name,
|
||||
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
||||
}
|
||||
} else {
|
||||
selectedEnvironmentIndex.value = { type: "NO_ENV_SELECTED" }
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
}
|
||||
} else {
|
||||
selectedEnvironmentIndex.value = { type: "NO_ENV_SELECTED" }
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
}
|
||||
})
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t(`environment.${action}`)"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<div class="relative flex">
|
||||
<input
|
||||
id="selectLabelEnvEdit"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="editingEnvironmentIndex === 'Global'"
|
||||
@keyup.enter="saveEnvironment"
|
||||
/>
|
||||
<label for="selectLabelEnvEdit">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<label for="variableList" class="p-4">
|
||||
{{ t("environment.variable_list") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="clearIcon"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconPlus"
|
||||
:title="t('add.new')"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evnExpandError"
|
||||
class="w-full px-4 py-2 mb-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
|
||||
>
|
||||
{{ t("environment.nested_overflow") }}
|
||||
</div>
|
||||
<div class="border rounded divide-y divide-dividerLight border-divider">
|
||||
<div
|
||||
v-for="({ id, env }, index) in vars"
|
||||
:key="`variable-${id}-${index}`"
|
||||
class="flex divide-x divide-dividerLight"
|
||||
>
|
||||
<input
|
||||
v-model="env.key"
|
||||
v-focus
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('count.variable', { count: index + 1 })}`"
|
||||
:name="'param' + index"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="env.value"
|
||||
:select-text-on-mount="env.key === editingVariableName"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:envs="liveEnvs"
|
||||
:name="'value' + index"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
id="variable"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="removeEnvironmentVariable(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="vars.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
outline
|
||||
@click="saveEnvironment"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { clone } from "lodash-es"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import { Environment, parseTemplateStringE } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import {
|
||||
createEnvironment,
|
||||
environments$,
|
||||
getEnvironment,
|
||||
getGlobalVariables,
|
||||
globalEnv$,
|
||||
setGlobalEnvVariables,
|
||||
setSelectedEnvironmentIndex,
|
||||
updateEnvironment,
|
||||
} from "~/newstore/environments"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
|
||||
type EnvironmentVariable = {
|
||||
id: number
|
||||
env: {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
action: "edit" | "new"
|
||||
editingEnvironmentIndex: number | "Global" | null
|
||||
editingVariableName: string | null
|
||||
envVars?: () => Environment["variables"]
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
action: "edit",
|
||||
editingEnvironmentIndex: null,
|
||||
editingVariableName: null,
|
||||
envVars: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const name = ref<string | null>(null)
|
||||
const vars = ref<EnvironmentVariable[]>([
|
||||
{ id: idTicker.value++, env: { key: "", value: "" } },
|
||||
])
|
||||
|
||||
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
|
||||
IconTrash2,
|
||||
1000
|
||||
)
|
||||
|
||||
const globalVars = useReadonlyStream(globalEnv$, [])
|
||||
|
||||
const workingEnv = computed(() => {
|
||||
if (props.editingEnvironmentIndex === "Global") {
|
||||
return {
|
||||
name: "Global",
|
||||
variables: getGlobalVariables(),
|
||||
} as Environment
|
||||
} else if (props.action === "new") {
|
||||
return {
|
||||
name: "",
|
||||
variables: props.envVars(),
|
||||
}
|
||||
} else if (props.editingEnvironmentIndex !== null) {
|
||||
return getEnvironment({
|
||||
type: "MY_ENV",
|
||||
index: props.editingEnvironmentIndex,
|
||||
})
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const envList = useReadonlyStream(environments$, []) || props.envVars()
|
||||
|
||||
const evnExpandError = computed(() => {
|
||||
const variables = pipe(
|
||||
vars.value,
|
||||
A.map((e) => e.env)
|
||||
)
|
||||
|
||||
return pipe(
|
||||
variables,
|
||||
A.exists(({ value }) => E.isLeft(parseTemplateStringE(value, variables)))
|
||||
)
|
||||
})
|
||||
|
||||
const liveEnvs = computed(() => {
|
||||
if (evnExpandError.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (props.editingEnvironmentIndex === "Global") {
|
||||
return [...vars.value.map((x) => ({ ...x.env, source: name.value! }))]
|
||||
} else {
|
||||
return [
|
||||
...vars.value.map((x) => ({ ...x.env, source: name.value! })),
|
||||
...globalVars.value.map((x) => ({ ...x, source: "Global" })),
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
name.value = workingEnv.value?.name ?? null
|
||||
vars.value = pipe(
|
||||
workingEnv.value?.variables ?? [],
|
||||
A.map((e) => ({
|
||||
id: idTicker.value++,
|
||||
env: clone(e),
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
vars.value = []
|
||||
clearIcon.value = IconDone
|
||||
toast.success(`${t("state.cleared")}`)
|
||||
}
|
||||
|
||||
const addEnvironmentVariable = () => {
|
||||
vars.value.push({
|
||||
id: idTicker.value++,
|
||||
env: {
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const removeEnvironmentVariable = (index: number) => {
|
||||
vars.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveEnvironment = () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("environment.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
const filterdVariables = pipe(
|
||||
vars.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.env.key !== ""),
|
||||
O.map((e) => e.env)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const environmentUpdated: Environment = {
|
||||
name: name.value,
|
||||
variables: filterdVariables,
|
||||
}
|
||||
|
||||
if (props.action === "new") {
|
||||
// Creating a new environment
|
||||
createEnvironment(name.value)
|
||||
updateEnvironment(envList.value.length - 1, environmentUpdated)
|
||||
setSelectedEnvironmentIndex({
|
||||
type: "MY_ENV",
|
||||
index: envList.value.length - 1,
|
||||
})
|
||||
toast.success(`${t("environment.created")}`)
|
||||
} else if (props.editingEnvironmentIndex === "Global") {
|
||||
// Editing the Global environment
|
||||
setGlobalEnvVariables(environmentUpdated.variables)
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
} else if (props.editingEnvironmentIndex !== null) {
|
||||
// Editing an environment
|
||||
updateEnvironment(props.editingEnvironmentIndex, environmentUpdated)
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
}
|
||||
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@contextmenu.prevent="options!.tippy.show()"
|
||||
>
|
||||
<span
|
||||
v-if="environmentIndex === 'Global'"
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<icon-lucide-globe class="svg-icons" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<icon-lucide-layers class="svg-icons" />
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ environment.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<tippy
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.e="edit!.$el.click()"
|
||||
@keyup.d="duplicate!.$el.click()"
|
||||
@keyup.delete="
|
||||
!(environmentIndex === 'Global')
|
||||
? deleteAction!.$el.click()
|
||||
: null
|
||||
"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-environment')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="`${t('action.duplicate')}`"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
duplicateEnvironments()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
v-if="environmentIndex !== 'Global'"
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_environment')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeEnvironment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { ref } from "vue"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
deleteEnvironment,
|
||||
duplicateEnvironment,
|
||||
createEnvironment,
|
||||
setEnvironmentVariables,
|
||||
getGlobalVariables,
|
||||
environmentsStore,
|
||||
} from "~/newstore/environments"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import SmartItem from "@components/smart/Item.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
environment: Environment
|
||||
environmentIndex: number | "Global" | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "edit-environment"): void
|
||||
}>()
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
const options = ref<TippyComponent | null>(null)
|
||||
const edit = ref<typeof SmartItem | null>(null)
|
||||
const duplicate = ref<typeof SmartItem | null>(null)
|
||||
const deleteAction = ref<typeof SmartItem | null>(null)
|
||||
|
||||
const removeEnvironment = () => {
|
||||
if (props.environmentIndex === null) return
|
||||
if (props.environmentIndex !== "Global")
|
||||
deleteEnvironment(props.environmentIndex)
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
}
|
||||
|
||||
const duplicateEnvironments = () => {
|
||||
if (props.environmentIndex === null) return
|
||||
if (props.environmentIndex === "Global") {
|
||||
createEnvironment(`Global - ${t("action.duplicate")}`)
|
||||
setEnvironmentVariables(
|
||||
environmentsStore.value.environments.length - 1,
|
||||
cloneDeep(getGlobalVariables())
|
||||
)
|
||||
} else duplicateEnvironment(props.environmentIndex)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="sticky z-10 flex justify-between flex-1 flex-shrink-0 overflow-x-auto border-b top-upperPrimaryStickyFold border-dividerLight bg-primary"
|
||||
>
|
||||
<ButtonSecondary
|
||||
:icon="IconPlus"
|
||||
:label="`${t('action.new')}`"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/environments"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<EnvironmentsMyEnvironment
|
||||
v-for="(environment, index) in environments"
|
||||
:key="`environment-${index}`"
|
||||
:environment-index="index"
|
||||
:environment="environment"
|
||||
@edit-environment="editEnvironment(index)"
|
||||
/>
|
||||
<div
|
||||
v-if="environments.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
outline
|
||||
class="mb-4"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
:editing-environment-index="editingEnvironmentIndex"
|
||||
:editing-variable-name="editingVariableName"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsImportExport
|
||||
:show="showModalImportExport"
|
||||
environment-type="MY_ENV"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { environments$ } from "~/newstore/environments"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const environments = useReadonlyStream(environments$, [])
|
||||
|
||||
const showModalImportExport = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<number | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
action.value = "new"
|
||||
showModalDetails.value = shouldDisplay
|
||||
}
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
showModalDetails.value = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) resetSelectedData()
|
||||
}
|
||||
const displayModalImportExport = (shouldDisplay: boolean) => {
|
||||
showModalImportExport.value = shouldDisplay
|
||||
}
|
||||
const editEnvironment = (environmentIndex: number) => {
|
||||
editingEnvironmentIndex.value = environmentIndex
|
||||
action.value = "edit"
|
||||
displayModalEdit(true)
|
||||
}
|
||||
const resetSelectedData = () => {
|
||||
editingEnvironmentIndex.value = null
|
||||
}
|
||||
|
||||
defineActionHandler(
|
||||
"modals.my.environment.edit",
|
||||
({ envName, variableName }) => {
|
||||
editingVariableName.value = variableName
|
||||
const envIndex: number = environments.value.findIndex(
|
||||
(environment: Environment) => {
|
||||
return environment.name === envName
|
||||
}
|
||||
)
|
||||
if (envName !== "Global") editEnvironment(envIndex)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t(`environment.${action}`)"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col px-2">
|
||||
<div class="relative flex">
|
||||
<input
|
||||
id="selectLabelEnvEdit"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
:class="isViewer && 'opacity-25'"
|
||||
placeholder=""
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="isViewer"
|
||||
@keyup.enter="saveEnvironment"
|
||||
/>
|
||||
<label for="selectLabelEnvEdit">
|
||||
{{ t("action.label") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<label for="variableList" class="p-4">
|
||||
{{ t("environment.variable_list") }}
|
||||
</label>
|
||||
<div v-if="!isViewer" class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="clearIcon"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconPlus"
|
||||
:title="t('add.new')"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evnExpandError"
|
||||
class="w-full px-4 py-2 mb-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
|
||||
>
|
||||
{{ t("environment.nested_overflow") }}
|
||||
</div>
|
||||
<div class="border rounded divide-y divide-dividerLight border-divider">
|
||||
<div
|
||||
v-for="({ id, env }, index) in vars"
|
||||
:key="`variable-${id}-${index}`"
|
||||
class="flex divide-x divide-dividerLight"
|
||||
>
|
||||
<input
|
||||
v-model="env.key"
|
||||
v-focus
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:class="isViewer && 'opacity-25'"
|
||||
:placeholder="`${t('count.variable', { count: index + 1 })}`"
|
||||
:name="'param' + index"
|
||||
:disabled="isViewer"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="env.value"
|
||||
:select-text-on-mount="env.key === editingVariableName"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:envs="liveEnvs"
|
||||
:name="'value' + index"
|
||||
:readonly="isViewer"
|
||||
/>
|
||||
<div v-if="!isViewer" class="flex">
|
||||
<ButtonSecondary
|
||||
id="variable"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="removeEnvironmentVariable(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="vars.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-if="isViewer"
|
||||
disabled
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click="addEnvironmentVariable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!isViewer" #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
:loading="isLoading"
|
||||
outline
|
||||
@click="saveEnvironment"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import { parseTemplateStringE } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { clone } from "lodash-es"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
createTeamEnvironment,
|
||||
updateTeamEnvironment,
|
||||
} from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
|
||||
type EnvironmentVariable = {
|
||||
id: number
|
||||
env: {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
action: "edit" | "new"
|
||||
editingEnvironment: TeamEnvironment | null
|
||||
editingTeamId: string | undefined
|
||||
editingVariableName: string | null
|
||||
isViewer: boolean
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
action: "edit",
|
||||
editingEnvironment: null,
|
||||
editingTeamId: "",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const name = ref<string | null>(null)
|
||||
const vars = ref<EnvironmentVariable[]>([
|
||||
{ id: idTicker.value++, env: { key: "", value: "" } },
|
||||
])
|
||||
|
||||
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
|
||||
IconTrash2,
|
||||
1000
|
||||
)
|
||||
|
||||
const evnExpandError = computed(() => {
|
||||
const variables = pipe(
|
||||
vars.value,
|
||||
A.map((e) => e.env)
|
||||
)
|
||||
|
||||
return pipe(
|
||||
variables,
|
||||
A.exists(({ value }) => E.isLeft(parseTemplateStringE(value, variables)))
|
||||
)
|
||||
})
|
||||
|
||||
const liveEnvs = computed(() => {
|
||||
if (evnExpandError.value) {
|
||||
return []
|
||||
} else {
|
||||
return [...vars.value.map((x) => ({ ...x.env, source: name.value! }))]
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
if (props.editingEnvironment === null) {
|
||||
name.value = null
|
||||
vars.value = []
|
||||
} else {
|
||||
name.value = props.editingEnvironment.environment.name ?? null
|
||||
vars.value = pipe(
|
||||
props.editingEnvironment.environment.variables ?? [],
|
||||
A.map((e: { key: string; value: string }) => ({
|
||||
id: idTicker.value++,
|
||||
env: clone(e),
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
vars.value = []
|
||||
clearIcon.value = IconDone
|
||||
toast.success(`${t("state.cleared")}`)
|
||||
}
|
||||
|
||||
const addEnvironmentVariable = () => {
|
||||
vars.value.push({
|
||||
id: idTicker.value++,
|
||||
env: {
|
||||
key: "",
|
||||
value: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const removeEnvironmentVariable = (index: number) => {
|
||||
vars.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const saveEnvironment = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
if (!name.value) {
|
||||
toast.error(`${t("environment.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
|
||||
const filterdVariables = pipe(
|
||||
vars.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.env.key !== ""),
|
||||
O.map((e) => e.env)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (props.action === "new") {
|
||||
await pipe(
|
||||
createTeamEnvironment(
|
||||
JSON.stringify(filterdVariables),
|
||||
props.editingTeamId,
|
||||
name.value
|
||||
),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
hideModal()
|
||||
toast.success(`${t("environment.created")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
} else {
|
||||
if (!props.editingEnvironment) {
|
||||
console.error("No Environment Found")
|
||||
return
|
||||
}
|
||||
|
||||
await pipe(
|
||||
updateTeamEnvironment(
|
||||
JSON.stringify(filterdVariables),
|
||||
props.editingEnvironment.id,
|
||||
name.value
|
||||
),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
hideModal()
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-stretch group"
|
||||
@contextmenu.prevent="options!.tippy.show()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-4 cursor-pointer"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<icon-lucide-layers class="svg-icons" />
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
@click="emit('edit-environment')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ environment.environment.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<tippy
|
||||
v-if="!isViewer"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
arrow
|
||||
:on-shown="() => tippyActions!.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.more')"
|
||||
:icon="IconMoreVertical"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
@keyup.e="edit!.$el.click()"
|
||||
@keyup.d="duplicate!.$el.click()"
|
||||
@keyup.delete="deleteAction!.$el.click()"
|
||||
@keyup.escape="options!.tippy().hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="`${t('action.edit')}`"
|
||||
:shortcut="['E']"
|
||||
@click="
|
||||
() => {
|
||||
emit('edit-environment')
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="`${t('action.duplicate')}`"
|
||||
:shortcut="['D']"
|
||||
@click="
|
||||
() => {
|
||||
duplicateEnvironments()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="`${t('action.delete')}`"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemove = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_environment')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="removeEnvironment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
deleteTeamEnvironment,
|
||||
createDuplicateEnvironment as duplicateEnvironment,
|
||||
} from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import SmartItem from "@components/smart/Item.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
environment: TeamEnvironment
|
||||
isViewer: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "edit-environment"): void
|
||||
}>()
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
const options = ref<TippyComponent | null>(null)
|
||||
const edit = ref<typeof SmartItem | null>(null)
|
||||
const duplicate = ref<typeof SmartItem | null>(null)
|
||||
const deleteAction = ref<typeof SmartItem | null>(null)
|
||||
|
||||
const removeEnvironment = () => {
|
||||
pipe(
|
||||
deleteTeamEnvironment(props.environment.id),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
toast.success(`${t("team_environment.deleted")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const duplicateEnvironments = () => {
|
||||
pipe(
|
||||
duplicateEnvironment(props.environment.id),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
toast.success(`${t("team_environment.duplicate")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="sticky z-10 flex justify-between flex-1 flex-shrink-0 overflow-x-auto border-b top-upperSecondaryStickyFold border-dividerLight bg-primary"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-if="team === undefined || team.myRole === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
class="!rounded-none"
|
||||
:icon="IconPlus"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:icon="IconPlus"
|
||||
:label="`${t('action.new')}`"
|
||||
class="!rounded-none"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/environments"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="team !== undefined && team.myRole === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconArchive"
|
||||
:title="t('modal.import_export')"
|
||||
@click="displayModalImportExport(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && teamEnvironments.length === 0 && !adapterError"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.environments')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.environments") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-if="team === undefined || team.myRole === 'VIEWER'"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
disabled
|
||||
filled
|
||||
class="mb-4"
|
||||
:icon="IconPlus"
|
||||
:title="t('team.no_access')"
|
||||
:label="t('action.new')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
outline
|
||||
class="mb-4"
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading">
|
||||
<EnvironmentsTeamsEnvironment
|
||||
v-for="(environment, index) in JSON.parse(
|
||||
JSON.stringify(teamEnvironments)
|
||||
)"
|
||||
:key="`environment-${index}`"
|
||||
:environment="environment"
|
||||
:is-viewer="team?.myRole === 'VIEWER'"
|
||||
@edit-environment="editEnvironment(environment)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && adapterError"
|
||||
class="flex flex-col items-center py-4"
|
||||
>
|
||||
<i class="mb-4 material-icons">help_outline</i>
|
||||
{{ getErrorMessage(adapterError) }}
|
||||
</div>
|
||||
<EnvironmentsTeamsDetails
|
||||
:show="showModalDetails"
|
||||
:action="action"
|
||||
:editing-environment="editingEnvironment"
|
||||
:editing-team-id="team?.id"
|
||||
:editing-variable-name="editingVariableName"
|
||||
:is-viewer="team?.myRole === 'VIEWER'"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsImportExport
|
||||
:show="showModalImportExport"
|
||||
:team-environments="teamEnvironments"
|
||||
:team-id="team?.id"
|
||||
environment-type="TEAM_ENV"
|
||||
@hide-modal="displayModalImportExport(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconArchive from "~icons/lucide/archive"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import { Team } from "~/helpers/backend/graphql"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
type SelectedTeam = Team | undefined
|
||||
|
||||
const props = defineProps<{
|
||||
team: SelectedTeam
|
||||
teamEnvironments: TeamEnvironment[]
|
||||
adapterError: GQLError<string> | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const showModalImportExport = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironment = ref<TeamEnvironment | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
action.value = "new"
|
||||
showModalDetails.value = shouldDisplay
|
||||
}
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
showModalDetails.value = shouldDisplay
|
||||
|
||||
if (!shouldDisplay) resetSelectedData()
|
||||
}
|
||||
const displayModalImportExport = (shouldDisplay: boolean) => {
|
||||
showModalImportExport.value = shouldDisplay
|
||||
}
|
||||
const editEnvironment = (environment: TeamEnvironment | null) => {
|
||||
editingEnvironment.value = environment
|
||||
action.value = "edit"
|
||||
displayModalEdit(true)
|
||||
}
|
||||
const resetSelectedData = () => {
|
||||
editingEnvironment.value = null
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineActionHandler(
|
||||
"modals.team.environment.edit",
|
||||
({ envName, variableName }) => {
|
||||
editingVariableName.value = variableName
|
||||
const teamEnvToEdit = props.teamEnvironments.find(
|
||||
(environment) => environment.environment.name === envName
|
||||
)
|
||||
if (teamEnvToEdit) editEnvironment(teamEnvToEdit)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
287
packages/hoppscotch-common/src/components/firebase/Login.vue
Normal file
287
packages/hoppscotch-common/src/components/firebase/Login.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('auth.login_to_hoppscotch')}`"
|
||||
styles="sm:max-w-md"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="mode === 'sign-in'" class="flex flex-col space-y-2">
|
||||
<SmartItem
|
||||
:loading="signingInWithGitHub"
|
||||
:icon="IconGithub"
|
||||
:label="`${t('auth.continue_with_github')}`"
|
||||
@click="signInWithGithub"
|
||||
/>
|
||||
<SmartItem
|
||||
:loading="signingInWithGoogle"
|
||||
:icon="IconGoogle"
|
||||
:label="`${t('auth.continue_with_google')}`"
|
||||
@click="signInWithGoogle"
|
||||
/>
|
||||
<SmartItem
|
||||
:loading="signingInWithMicrosoft"
|
||||
:icon="IconMicrosoft"
|
||||
:label="`${t('auth.continue_with_microsoft')}`"
|
||||
@click="signInWithMicrosoft"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconEmail"
|
||||
:label="`${t('auth.continue_with_email')}`"
|
||||
@click="mode = 'email'"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
v-if="mode === 'email'"
|
||||
class="flex flex-col space-y-2"
|
||||
@submit.prevent="signInWithEmail"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="off"
|
||||
required
|
||||
spellcheck="false"
|
||||
autofocus
|
||||
/>
|
||||
<label for="email">
|
||||
{{ t("auth.email") }}
|
||||
</label>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
:loading="signingInWithEmail"
|
||||
type="submit"
|
||||
:label="`${t('auth.send_magic_link')}`"
|
||||
/>
|
||||
</form>
|
||||
<div v-if="mode === 'email-sent'" class="flex flex-col px-4">
|
||||
<div class="flex flex-col items-center justify-center max-w-md">
|
||||
<icon-lucide-inbox class="w-6 h-6 text-accent" />
|
||||
<h3 class="my-2 text-lg text-center">
|
||||
{{ t("auth.we_sent_magic_link") }}
|
||||
</h3>
|
||||
<p class="text-center">
|
||||
{{
|
||||
t("auth.we_sent_magic_link_description", { email: form.email })
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="mode === 'sign-in'" class="text-secondaryLight text-tiny">
|
||||
By signing in, you are agreeing to our
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
to="https://docs.hoppscotch.io/terms"
|
||||
blank
|
||||
label="Terms of Service"
|
||||
/>
|
||||
and
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
to="https://docs.hoppscotch.io/privacy"
|
||||
blank
|
||||
label="Privacy Policy"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="mode === 'email'">
|
||||
<ButtonSecondary
|
||||
:label="t('auth.all_sign_in_options')"
|
||||
:icon="IconArrowLeft"
|
||||
class="!p-0"
|
||||
@click="mode = 'sign-in'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'email-sent'"
|
||||
class="flex justify-between flex-1 text-secondaryLight"
|
||||
>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="t('auth.re_enter_email')"
|
||||
:icon="IconArrowLeft"
|
||||
@click="mode = 'email'"
|
||||
/>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="`${t('action.dismiss')}`"
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import {
|
||||
signInUserWithGoogle,
|
||||
signInUserWithGithub,
|
||||
signInUserWithMicrosoft,
|
||||
setProviderInfo,
|
||||
currentUser$,
|
||||
signInWithEmail,
|
||||
linkWithFBCredentialFromAuthError,
|
||||
getGithubCredentialFromResult,
|
||||
} from "~/helpers/fb/auth"
|
||||
import IconGithub from "~icons/auth/github"
|
||||
import IconGoogle from "~icons/auth/google"
|
||||
import IconEmail from "~icons/auth/email"
|
||||
import IconMicrosoft from "~icons/auth/microsoft"
|
||||
import IconArrowLeft from "~icons/lucide/arrow-left"
|
||||
import { setLocalConfig } from "~/newstore/localpersistence"
|
||||
import { useStreamSubscriber } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
},
|
||||
emits: ["hide-modal"],
|
||||
setup() {
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
return {
|
||||
subscribeToStream,
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
IconGithub,
|
||||
IconGoogle,
|
||||
IconEmail,
|
||||
IconMicrosoft,
|
||||
IconArrowLeft,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
email: "",
|
||||
},
|
||||
signingInWithGoogle: false,
|
||||
signingInWithGitHub: false,
|
||||
signingInWithMicrosoft: false,
|
||||
signingInWithEmail: false,
|
||||
mode: "sign-in",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.subscribeToStream(currentUser$, (user) => {
|
||||
if (user) this.hideModal()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
showLoginSuccess() {
|
||||
this.toast.success(`${this.t("auth.login_success")}`)
|
||||
},
|
||||
async signInWithGoogle() {
|
||||
this.signingInWithGoogle = true
|
||||
|
||||
try {
|
||||
await signInUserWithGoogle()
|
||||
this.showLoginSuccess()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
/*
|
||||
A auth/account-exists-with-different-credential Firebase error wont happen between Google and any other providers
|
||||
Seems Google account overwrites accounts of other providers https://github.com/firebase/firebase-android-sdk/issues/25
|
||||
*/
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
}
|
||||
|
||||
this.signingInWithGoogle = false
|
||||
},
|
||||
async signInWithGithub() {
|
||||
this.signingInWithGitHub = true
|
||||
|
||||
try {
|
||||
const result = await signInUserWithGithub()
|
||||
const credential = getGithubCredentialFromResult(result)!
|
||||
const token = credential.accessToken
|
||||
setProviderInfo(result.providerId!, token!)
|
||||
|
||||
this.showLoginSuccess()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// This user's email is already present in Firebase but with other providers, namely Google or Microsoft
|
||||
if (
|
||||
(e as any).code === "auth/account-exists-with-different-credential"
|
||||
) {
|
||||
this.toast.info(`${this.t("auth.account_exists")}`, {
|
||||
duration: 0,
|
||||
closeOnSwipe: false,
|
||||
action: {
|
||||
text: `${this.t("action.yes")}`,
|
||||
onClick: async (_, toastObject) => {
|
||||
await linkWithFBCredentialFromAuthError(e)
|
||||
this.showLoginSuccess()
|
||||
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.signingInWithGitHub = false
|
||||
},
|
||||
async signInWithMicrosoft() {
|
||||
this.signingInWithMicrosoft = true
|
||||
|
||||
try {
|
||||
await signInUserWithMicrosoft()
|
||||
this.showLoginSuccess()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
/*
|
||||
A auth/account-exists-with-different-credential Firebase error wont happen between MS with Google or Github
|
||||
If a Github account exists and user then logs in with MS email we get a "Something went wrong toast" and console errors and MS replaces GH as only provider.
|
||||
The error messages are as follows:
|
||||
FirebaseError: Firebase: Error (auth/popup-closed-by-user).
|
||||
@firebase/auth: Auth (9.6.11): INTERNAL ASSERTION FAILED: Pending promise was never set
|
||||
They may be related to https://github.com/firebase/firebaseui-web/issues/947
|
||||
*/
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
}
|
||||
|
||||
this.signingInWithMicrosoft = false
|
||||
},
|
||||
async signInWithEmail() {
|
||||
this.signingInWithEmail = true
|
||||
|
||||
const actionCodeSettings = {
|
||||
url: `${import.meta.env.VITE_BASE_URL}/enter`,
|
||||
handleCodeInApp: true,
|
||||
}
|
||||
await signInWithEmail(this.form.email, actionCodeSettings)
|
||||
.then(() => {
|
||||
this.mode = "email-sent"
|
||||
setLocalConfig("emailForSignIn", this.form.email)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
this.toast.error(e.message)
|
||||
this.signingInWithEmail = false
|
||||
})
|
||||
.finally(() => {
|
||||
this.signingInWithEmail = false
|
||||
})
|
||||
},
|
||||
hideModal() {
|
||||
this.mode = "sign-in"
|
||||
this.toast.clear()
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex" @click="OpenLogoutModal()">
|
||||
<SmartItem
|
||||
ref="logoutItem"
|
||||
:icon="IconLogOut"
|
||||
:label="`${t('auth.logout')}`"
|
||||
:outline="outline"
|
||||
:shortcut="shortcut"
|
||||
@click="OpenLogoutModal()"
|
||||
/>
|
||||
<SmartConfirmModal
|
||||
:show="confirmLogout"
|
||||
:title="`${t('confirm.logout')}`"
|
||||
@hide-modal="confirmLogout = false"
|
||||
@resolve="logout"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import IconLogOut from "~icons/lucide/log-out"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { signOutUser } from "~/helpers/fb/auth"
|
||||
|
||||
defineProps({
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shortcut: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "confirm-logout"): void
|
||||
}>()
|
||||
|
||||
const confirmLogout = ref(false)
|
||||
const logoutItem = ref<HTMLButtonElement>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await signOutUser()
|
||||
toast.success(`${t("auth.logged_out")}`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const OpenLogoutModal = () => {
|
||||
emit("confirm-logout")
|
||||
confirmLogout.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<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"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary class="pr-8 ml-2 rounded-none" :label="authName" />
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="authName === 'None' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'None'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'none'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Basic Auth"
|
||||
:icon="authName === 'Basic Auth' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Basic Auth'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'basic'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="authName === 'Bearer' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Bearer'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'bearer'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="OAuth 2.0"
|
||||
:icon="authName === 'OAuth 2.0' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'OAuth 2.0'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'oauth-2'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="API key"
|
||||
:icon="authName === 'API key' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'API key'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'api-key'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<!-- <SmartCheckbox
|
||||
:on="!URLExcludes.auth"
|
||||
@change="setExclude('auth', !$event)"
|
||||
>
|
||||
{{ t("authorization.include_in_url") }}
|
||||
</SmartCheckbox> -->
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>
|
||||
{{ t("state.enabled") }}
|
||||
</SmartCheckbox>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'none'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.authorization") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<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 class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicUsername"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicPassword"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'bearer'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'oauth-2'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="oauth2Token" placeholder="Token" />
|
||||
</div>
|
||||
<HttpOAuth2Authorization />
|
||||
</div>
|
||||
<div v-if="authType === 'api-key'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiKey" placeholder="Key" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiValue" 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">
|
||||
<ButtonSecondary
|
||||
: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()"
|
||||
>
|
||||
<SmartItem
|
||||
:icon="addTo === 'Headers' ? IconCircleDot : IconCircle"
|
||||
:active="addTo === 'Headers'"
|
||||
:label="'Headers'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Headers'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="
|
||||
addTo === 'Query params' ? IconCircleDot : IconCircle
|
||||
"
|
||||
:active="addTo === 'Query params'"
|
||||
:label="'Query params'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Query params'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.authorization") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="t('authorization.learn')"
|
||||
:icon="IconExternalLink"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const auth = useStream(
|
||||
gqlAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setGQLAuth
|
||||
)
|
||||
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 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 = {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const authTippyActions = ref<any | null>(null)
|
||||
</script>
|
||||
93
packages/hoppscotch-common/src/components/graphql/Field.vue
Normal file
93
packages/hoppscotch-common/src/components/graphql/Field.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="field-title" :class="{ 'field-highlighted': isHighlighted }">
|
||||
{{ fieldName }}
|
||||
<span v-if="fieldArgs.length > 0">
|
||||
(
|
||||
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
|
||||
{{ field.name }}:
|
||||
<GraphqlTypeLink
|
||||
:gql-type="field.type"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
/>
|
||||
<span v-if="index !== fieldArgs.length - 1">, </span>
|
||||
</span>
|
||||
) </span
|
||||
>:
|
||||
<GraphqlTypeLink
|
||||
:gql-type="gqlField.type"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="gqlField.description"
|
||||
class="py-2 text-secondaryLight field-desc"
|
||||
>
|
||||
{{ gqlField.description }}
|
||||
</div>
|
||||
<div
|
||||
v-if="gqlField.isDeprecated"
|
||||
class="inline-block px-2 py-1 my-1 text-black bg-yellow-200 rounded field-deprecated"
|
||||
>
|
||||
{{ t("state.deprecated") }}
|
||||
</div>
|
||||
<div v-if="fieldArgs.length > 0">
|
||||
<h5 class="my-2">Arguments:</h5>
|
||||
<div class="pl-4 border-l-2 border-divider">
|
||||
<div v-for="(field, index) in fieldArgs" :key="`field-${index}`">
|
||||
<span>
|
||||
{{ field.name }}:
|
||||
<GraphqlTypeLink
|
||||
:gql-type="field.type"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="py-2 text-secondaryLight field-desc"
|
||||
>
|
||||
{{ field.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// TypeScript + Script Setup this :)
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
gqlField: { type: Object, default: () => ({}) },
|
||||
jumpTypeCallback: { type: Function, default: () => ({}) },
|
||||
isHighlighted: { type: Boolean, default: false },
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldName() {
|
||||
return this.gqlField.name
|
||||
},
|
||||
|
||||
fieldArgs() {
|
||||
return this.gqlField.args || []
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-highlighted {
|
||||
@apply border-accent border-b-2;
|
||||
}
|
||||
|
||||
.field-title {
|
||||
@apply select-text;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<input
|
||||
id="url"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:disabled="connected"
|
||||
@keyup.enter="onConnectClick"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="get"
|
||||
name="get"
|
||||
:label="!connected ? t('action.connect') : t('action.disconnect')"
|
||||
class="w-32"
|
||||
@click="onConnectClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
import { GQLConnection } from "~/helpers/GQLConnection"
|
||||
import { getCurrentStrategyID } from "~/helpers/network"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { gqlHeaders$, gqlURL$, setGQLURL } from "~/newstore/GQLSession"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
conn: GQLConnection
|
||||
}>()
|
||||
|
||||
const connected = useReadonlyStream(props.conn.connected$, false)
|
||||
const headers = useReadonlyStream(gqlHeaders$, [])
|
||||
|
||||
const url = useStream(gqlURL$, "", setGQLURL)
|
||||
|
||||
const onConnectClick = () => {
|
||||
if (!connected.value) {
|
||||
props.conn.connect(url.value, headers.value as any)
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "graphql-schema",
|
||||
strategy: getCurrentStrategyID(),
|
||||
})
|
||||
} else {
|
||||
props.conn.disconnect()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,804 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-full">
|
||||
<SmartTabs
|
||||
v-model="selectedOptionTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'query'"
|
||||
:label="`${t('tab.query')}`"
|
||||
:indicator="gqlQueryString && gqlQueryString.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">
|
||||
<ButtonSecondary
|
||||
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()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
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"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLQuery()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabledQuery }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabledQuery = !linewrapEnabledQuery"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyQueryIcon"
|
||||
@click="prettifyQuery"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyQueryIcon"
|
||||
@click="copyQuery"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="queryEditor" class="flex flex-col flex-1"></div>
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'variables'"
|
||||
:label="`${t('tab.variables')}`"
|
||||
:indicator="variableString && variableString.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">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearGQLVariables()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabledVariable }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="
|
||||
linewrapEnabledVariable = !linewrapEnabledVariable
|
||||
"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyVariablesIcon"
|
||||
@click="prettifyVariableString"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyVariablesIcon"
|
||||
@click="copyVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="variableEditor" class="flex flex-col flex-1"></div>
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
: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">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<SmartAutoComplete
|
||||
: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>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteHeader(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="workingHeaders.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.headers") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
|
||||
<GraphqlAuthorization />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
<CollectionsSaveRequest
|
||||
mode="graphql"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="hideRequestModal"
|
||||
/>
|
||||
</div>
|
||||
</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"
|
||||
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 { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
|
||||
import { getCurrentStrategyID } from "~/helpers/network"
|
||||
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 { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
|
||||
type OptionTabs = "query" | "headers" | "variables" | "authorization"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const selectedOptionTab = ref<OptionTabs>("query")
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
conn: GQLConnection
|
||||
}>()
|
||||
|
||||
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 }>>([
|
||||
{
|
||||
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(
|
||||
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(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 activeGQLHeadersCount = computed(
|
||||
() =>
|
||||
headers.value.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 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 responseText = await props.conn.runQuery(
|
||||
runURL,
|
||||
runHeaders,
|
||||
runQuery,
|
||||
runVariables,
|
||||
runAuth
|
||||
)
|
||||
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}`
|
||||
completePageProgress()
|
||||
|
||||
toast.error(
|
||||
`${t("error.something_went_wrong")}. ${t("error.check_console_details")}`,
|
||||
{}
|
||||
)
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
logHoppRequestRunToAnalytics({
|
||||
platform: "graphql-query",
|
||||
strategy: getCurrentStrategyID(),
|
||||
})
|
||||
}
|
||||
|
||||
const hideRequestModal = () => {
|
||||
showSaveRequestModal.value = false
|
||||
}
|
||||
|
||||
const prettifyQuery = () => {
|
||||
try {
|
||||
gqlQueryString.value = gql.print(gql.parse(gqlQueryString.value))
|
||||
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 = ""
|
||||
}
|
||||
|
||||
const clearGQLVariables = () => {
|
||||
variableString.value = ""
|
||||
}
|
||||
|
||||
defineActionHandler("request.send-cancel", runQuery)
|
||||
defineActionHandler("request.save", saveRequest)
|
||||
defineActionHandler("request.reset", clearGQLQuery)
|
||||
</script>
|
||||
122
packages/hoppscotch-common/src/components/graphql/Response.vue
Normal file
122
packages/hoppscotch-common/src/components/graphql/Response.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
|
||||
<div
|
||||
v-if="responseString === 'loading'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div v-else-if="responseString" 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"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.title") }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadResponseIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyResponseIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="schemaEditor" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
<AppShortcutsPrompt v-else class="p-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { 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"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const responseString = useReadonlyStream(gqlResponse$, "")
|
||||
|
||||
const schemaEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
schemaEditor,
|
||||
responseString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/ld+json",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const downloadResponseIcon = refAutoReset<
|
||||
typeof IconDownload | typeof IconCheck
|
||||
>(IconDownload, 1000)
|
||||
const copyResponseIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const copyResponse = () => {
|
||||
copyToClipboard(responseString.value!)
|
||||
copyResponseIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const downloadResponse = () => {
|
||||
const dataToWrite = responseString.value
|
||||
const file = new Blob([dataToWrite!], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
downloadResponseIcon.value = IconCheck
|
||||
toast.success(`${t("state.download_started")}`)
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
</script>
|
||||
468
packages/hoppscotch-common/src/components/graphql/Sidebar.vue
Normal file
468
packages/hoppscotch-common/src/components/graphql/Sidebar.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-model="selectedNavigationTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary z-10 top-0"
|
||||
vertical
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab :id="'history'" :icon="IconClock" :label="`${t('tab.history')}`">
|
||||
<History :page="'graphql'" @use-history="handleUseHistory" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'collections'"
|
||||
:icon="IconFolder"
|
||||
:label="`${t('tab.collections')}`"
|
||||
>
|
||||
<CollectionsGraphql />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'docs'"
|
||||
:icon="IconBookOpen"
|
||||
:label="`${t('tab.documentation')}`"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
queryFields.length === 0 &&
|
||||
mutationFields.length === 0 &&
|
||||
subscriptionFields.length === 0 &&
|
||||
graphqlTypes.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_comment.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.documentation')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.documentation") }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
|
||||
>
|
||||
<input
|
||||
v-model="graphqlFieldsFilterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
class="flex flex-1 p-4 py-2 bg-transparent"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/quickstart/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SmartTabs
|
||||
v-model="selectedGqlTab"
|
||||
styles="border-t border-b border-dividerLight bg-primary sticky overflow-x-auto flex-shrink-0 z-10 top-sidebarPrimaryStickyFold"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
v-if="queryFields.length > 0"
|
||||
:id="'queries'"
|
||||
:label="`${t('tab.queries')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredQueryFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
:jump-type-callback="handleJumpToType"
|
||||
class="p-4"
|
||||
/>
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
v-if="mutationFields.length > 0"
|
||||
:id="'mutations'"
|
||||
:label="`${t('graphql.mutations')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredMutationFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
:jump-type-callback="handleJumpToType"
|
||||
class="p-4"
|
||||
/>
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
v-if="subscriptionFields.length > 0"
|
||||
:id="'subscriptions'"
|
||||
:label="`${t('graphql.subscriptions')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in filteredSubscriptionFields"
|
||||
:key="`field-${index}`"
|
||||
:gql-field="field"
|
||||
:jump-type-callback="handleJumpToType"
|
||||
class="p-4"
|
||||
/>
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
v-if="graphqlTypes.length > 0"
|
||||
:id="'types'"
|
||||
:label="`${t('tab.types')}`"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<GraphqlType
|
||||
v-for="(type, index) in filteredGraphqlTypes"
|
||||
:key="`type-${index}`"
|
||||
:gql-type="type"
|
||||
:gql-types="graphqlTypes"
|
||||
:is-highlighted="isGqlTypeHighlighted(type)"
|
||||
:highlighted-fields="getGqlTypeHighlightedFields(type)"
|
||||
:jump-type-callback="handleJumpToType"
|
||||
/>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</div>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'schema'" :icon="IconBox" :label="`${t('tab.schema')}`">
|
||||
<div
|
||||
v-if="schemaString"
|
||||
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"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("graphql.schema") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/quickstart/graphql"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="downloadSchemaIcon"
|
||||
@click="downloadSchema"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copySchemaIcon"
|
||||
@click="copySchema"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="schemaString"
|
||||
ref="schemaEditor"
|
||||
class="flex flex-col flex-1"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/blockchain.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.schema')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.schema") }}
|
||||
</span>
|
||||
</div>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconBookOpen from "~icons/lucide/book-open"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClock from "~icons/lucide/clock"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconBox from "~icons/lucide/box"
|
||||
import { computed, nextTick, reactive, ref } from "vue"
|
||||
import { GraphQLField, GraphQLType } 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"
|
||||
|
||||
type NavigationTabs = "history" | "collection" | "docs" | "schema"
|
||||
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
|
||||
|
||||
const selectedNavigationTab = ref<NavigationTabs>("history")
|
||||
const selectedGqlTab = ref<GqlTabs>("queries")
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
function isTextFoundInGraphqlFieldObject(
|
||||
text: string,
|
||||
field: GraphQLField<any, any>
|
||||
) {
|
||||
const normalizedText = text.toLowerCase()
|
||||
|
||||
const isFilterTextFoundInDescription = field.description
|
||||
? field.description.toLowerCase().includes(normalizedText)
|
||||
: false
|
||||
const isFilterTextFoundInName = field.name
|
||||
.toLowerCase()
|
||||
.includes(normalizedText)
|
||||
|
||||
return isFilterTextFoundInDescription || isFilterTextFoundInName
|
||||
}
|
||||
|
||||
function getFilteredGraphqlFields(
|
||||
filterText: string,
|
||||
fields: GraphQLField<any, any>[]
|
||||
) {
|
||||
if (!filterText) return fields
|
||||
|
||||
return fields.filter((field) =>
|
||||
isTextFoundInGraphqlFieldObject(filterText, field)
|
||||
)
|
||||
}
|
||||
|
||||
function getFilteredGraphqlTypes(filterText: string, types: GraphQLType[]) {
|
||||
if (!filterText) return types
|
||||
|
||||
return types.filter((type) => {
|
||||
const isFilterTextMatching = isTextFoundInGraphqlFieldObject(
|
||||
filterText,
|
||||
type as any
|
||||
)
|
||||
|
||||
if (isFilterTextMatching) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isFilterTextMatchingAtLeastOneField = Object.values(
|
||||
(type as any)._fields || {}
|
||||
).some((field) => isTextFoundInGraphqlFieldObject(filterText, field as any))
|
||||
|
||||
return isFilterTextMatchingAtLeastOneField
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRootType(type: GraphQLType) {
|
||||
let t: any = type
|
||||
while (t.ofType) t = t.ofType
|
||||
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
|
||||
)
|
||||
const copySchemaIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const graphqlFieldsFilterText = ref("")
|
||||
|
||||
const filteredQueryFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
queryFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredMutationFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
mutationFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredSubscriptionFields = computed(() => {
|
||||
return getFilteredGraphqlFields(
|
||||
graphqlFieldsFilterText.value,
|
||||
subscriptionFields.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const filteredGraphqlTypes = computed(() => {
|
||||
return getFilteredGraphqlTypes(
|
||||
graphqlFieldsFilterText.value,
|
||||
graphqlTypes.value as any
|
||||
)
|
||||
})
|
||||
|
||||
const isGqlTypeHighlighted = (gqlType: GraphQLType) => {
|
||||
if (!graphqlFieldsFilterText.value) return false
|
||||
|
||||
return isTextFoundInGraphqlFieldObject(
|
||||
graphqlFieldsFilterText.value,
|
||||
gqlType as any
|
||||
)
|
||||
}
|
||||
|
||||
const getGqlTypeHighlightedFields = (gqlType: GraphQLType) => {
|
||||
if (!graphqlFieldsFilterText.value) return []
|
||||
|
||||
const fields = Object.values((gqlType as any)._fields || {})
|
||||
if (!fields || fields.length === 0) return []
|
||||
|
||||
return fields.filter((field) =>
|
||||
isTextFoundInGraphqlFieldObject(graphqlFieldsFilterText.value, field as any)
|
||||
)
|
||||
}
|
||||
|
||||
const handleJumpToType = async (type: GraphQLType) => {
|
||||
selectedGqlTab.value = "types"
|
||||
await nextTick()
|
||||
|
||||
const rootTypeName = resolveRootType(type).name
|
||||
const target = document.getElementById(`type_${rootTypeName}`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
target.classList.add(
|
||||
"transition-all",
|
||||
"ring-inset",
|
||||
"ring-accentLight",
|
||||
"ring-4"
|
||||
)
|
||||
setTimeout(
|
||||
() =>
|
||||
target.classList.remove(
|
||||
"ring-inset",
|
||||
"ring-accentLight",
|
||||
"ring-4",
|
||||
"transition-all"
|
||||
),
|
||||
2000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const schemaString = useReadonlyStream(
|
||||
props.conn.schemaString$.pipe(map((x) => x ?? "")),
|
||||
""
|
||||
)
|
||||
|
||||
const schemaEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
schemaEditor,
|
||||
schemaString,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "graphql",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const downloadSchema = () => {
|
||||
const dataToWrite = JSON.stringify(schemaString.value, null, 2)
|
||||
const file = new Blob([dataToWrite], { type: "application/graphql" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.graphql`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
downloadSchemaIcon.value = IconCheck
|
||||
toast.success(`${t("state.download_started")}`)
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const copySchema = () => {
|
||||
if (!schemaString.value) return
|
||||
|
||||
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>
|
||||
109
packages/hoppscotch-common/src/components/graphql/Type.vue
Normal file
109
packages/hoppscotch-common/src/components/graphql/Type.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div :id="`type_${gqlType.name}`" class="p-4">
|
||||
<div class="type-title" :class="{ 'text-accent': isHighlighted }">
|
||||
<span v-if="isInput" class="text-accent">input </span>
|
||||
<span v-else-if="isInterface" class="text-accent">interface </span>
|
||||
<span v-else-if="isEnum" class="text-accent">enum </span>
|
||||
{{ gqlType.name }}
|
||||
</div>
|
||||
<div v-if="gqlType.description" class="py-2 text-secondaryLight type-desc">
|
||||
{{ gqlType.description }}
|
||||
</div>
|
||||
<div v-if="interfaces.length > 0">
|
||||
<h5 class="my-2">Interfaces:</h5>
|
||||
<div
|
||||
v-for="(gqlInterface, index) in interfaces"
|
||||
:key="`gqlInterface-${index}`"
|
||||
>
|
||||
<GraphqlTypeLink
|
||||
:gql-type="gqlInterface"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
class="pl-4 border-l-2 border-divider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="children.length > 0" class="mb-2">
|
||||
<h5 class="my-2">Children:</h5>
|
||||
<GraphqlTypeLink
|
||||
v-for="(child, index) in children"
|
||||
:key="`child-${index}`"
|
||||
:gql-type="child"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
class="pl-4 border-l-2 border-divider"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="gqlType.getFields">
|
||||
<h5 class="my-2">Fields:</h5>
|
||||
<GraphqlField
|
||||
v-for="(field, index) in gqlType.getFields()"
|
||||
:key="`field-${index}`"
|
||||
class="pl-4 border-l-2 border-divider"
|
||||
:gql-field="field"
|
||||
:is-highlighted="isFieldHighlighted({ field })"
|
||||
:jump-type-callback="jumpTypeCallback"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isEnum">
|
||||
<h5 class="my-2">Values:</h5>
|
||||
<div
|
||||
v-for="(value, index) in gqlType.getValues()"
|
||||
:key="`value-${index}`"
|
||||
class="pl-4 border-l-2 border-divider"
|
||||
v-text="value.name"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// TODO: TypeScript + Setup Script this at some point :)
|
||||
|
||||
import { defineComponent } from "vue"
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLInterfaceType,
|
||||
} from "graphql"
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.type-highlighted {
|
||||
@apply text-accent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<span
|
||||
:class="isScalar ? 'text-secondaryLight' : 'cursor-pointer text-accent'"
|
||||
@click="jumpToType"
|
||||
>
|
||||
{{ typeString }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { GraphQLScalarType } from "graphql"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
// eslint-disable-next-line vue/require-default-prop
|
||||
gqlType: null,
|
||||
// (typeName: string) => void
|
||||
// eslint-disable-next-line vue/require-default-prop
|
||||
jumpTypeCallback: Function,
|
||||
},
|
||||
|
||||
computed: {
|
||||
typeString() {
|
||||
return `${this.gqlType}`
|
||||
},
|
||||
isScalar() {
|
||||
return this.resolveRootType(this.gqlType) instanceof GraphQLScalarType
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
jumpToType() {
|
||||
if (this.isScalar) return
|
||||
this.jumpTypeCallback(this.gqlType)
|
||||
},
|
||||
resolveRootType(type) {
|
||||
let t = type
|
||||
while (t.ofType != null) t = t.ofType
|
||||
return t
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="flex flex-col group">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content: entry.updatedOn ? shortDateTime(entry.updatedOn) : null,
|
||||
}"
|
||||
class="flex flex-1 min-w-0 py-2 pl-4 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
data-testid="restore_history_entry"
|
||||
@click="useEntry"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ entry.request.url }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
:title="t('action.remove')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
data-testid="delete_history_entry"
|
||||
@click="emit('delete-entry')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="expand ? t('hide.more') : t('show.more')"
|
||||
:icon="expand ? IconMinimize2 : IconMaximize2"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="expand = !expand"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="!entry.star ? t('add.star') : t('remove.star')"
|
||||
:icon="entry.star ? IconStarOff : IconStar"
|
||||
color="yellow"
|
||||
:class="{ 'group-hover:inline-flex hidden': !entry.star }"
|
||||
data-testid="star_button"
|
||||
@click="emit('toggle-star')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col text-tiny">
|
||||
<span
|
||||
v-for="(line, index) in query"
|
||||
:key="`line-${index}`"
|
||||
class="px-4 font-mono truncate whitespace-pre cursor-pointer text-secondaryLight"
|
||||
data-testid="restore_history_entry"
|
||||
@click="useEntry"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
|
||||
import IconStar from "~icons/lucide/star"
|
||||
import IconStarOff from "~icons/lucide/star-off"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconMinimize2 from "~icons/lucide/minimize-2"
|
||||
import IconMaximize2 from "~icons/lucide/maximize-2"
|
||||
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
entry: GQLHistoryEntry
|
||||
showMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-entry"): void
|
||||
(e: "toggle-star"): void
|
||||
}>()
|
||||
|
||||
const expand = ref(false)
|
||||
|
||||
const query = computed(() =>
|
||||
expand.value
|
||||
? (props.entry.request.query.split("\n") as string[])
|
||||
: (props.entry.request.query
|
||||
.split("\n")
|
||||
.slice(0, 2)
|
||||
.concat(["..."]) as string[])
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
453
packages/hoppscotch-common/src/components/history/index.vue
Normal file
453
packages/hoppscotch-common/src/components/history/index.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight"
|
||||
>
|
||||
<input
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex flex-1 p-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
/>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/history"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<tippy interactive trigger="click" theme="popover">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.filter')"
|
||||
:icon="IconFilter"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div ref="tippyActions" class="flex flex-col focus:outline-none">
|
||||
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
|
||||
{{ t("action.filter") }}
|
||||
</div>
|
||||
<SmartRadioGroup
|
||||
v-model="filterSelection"
|
||||
:radios="filters"
|
||||
@update:model-value="hide()"
|
||||
/>
|
||||
<hr />
|
||||
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
|
||||
{{ t("action.group_by") }}
|
||||
</div>
|
||||
<SmartRadioGroup
|
||||
v-model="groupSelection"
|
||||
:radios="groups"
|
||||
@update:model-value="hide()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
data-testid="clear_history"
|
||||
:disabled="history.length === 0"
|
||||
:icon="IconTrash2"
|
||||
:title="t('action.clear_all')"
|
||||
@click="confirmRemove = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<details
|
||||
v-for="(
|
||||
filteredHistoryGroup, filteredHistoryGroupIndex
|
||||
) in filteredHistoryGroups"
|
||||
:key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
|
||||
class="flex flex-col"
|
||||
open
|
||||
>
|
||||
<summary
|
||||
class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary"
|
||||
>
|
||||
<icon-lucide-chevron-right class="mr-2 indicator" />
|
||||
<span
|
||||
:class="[
|
||||
{ 'capitalize-first': groupSelection === 'TIME' },
|
||||
'truncate',
|
||||
]"
|
||||
>
|
||||
{{ filteredHistoryGroupIndex }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
:title="t('action.remove')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
@click="deleteBatchHistoryEntry(filteredHistoryGroup)"
|
||||
/>
|
||||
</summary>
|
||||
<component
|
||||
:is="page === 'rest' ? HistoryRestCard : HistoryGraphqlCard"
|
||||
v-for="(entry, index) in filteredHistoryGroup"
|
||||
:id="index"
|
||||
:key="`entry-${index}`"
|
||||
:entry="entry.entry"
|
||||
:show-more="showMore"
|
||||
@toggle-star="toggleStar(entry.entry)"
|
||||
@delete-entry="deleteHistory(entry.entry)"
|
||||
@use-entry="useHistory(entry.entry)"
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
<div
|
||||
v-if="history.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/history.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.history')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.history") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
Object.keys(filteredHistoryGroups).length === 0 ||
|
||||
filteredHistory.length === 0
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="mt-2 mb-4 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ filterText || filterSelection }}"
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="t('action.clear')"
|
||||
outline
|
||||
@click="
|
||||
() => {
|
||||
filterText = ''
|
||||
filterSelection = 'ALL'
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_history')}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="clearHistory"
|
||||
/>
|
||||
<HttpReqChangeConfirmModal
|
||||
:show="confirmChange"
|
||||
@hide-modal="confirmChange = false"
|
||||
@save-change="saveRequestChange"
|
||||
@discard-change="discardRequestChange"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconFilter from "~icons/lucide/filter"
|
||||
import { computed, ref, Ref } from "vue"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
safelyExtractRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { groupBy, escapeRegExp, filter } from "lodash-es"
|
||||
import { useTimeAgo } from "@vueuse/core"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
restHistory$,
|
||||
graphqlHistory$,
|
||||
clearRESTHistory,
|
||||
clearGraphqlHistory,
|
||||
toggleGraphqlHistoryEntryStar,
|
||||
toggleRESTHistoryEntryStar,
|
||||
deleteGraphqlHistoryEntry,
|
||||
deleteRESTHistoryEntry,
|
||||
RESTHistoryEntry,
|
||||
GQLHistoryEntry,
|
||||
} from "~/newstore/history"
|
||||
import {
|
||||
getDefaultRESTRequest,
|
||||
getRESTRequest,
|
||||
getRESTSaveContext,
|
||||
setRESTRequest,
|
||||
setRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
||||
|
||||
import HistoryRestCard from "./rest/Card.vue"
|
||||
import HistoryGraphqlCard from "./graphql/Card.vue"
|
||||
|
||||
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
||||
|
||||
type TimedHistoryEntry = {
|
||||
entry: HistoryEntry
|
||||
timeAgo: Ref<string>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
page: "rest" | "graphql"
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const filterText = ref("")
|
||||
const showMore = ref(false)
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const clickedHistory = ref<HistoryEntry | null>(null)
|
||||
const confirmChange = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
|
||||
props.page === "rest" ? restHistory$ : graphqlHistory$,
|
||||
[]
|
||||
)
|
||||
|
||||
const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
|
||||
if (value === null || value === undefined) return false
|
||||
|
||||
if (typeof value === "string") return regExp.test(value)
|
||||
if (typeof value === "number") return regExp.test(value.toString())
|
||||
|
||||
if (typeof value === "object")
|
||||
return Object.values(value).some((input) =>
|
||||
deepCheckForRegex(input, regExp)
|
||||
)
|
||||
if (Array.isArray(value))
|
||||
return value.some((input) => deepCheckForRegex(input, regExp))
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const filteredHistory = computed(() =>
|
||||
pipe(
|
||||
history.value as HistoryEntry[],
|
||||
A.filter(
|
||||
(
|
||||
input
|
||||
): input is HistoryEntry & {
|
||||
updatedOn: NonNullable<HistoryEntry["updatedOn"]>
|
||||
} => {
|
||||
return (
|
||||
!!input.updatedOn &&
|
||||
(filterText.value.length === 0 ||
|
||||
deepCheckForRegex(
|
||||
input,
|
||||
new RegExp(escapeRegExp(filterText.value), "gi")
|
||||
))
|
||||
)
|
||||
}
|
||||
),
|
||||
A.map(
|
||||
(entry): TimedHistoryEntry => ({
|
||||
entry,
|
||||
timeAgo: useTimeAgo(entry.updatedOn),
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filters = computed(() => [
|
||||
{ value: "ALL" as const, label: t("filter.all") },
|
||||
{ value: "STARRED" as const, label: t("filter.starred") },
|
||||
])
|
||||
|
||||
type FilterMode = typeof filters["value"][number]["value"]
|
||||
|
||||
const filterSelection = ref<FilterMode>("ALL")
|
||||
|
||||
const groups = computed(() => [
|
||||
{ value: "TIME" as const, label: t("group.time") },
|
||||
{ value: "URL" as const, label: t("group.url") },
|
||||
])
|
||||
|
||||
type GroupMode = typeof groups["value"][number]["value"]
|
||||
|
||||
const groupSelection = ref<GroupMode>("TIME")
|
||||
|
||||
const filteredHistoryGroups = computed(() =>
|
||||
groupBy(
|
||||
filter(filteredHistory.value, (input) =>
|
||||
filterSelection.value === "STARRED" ? input.entry.star : true
|
||||
),
|
||||
(input) =>
|
||||
groupSelection.value === "TIME"
|
||||
? input.timeAgo.value
|
||||
: getAppropriateURL(input.entry)
|
||||
)
|
||||
)
|
||||
|
||||
const getAppropriateURL = (entry: HistoryEntry) => {
|
||||
if (props.page === "rest") {
|
||||
return (entry.request as HoppRESTRequest).endpoint
|
||||
} else if (props.page === "graphql") {
|
||||
return (entry.request as HoppGQLRequest).url
|
||||
}
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
if (props.page === "rest") clearRESTHistory()
|
||||
else clearGraphqlHistory()
|
||||
toast.success(`${t("state.history_deleted")}`)
|
||||
}
|
||||
|
||||
const setRestReq = (request: HoppRESTRequest | null | undefined) => {
|
||||
setRESTRequest(safelyExtractRESTRequest(request, getDefaultRESTRequest()))
|
||||
}
|
||||
|
||||
// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
|
||||
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
|
||||
const useHistory = (entry: RESTHistoryEntry) => {
|
||||
const currentFullReq = getRESTRequest()
|
||||
// Initial state trigers a popup
|
||||
if (!clickedHistory.value) {
|
||||
clickedHistory.value = entry
|
||||
confirmChange.value = true
|
||||
return
|
||||
}
|
||||
// Checks if there are any change done in current request and the history request
|
||||
if (
|
||||
!isEqualHoppRESTRequest(
|
||||
currentFullReq,
|
||||
clickedHistory.value.request as HoppRESTRequest
|
||||
)
|
||||
) {
|
||||
clickedHistory.value = entry
|
||||
confirmChange.value = true
|
||||
} else {
|
||||
props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
|
||||
clickedHistory.value = entry
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current request to the collection */
|
||||
const saveRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
saveCurrentRequest(saveCtx)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
/** Discard changes and change the current request and remove the collection context */
|
||||
const discardRequestChange = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
if (saveCtx) {
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
clickedHistory.value &&
|
||||
setRestReq(clickedHistory.value.request as HoppRESTRequest)
|
||||
confirmChange.value = false
|
||||
}
|
||||
|
||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
clickedHistory.value &&
|
||||
setRestReq(clickedHistory.value.request as HoppRESTRequest)
|
||||
setRESTSaveContext(null)
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setRESTSaveContext(null)
|
||||
saveCurrentRequest(null)
|
||||
}
|
||||
} else if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
clickedHistory.value &&
|
||||
setRestReq(clickedHistory.value.request as HoppRESTRequest)
|
||||
setRESTSaveContext(null)
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
setRESTSaveContext(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isRESTHistoryEntry = (
|
||||
entries: TimedHistoryEntry[]
|
||||
): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
|
||||
// If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
|
||||
props.page === "rest"
|
||||
|
||||
const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
|
||||
if (isRESTHistoryEntry(entries)) {
|
||||
entries.forEach((entry) => {
|
||||
deleteRESTHistoryEntry(entry.entry)
|
||||
})
|
||||
} else {
|
||||
entries.forEach((entry) => {
|
||||
deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
|
||||
})
|
||||
}
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
}
|
||||
|
||||
const deleteHistory = (entry: HistoryEntry) => {
|
||||
if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
|
||||
else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
}
|
||||
|
||||
const toggleStar = (entry: HistoryEntry) => {
|
||||
// History entry type specified because function does not know the type
|
||||
if (props.page === "rest")
|
||||
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
||||
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="flex items-stretch group">
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||
:class="entryStatus.className"
|
||||
data-testid="restore_history_entry"
|
||||
:title="`${duration}`"
|
||||
@click="emit('use-entry')"
|
||||
>
|
||||
<span class="font-semibold truncate text-tiny">
|
||||
{{ entry.request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content: entry.updatedOn ? shortDateTime(entry.updatedOn) : null,
|
||||
}"
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||
data-testid="restore_history_entry"
|
||||
@click="emit('use-entry')"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ entry.request.endpoint }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
:title="t('action.remove')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
data-testid="delete_history_entry"
|
||||
@click="emit('delete-entry')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="!entry.star ? t('add.star') : t('remove.star')"
|
||||
:class="{ 'group-hover:inline-flex hidden': !entry.star }"
|
||||
:icon="entry.star ? IconStarOff : IconStar"
|
||||
color="yellow"
|
||||
data-testid="star_button"
|
||||
@click="emit('toggle-star')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import findStatusGroup from "~/helpers/findStatusGroup"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { RESTHistoryEntry } from "~/newstore/history"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
import IconStar from "~icons/lucide/star"
|
||||
import IconStarOff from "~icons/lucide/star-off"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
|
||||
const props = defineProps<{
|
||||
entry: RESTHistoryEntry
|
||||
showMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "use-entry"): void
|
||||
(e: "delete-entry"): void
|
||||
(e: "toggle-star"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const duration = computed(() => {
|
||||
if (props.entry.responseMeta.duration) {
|
||||
const responseDuration = props.entry.responseMeta.duration
|
||||
if (!responseDuration) return ""
|
||||
|
||||
return responseDuration > 0
|
||||
? `${t("request.duration")}: ${responseDuration}ms`
|
||||
: t("error.no_duration")
|
||||
} else return t("error.no_duration")
|
||||
})
|
||||
|
||||
const entryStatus = computed(() => {
|
||||
const foundStatusGroup = findStatusGroup(props.entry.responseMeta.statusCode)
|
||||
return (
|
||||
foundStatusGroup || {
|
||||
className: "",
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
302
packages/hoppscotch-common/src/components/http/Authorization.vue
Normal file
302
packages/hoppscotch-common/src/components/http/Authorization.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary class="pr-8 ml-2 rounded-none" :label="authName" />
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="authName === 'None' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'None'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'none'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Basic Auth"
|
||||
:icon="authName === 'Basic Auth' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Basic Auth'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'basic'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="authName === 'Bearer' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'Bearer'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'bearer'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="OAuth 2.0"
|
||||
:icon="authName === 'OAuth 2.0' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'OAuth 2.0'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'oauth-2'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="API key"
|
||||
:icon="authName === 'API key' ? IconCircleDot : IconCircle"
|
||||
:active="authName === 'API key'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'api-key'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<!-- <SmartCheckbox
|
||||
:on="!URLExcludes.auth"
|
||||
@change="setExclude('auth', !$event)"
|
||||
>
|
||||
{{ $t("authorization.include_in_url") }}
|
||||
</SmartCheckbox>-->
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>{{ t("state.enabled") }}</SmartCheckbox
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'none'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.authorization") }}</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<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 class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicUsername"
|
||||
:placeholder="t('authorization.username')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="basicPassword"
|
||||
:placeholder="t('authorization.password')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'bearer'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authType === 'oauth-2'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="oauth2Token" placeholder="Token" />
|
||||
</div>
|
||||
<HttpOAuth2Authorization />
|
||||
</div>
|
||||
<div v-if="authType === 'api-key'">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiKey" placeholder="Key" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="apiValue" 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">
|
||||
<ButtonSecondary
|
||||
: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()"
|
||||
>
|
||||
<SmartItem
|
||||
:icon="addTo === 'Headers' ? IconCircleDot : IconCircle"
|
||||
:active="addTo === 'Headers'"
|
||||
:label="'Headers'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Headers'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="
|
||||
addTo === 'Query params' ? IconCircleDot : IconCircle
|
||||
"
|
||||
:active="addTo === 'Query params'"
|
||||
:label="'Query params'"
|
||||
@click="
|
||||
() => {
|
||||
addTo = 'Query params'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.authorization") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="t('authorization.learn')"
|
||||
:icon="IconExternalLink"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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, Ref } from "vue"
|
||||
import {
|
||||
HoppRESTAuthBasic,
|
||||
HoppRESTAuthBearer,
|
||||
HoppRESTAuthOAuth2,
|
||||
HoppRESTAuthAPIKey,
|
||||
} from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { restAuth$, setRESTAuth } from "~/newstore/RESTSession"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
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 authActive = pluckRef(auth, "authActive")
|
||||
const basicUsername = pluckRef(auth as Ref<HoppRESTAuthBasic>, "username")
|
||||
const basicPassword = pluckRef(auth as Ref<HoppRESTAuthBasic>, "password")
|
||||
const bearerToken = pluckRef(auth as Ref<HoppRESTAuthBearer>, "token")
|
||||
const oauth2Token = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "token")
|
||||
const apiKey = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "key")
|
||||
const apiValue = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "value")
|
||||
const addTo = pluckRef(auth as Ref<HoppRESTAuthAPIKey>, "addTo")
|
||||
if (typeof addTo.value === "undefined") {
|
||||
addTo.value = "Headers"
|
||||
apiKey.value = ""
|
||||
apiValue.value = ""
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
auth.value = {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const authTippyActions = ref<any | null>(null)
|
||||
</script>
|
||||
188
packages/hoppscotch-common/src/components/http/Body.vue
Normal file
188
packages/hoppscotch-common/src/components/http/Body.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.content_type") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
:label="contentType || t('state.none')"
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col space-y-2 divide-y focus:outline-none divide-dividerLight"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
:label="t('state.none')"
|
||||
:info-icon="contentType === null ? IconDone : null"
|
||||
:active-info-icon="contentType === null"
|
||||
@click="
|
||||
() => {
|
||||
contentType = null
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-for="(
|
||||
contentTypeItems, contentTypeItemsIndex
|
||||
) in segmentedContentTypes"
|
||||
:key="`contentTypeItems-${contentTypeItemsIndex}`"
|
||||
class="flex flex-col text-left"
|
||||
>
|
||||
<div class="flex px-4 py-2 rounded">
|
||||
<span class="font-bold text-tiny text-secondaryLight">
|
||||
{{ t(contentTypeItems.title) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<SmartItem
|
||||
v-for="(
|
||||
contentTypeItem, contentTypeIndex
|
||||
) in contentTypeItems.contentTypes"
|
||||
:key="`contentTypeItem-${contentTypeIndex}`"
|
||||
:label="contentTypeItem"
|
||||
:info-icon="
|
||||
contentTypeItem === contentType ? IconDone : null
|
||||
"
|
||||
:active-info-icon="contentTypeItem === contentType"
|
||||
@click="
|
||||
() => {
|
||||
contentType = contentTypeItem
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="t('request.override_help')"
|
||||
:label="
|
||||
overridenContentType
|
||||
? `${t('request.overriden')}: ${overridenContentType}`
|
||||
: t('request.override')
|
||||
"
|
||||
:icon="overridenContentType ? IconInfo : IconRefreshCW"
|
||||
:class="[
|
||||
'!px-1 !py-0.5',
|
||||
{
|
||||
'text-yellow-500 hover:text-yellow-500': overridenContentType,
|
||||
},
|
||||
]"
|
||||
filled
|
||||
outline
|
||||
@click="contentTypeOverride('headers')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<HttpBodyParameters v-if="contentType === 'multipart/form-data'" />
|
||||
<HttpURLEncodedParams
|
||||
v-else-if="contentType === 'application/x-www-form-urlencoded'"
|
||||
/>
|
||||
<HttpRawBody v-else-if="contentType !== null" :content-type="contentType" />
|
||||
<div
|
||||
v-if="contentType == null"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.body')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.body") }}</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="`${t('app.documentation')}`"
|
||||
to="https://docs.hoppscotch.io/features/body"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconDone from "~icons/lucide/check"
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import IconRefreshCW from "~icons/lucide/refresh-cw"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import { computed, ref } from "vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { RequestOptionTabs } from "./RequestOptions.vue"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { segmentedContentTypes } from "~/helpers/utils/contenttypes"
|
||||
import {
|
||||
restContentType$,
|
||||
restHeaders$,
|
||||
setRESTContentType,
|
||||
setRESTHeaders,
|
||||
addRESTHeader,
|
||||
} from "~/newstore/RESTSession"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change-tab", value: string): void
|
||||
}>()
|
||||
|
||||
const contentType = useStream(restContentType$, null, setRESTContentType)
|
||||
|
||||
// The functional headers list (the headers actually in the system)
|
||||
const headers = useStream(restHeaders$, [], setRESTHeaders)
|
||||
|
||||
const overridenContentType = computed(() =>
|
||||
pipe(
|
||||
headers.value,
|
||||
A.findLast((h) => h.key.toLowerCase() === "content-type" && h.active),
|
||||
O.map((h) => h.value),
|
||||
O.getOrElse(() => "")
|
||||
)
|
||||
)
|
||||
|
||||
const contentTypeOverride = (tab: RequestOptionTabs) => {
|
||||
emit("change-tab", tab)
|
||||
if (!isContentTypeAlreadyExist()) {
|
||||
addRESTHeader({
|
||||
key: "Content-Type",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isContentTypeAlreadyExist = () => {
|
||||
return pipe(
|
||||
headers.value,
|
||||
A.some((e) => e.key.toLowerCase() === "content-type")
|
||||
)
|
||||
}
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div>
|
||||
<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-upperMobileStickyFold sm:top-upperMobileTertiaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/body"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
@click="addBodyParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="workingParams"
|
||||
item-key="id"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: { entry }, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== workingParams?.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 !== workingParams?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<SmartEnvInput
|
||||
v-model="entry.key"
|
||||
:placeholder="`${t('count.parameter', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateBodyParam(index, {
|
||||
key: $event,
|
||||
value: entry.value,
|
||||
active: entry.active,
|
||||
isFile: entry.isFile,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<div v-if="entry.isFile" class="file-chips-container">
|
||||
<div class="space-x-2 file-chips-wrapper">
|
||||
<SmartFileChip
|
||||
v-for="(file, fileIndex) in entry.value"
|
||||
:key="`param-${index}-file-${fileIndex}`"
|
||||
>{{ file.name }}</SmartFileChip
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="flex flex-1">
|
||||
<SmartEnvInput
|
||||
v-model="entry.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateBodyParam(index, {
|
||||
key: entry.key,
|
||||
value: $event,
|
||||
active: entry.active,
|
||||
isFile: entry.isFile,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<label :for="`attachment${index}`" class="p-0">
|
||||
<input
|
||||
:id="`attachment${index}`"
|
||||
:name="`attachment${index}`"
|
||||
type="file"
|
||||
multiple
|
||||
class="p-1 transition cursor-pointer file:transition file:cursor-pointer text-secondaryLight hover:text-secondaryDark file:mr-2 file:py-1 file:px-4 file:rounded file:border-0 file:text-tiny text-tiny file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
|
||||
@change="setRequestAttachment(index, entry, $event)"
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
entry.hasOwnProperty('active')
|
||||
? entry.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
entry.hasOwnProperty('active')
|
||||
? entry.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateBodyParam(index, {
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
active: entry.hasOwnProperty('active')
|
||||
? !entry.active
|
||||
: false,
|
||||
isFile: entry.isFile,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteBodyParam(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="workingParams.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/upload_single_file.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.body')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.body") }}</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
filled
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addBodyParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconGripVertical from "~icons/lucide/grip-vertical"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { ref, watch } from "vue"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { FormDataKeyValue } from "@hoppscotch/data"
|
||||
import { isEqual, clone } from "lodash-es"
|
||||
import draggable from "vuedraggable"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useRESTRequestBody } from "~/newstore/RESTSession"
|
||||
|
||||
type WorkingFormDataKeyValue = { id: number; entry: FormDataKeyValue }
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body")
|
||||
|
||||
// The UI representation of the parameters list (has the empty end param)
|
||||
const workingParams = ref<WorkingFormDataKeyValue[]>([
|
||||
{
|
||||
id: idTicker.value++,
|
||||
entry: {
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
isFile: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working Params always have last element is always an empty param
|
||||
watch(workingParams, (paramsList) => {
|
||||
if (
|
||||
paramsList.length > 0 &&
|
||||
paramsList[paramsList.length - 1].entry.key !== ""
|
||||
) {
|
||||
workingParams.value.push({
|
||||
id: idTicker.value++,
|
||||
entry: {
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
isFile: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sync logic between params and working params
|
||||
watch(
|
||||
bodyParams,
|
||||
(newParamsList) => {
|
||||
if (!Array.isArray(newParamsList)) return
|
||||
|
||||
// Sync should overwrite working params
|
||||
const filteredWorkingParams = pipe(
|
||||
workingParams.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.entry.key !== "" || e.entry.isFile),
|
||||
O.map((e) => e.entry)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(newParamsList, filteredWorkingParams)) {
|
||||
workingParams.value = pipe(
|
||||
newParamsList,
|
||||
A.map((x) => ({ id: idTicker.value++, entry: x }))
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workingParams, (newWorkingParams) => {
|
||||
const fixedParams = pipe(
|
||||
newWorkingParams,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.entry.key !== "" || e.entry.isFile),
|
||||
O.map((e) => e.entry)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(bodyParams.value, fixedParams)) {
|
||||
bodyParams.value = fixedParams
|
||||
}
|
||||
})
|
||||
|
||||
const addBodyParam = () => {
|
||||
workingParams.value.push({
|
||||
id: idTicker.value++,
|
||||
entry: {
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
isFile: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updateBodyParam = (index: number, entry: FormDataKeyValue) => {
|
||||
workingParams.value = workingParams.value.map((h, i) =>
|
||||
i === index ? { id: h.id, entry } : h
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBodyParam = (index: number) => {
|
||||
const paramsBeforeDeletion = clone(workingParams.value)
|
||||
|
||||
if (
|
||||
!(
|
||||
paramsBeforeDeletion.length > 0 &&
|
||||
index === paramsBeforeDeletion.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) => {
|
||||
workingParams.value = paramsBeforeDeletion
|
||||
toastObject.goAway(0)
|
||||
deletionToast.value = null
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
onComplete: () => {
|
||||
deletionToast.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workingParams.value = workingParams.value.filter(
|
||||
(_, arrIndex) => arrIndex != index
|
||||
)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set params list to the initial state
|
||||
workingParams.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
entry: {
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
isFile: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const setRequestAttachment = (
|
||||
index: number,
|
||||
entry: FormDataKeyValue,
|
||||
event: InputEvent
|
||||
) => {
|
||||
// check if file exists or not
|
||||
if ((event.target as HTMLInputElement).files?.length === 0) {
|
||||
updateBodyParam(index, {
|
||||
...entry,
|
||||
isFile: false,
|
||||
value: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fileEntry: FormDataKeyValue = {
|
||||
...entry,
|
||||
isFile: true,
|
||||
value: Array.from((event.target as HTMLInputElement).files!),
|
||||
}
|
||||
updateBodyParam(index, fileEntry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-chips-container {
|
||||
@apply flex flex-1;
|
||||
@apply whitespace-nowrap;
|
||||
@apply overflow-auto;
|
||||
@apply bg-transparent;
|
||||
|
||||
.file-chips-wrapper {
|
||||
@apply flex;
|
||||
@apply p-1;
|
||||
@apply w-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
274
packages/hoppscotch-common/src/components/http/CodegenModal.vue
Normal file
274
packages/hoppscotch-common/src/components/http/CodegenModal.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('request.generate_code')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
<label for="requestType" class="px-4 pb-4">
|
||||
{{ t("request.choose_language") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
placement="bottom"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
:label="
|
||||
CodegenDefinitions.find((x) => x.name === codegenType).caption
|
||||
"
|
||||
outline
|
||||
class="flex-1 pr-8"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="sticky top-0 flex-shrink-0 overflow-x-auto">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="flex w-full p-4 py-2 input !bg-primaryContrast"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="codegen in filteredCodegenDefinitions"
|
||||
:key="codegen.name"
|
||||
:label="codegen.caption"
|
||||
:info-icon="codegen.name === codegenType ? IconCheck : null"
|
||||
:active-info-icon="codegen.name === codegenType"
|
||||
@click="
|
||||
() => {
|
||||
codegenType = codegen.name
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
!(
|
||||
filteredCodegenDefinitions.length !== 0 ||
|
||||
CodegenDefinitions.length === 0
|
||||
)
|
||||
"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center">
|
||||
{{ t("state.nothing_found") }} "{{ searchQuery }}"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<div
|
||||
v-if="errorState"
|
||||
class="w-full px-4 py-2 mt-4 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
|
||||
>
|
||||
{{ t("error.something_went_wrong") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="codegenType"
|
||||
class="mt-4 border rounded border-dividerLight"
|
||||
>
|
||||
<div class="flex items-center justify-between pl-4">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.generated_code") }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="generatedCode"
|
||||
class="border-t rounded-b border-dividerLight"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.copy')}`"
|
||||
:icon="copyCodeIcon"
|
||||
outline
|
||||
@click="copyRequestCode"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.dismiss')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { Environment, makeRESTRequest } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import {
|
||||
getEffectiveRESTRequest,
|
||||
resolvesEnvsInBody,
|
||||
} from "~/helpers/utils/EffectiveURL"
|
||||
import { getAggregateEnvs } from "~/newstore/environments"
|
||||
import { getRESTRequest } from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
CodegenDefinitions,
|
||||
CodegenName,
|
||||
generateCode,
|
||||
} from "~/helpers/new-codegen"
|
||||
import {
|
||||
useCopyResponse,
|
||||
useDownloadResponse,
|
||||
} from "~/composables/lens-actions"
|
||||
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const request = ref(getRESTRequest())
|
||||
const codegenType = ref<CodegenName>("shell-curl")
|
||||
const errorState = ref(false)
|
||||
|
||||
const copyCodeIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const requestCode = computed(() => {
|
||||
const aggregateEnvs = getAggregateEnvs()
|
||||
const env: Environment = {
|
||||
name: "Env",
|
||||
variables: aggregateEnvs,
|
||||
}
|
||||
const effectiveRequest = getEffectiveRESTRequest(request.value, env)
|
||||
|
||||
if (!props.show) return ""
|
||||
|
||||
const result = generateCode(
|
||||
codegenType.value,
|
||||
makeRESTRequest({
|
||||
...effectiveRequest,
|
||||
body: resolvesEnvsInBody(effectiveRequest.body, env),
|
||||
headers: effectiveRequest.effectiveFinalHeaders.map((header) => ({
|
||||
...header,
|
||||
active: true,
|
||||
})),
|
||||
params: effectiveRequest.effectiveFinalParams.map((param) => ({
|
||||
...param,
|
||||
active: true,
|
||||
})),
|
||||
endpoint: effectiveRequest.effectiveFinalURL,
|
||||
})
|
||||
)
|
||||
|
||||
if (O.isSome(result)) {
|
||||
errorState.value = false
|
||||
return result.value
|
||||
} else {
|
||||
errorState.value = true
|
||||
return ""
|
||||
}
|
||||
})
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const generatedCode = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
generatedCode,
|
||||
requestCode,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/plain",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(goingToShow) => {
|
||||
if (goingToShow) {
|
||||
request.value = getRESTRequest()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const hideModal = () => emit("hide-modal")
|
||||
|
||||
const copyRequestCode = () => {
|
||||
copyToClipboard(requestCode.value)
|
||||
copyCodeIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const searchQuery = ref("")
|
||||
|
||||
const filteredCodegenDefinitions = computed(() => {
|
||||
return CodegenDefinitions.filter((obj) =>
|
||||
Object.values(obj).some((val) =>
|
||||
val.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(requestCode)
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse("", requestCode)
|
||||
</script>
|
||||
512
packages/hoppscotch-common/src/components/http/Headers.vue
Normal file
512
packages/hoppscotch-common/src/components/http/Headers.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.header_list") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/headers"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="bulkMode"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<SmartAutoComplete
|
||||
: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,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="header.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
key: header.key,
|
||||
value: $event,
|
||||
active: header.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
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>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteHeader(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<draggable
|
||||
v-model="computedHeaders"
|
||||
item-key="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>
|
||||
<ButtonSecondary
|
||||
:icon="IconLock"
|
||||
class="opacity-25 cursor-auto text-secondaryLight bg-divider"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<SmartEnvInput
|
||||
v-model="header.header.key"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
readonly
|
||||
/>
|
||||
<SmartEnvInput
|
||||
:model-value="mask(header)"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
readonly
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-if="header.source === 'auth'"
|
||||
:icon="masking ? IconEye : IconEyeOff"
|
||||
@click="toggleMask()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:icon="IconArrowUpRight"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
:icon="IconArrowUpRight"
|
||||
@click="changeTab(header.source)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="workingHeaders.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.headers')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.headers") }}</span>
|
||||
<ButtonSecondary
|
||||
filled
|
||||
:label="`${t('add.new')}`"
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addHeader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconLock from "~icons/lucide/lock"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
import IconEyeOff from "~icons/lucide/eye-off"
|
||||
import IconArrowUpRight from "~icons/lucide/arrow-up-right"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { computed, reactive, Ref, ref, watch } from "vue"
|
||||
import { isEqual, cloneDeep } from "lodash-es"
|
||||
import {
|
||||
HoppRESTHeader,
|
||||
parseRawKeyValueEntriesE,
|
||||
rawKeyValueEntriesToString,
|
||||
RawKeyValueEntry,
|
||||
} from "@hoppscotch/data"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import draggable from "vuedraggable"
|
||||
import { RequestOptionTabs } from "./RequestOptions.vue"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import {
|
||||
getRESTRequest,
|
||||
restHeaders$,
|
||||
restRequest$,
|
||||
setRESTHeaders,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { commonHeaders } from "~/helpers/headers"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import linter from "~/helpers/editor/linting/rawKeyValue"
|
||||
import { throwError } from "~/helpers/functional/error"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
import {
|
||||
ComputedHeader,
|
||||
getComputedHeaders,
|
||||
} from "~/helpers/utils/EffectiveURL"
|
||||
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
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)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "change-tab", value: RequestOptionTabs): void
|
||||
}>()
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkHeaders,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
// The functional headers list (the headers actually in the system)
|
||||
const headers = useStream(restHeaders$, [], setRESTHeaders) as Ref<
|
||||
HoppRESTHeader[]
|
||||
>
|
||||
|
||||
// The UI representation of the headers list (has the empty end headers)
|
||||
const workingHeaders = ref<Array<HoppRESTHeader & { id: number }>>([
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working Headers always have 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/bulk 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)
|
||||
}
|
||||
},
|
||||
{ immediate: 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)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
const addHeader = () => {
|
||||
workingHeaders.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateHeader = (
|
||||
index: number,
|
||||
header: HoppRESTHeader & { id: number }
|
||||
) => {
|
||||
workingHeaders.value = workingHeaders.value.map((h, i) =>
|
||||
i === index ? header : h
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHeader = (index: number) => {
|
||||
const headersBeforeDeletion = cloneDeep(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 = pipe(
|
||||
workingHeaders.value,
|
||||
A.deleteAt(index),
|
||||
O.getOrElseW(() => throwError("Working Headers Deletion Out of Bounds"))
|
||||
)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set params list to the initial state
|
||||
workingHeaders.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
|
||||
bulkHeaders.value = ""
|
||||
}
|
||||
|
||||
const restRequest = useReadonlyStream(restRequest$, getRESTRequest())
|
||||
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
|
||||
|
||||
const computedHeaders = computed(() =>
|
||||
getComputedHeaders(restRequest.value, aggregateEnvs.value).map(
|
||||
(header, index) => ({
|
||||
id: `header-${index}`,
|
||||
...header,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const masking = ref(true)
|
||||
|
||||
const toggleMask = () => {
|
||||
masking.value = !masking.value
|
||||
}
|
||||
|
||||
const mask = (header: ComputedHeader) => {
|
||||
if (header.source === "auth" && masking.value)
|
||||
return header.header.value.replace(/\S/gi, "*")
|
||||
return header.header.value
|
||||
}
|
||||
|
||||
const changeTab = (tab: ComputedHeader["source"]) => {
|
||||
if (tab === "auth") emit("change-tab", "authorization")
|
||||
else emit("change-tab", "bodyParams")
|
||||
}
|
||||
</script>
|
||||
179
packages/hoppscotch-common/src/components/http/ImportCurl.vue
Normal file
179
packages/hoppscotch-common/src/components/http/ImportCurl.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('import.curl')}`"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="border rounded border-dividerLight">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between pl-4">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
cURL
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-46">
|
||||
<div
|
||||
ref="curlEditor"
|
||||
class="h-full border-t rounded-b border-dividerLight"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
ref="importButton"
|
||||
:label="`${t('import.title')}`"
|
||||
outline
|
||||
@click="handleImport"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="`${t('action.cancel')}`"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex">
|
||||
<ButtonSecondary
|
||||
:icon="pasteIcon"
|
||||
:label="`${t('action.paste')}`"
|
||||
filled
|
||||
outline
|
||||
@click="handlePaste"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { setRESTRequest } from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { parseCurlToHoppRESTReq } from "~/helpers/curl"
|
||||
import {
|
||||
useCopyResponse,
|
||||
useDownloadResponse,
|
||||
} from "~/composables/lens-actions"
|
||||
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconClipboard from "~icons/lucide/clipboard"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const curl = ref("")
|
||||
|
||||
const curlEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const props = defineProps<{ show: boolean; text: string }>()
|
||||
|
||||
useCodemirror(
|
||||
curlEditor,
|
||||
curl,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/x-sh",
|
||||
placeholder: `${t("request.enter_curl")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
() => {
|
||||
if (props.show) {
|
||||
curl.value = props.text.toString()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const text = curl.value
|
||||
try {
|
||||
const req = parseCurlToHoppRESTReq(text)
|
||||
|
||||
setRESTRequest(req)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`${t("error.curl_invalid_format")}`)
|
||||
}
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const pasteIcon = refAutoReset<typeof IconClipboard | typeof IconCheck>(
|
||||
IconClipboard,
|
||||
1000
|
||||
)
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) {
|
||||
curl.value = text
|
||||
pasteIcon.value = IconCheck
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to copy: ", e)
|
||||
toast.error(t("profile.no_permission").toString())
|
||||
}
|
||||
}
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(curl)
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse("", curl)
|
||||
|
||||
const clearContent = () => {
|
||||
curl.value = ""
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="oidcDiscoveryURL"
|
||||
placeholder="OpenID Connect Discovery URL"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="authURL" placeholder="Authorization URL" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="accessTokenURL" placeholder="Access Token URL" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="clientID" placeholder="Client ID" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="clientSecret" placeholder="Client Secret" />
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="scope" placeholder="Scope" />
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<ButtonSecondary
|
||||
filled
|
||||
:label="`${t('authorization.generate_token')}`"
|
||||
@click="handleAccessTokenRequest()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Ref, defineComponent } from "vue"
|
||||
import { HoppRESTAuthOAuth2, parseTemplateString } from "@hoppscotch/data"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { restAuth$, setRESTAuth } from "~/newstore/RESTSession"
|
||||
import { tokenRequest } from "~/helpers/oauth"
|
||||
import { getCombinedEnvVariables } from "~/helpers/preRequest"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
|
||||
const oidcDiscoveryURL = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"oidcDiscoveryURL"
|
||||
)
|
||||
|
||||
const authURL = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "authURL")
|
||||
|
||||
const accessTokenURL = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"accessTokenURL"
|
||||
)
|
||||
|
||||
const clientID = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "clientID")
|
||||
|
||||
const clientSecret = pluckRef(
|
||||
auth as Ref<HoppRESTAuthOAuth2>,
|
||||
"clientSecret"
|
||||
)
|
||||
|
||||
const scope = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "scope")
|
||||
|
||||
const handleAccessTokenRequest = async () => {
|
||||
if (
|
||||
oidcDiscoveryURL.value === "" &&
|
||||
(authURL.value === "" || accessTokenURL.value === "")
|
||||
) {
|
||||
toast.error(`${t("error.incomplete_config_urls")}`)
|
||||
return
|
||||
}
|
||||
const envs = getCombinedEnvVariables()
|
||||
const envVars = [...envs.selected, ...envs.global]
|
||||
|
||||
try {
|
||||
const tokenReqParams = {
|
||||
grantType: "code",
|
||||
oidcDiscoveryUrl: parseTemplateString(
|
||||
oidcDiscoveryURL.value,
|
||||
envVars
|
||||
),
|
||||
authUrl: parseTemplateString(authURL.value, envVars),
|
||||
accessTokenUrl: parseTemplateString(accessTokenURL.value, envVars),
|
||||
clientId: parseTemplateString(clientID.value, envVars),
|
||||
clientSecret: parseTemplateString(clientSecret.value, envVars),
|
||||
scope: parseTemplateString(scope.value, envVars),
|
||||
}
|
||||
await tokenRequest(tokenReqParams)
|
||||
} catch (e) {
|
||||
toast.error(`${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidcDiscoveryURL,
|
||||
authURL,
|
||||
accessTokenURL,
|
||||
clientID,
|
||||
clientSecret,
|
||||
scope,
|
||||
handleAccessTokenRequest,
|
||||
t,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
400
packages/hoppscotch-common/src/components/http/Parameters.vue
Normal file
400
packages/hoppscotch-common/src/components/http/Parameters.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.parameter_list") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/parameters"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="bulkMode"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="bulkMode"
|
||||
@click="addParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-col flex-1"></div>
|
||||
<div v-else>
|
||||
<draggable
|
||||
v-model="workingParams"
|
||||
item-key="id"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: param, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== workingParams?.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 !== workingParams?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<SmartEnvInput
|
||||
v-model="param.key"
|
||||
:placeholder="`${t('count.parameter', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateParam(index, {
|
||||
id: param.id,
|
||||
key: $event,
|
||||
value: param.value,
|
||||
active: param.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="param.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateParam(index, {
|
||||
id: param.id,
|
||||
key: param.key,
|
||||
value: $event,
|
||||
active: param.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
param.hasOwnProperty('active')
|
||||
? param.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
param.hasOwnProperty('active')
|
||||
? param.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateParam(index, {
|
||||
id: param.id,
|
||||
key: param.key,
|
||||
value: param.value,
|
||||
active: param.hasOwnProperty('active')
|
||||
? !param.active
|
||||
: false,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteParam(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="workingParams.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.parameters')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">{{ t("empty.parameters") }}</span>
|
||||
<ButtonSecondary
|
||||
:label="`${t('add.new')}`"
|
||||
:icon="IconPlus"
|
||||
filled
|
||||
class="mb-4"
|
||||
@click="addParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { reactive, Ref, ref, watch } from "vue"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
HoppRESTParam,
|
||||
parseRawKeyValueEntriesE,
|
||||
rawKeyValueEntriesToString,
|
||||
RawKeyValueEntry,
|
||||
} from "@hoppscotch/data"
|
||||
import { isEqual, cloneDeep } from "lodash-es"
|
||||
import draggable from "vuedraggable"
|
||||
import linter from "~/helpers/editor/linting/rawKeyValue"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { restParams$, setRESTParams } from "~/newstore/RESTSession"
|
||||
import { throwError } from "@functional/error"
|
||||
import { objRemoveKey } from "@functional/object"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const bulkMode = ref(false)
|
||||
const bulkParams = ref("")
|
||||
const bulkEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkParams,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
// The functional parameters list (the parameters actually applied to the session)
|
||||
const params = useStream(restParams$, [], setRESTParams) as Ref<HoppRESTParam[]>
|
||||
|
||||
// The UI representation of the parameters list (has the empty end param)
|
||||
const workingParams = ref<Array<HoppRESTParam & { id: number }>>([
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working Params always have last element is always an empty param
|
||||
watch(workingParams, (paramsList) => {
|
||||
if (paramsList.length > 0 && paramsList[paramsList.length - 1].key !== "") {
|
||||
workingParams.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sync logic between params and working/bulk params
|
||||
watch(
|
||||
params,
|
||||
(newParamsList) => {
|
||||
// Sync should overwrite working params
|
||||
const filteredWorkingParams: HoppRESTParam[] = pipe(
|
||||
workingParams.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredBulkParams = pipe(
|
||||
parseRawKeyValueEntriesE(bulkParams.value),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(newParamsList, filteredWorkingParams)) {
|
||||
workingParams.value = pipe(
|
||||
newParamsList,
|
||||
A.map((x) => ({ id: idTicker.value++, ...x }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEqual(newParamsList, filteredBulkParams)) {
|
||||
bulkParams.value = rawKeyValueEntriesToString(newParamsList)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workingParams, (newWorkingParams) => {
|
||||
const fixedParams = pipe(
|
||||
newWorkingParams,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(params.value, fixedParams)) {
|
||||
params.value = cloneDeep(fixedParams)
|
||||
}
|
||||
})
|
||||
|
||||
watch(bulkParams, (newBulkParams) => {
|
||||
const filteredBulkParams = pipe(
|
||||
parseRawKeyValueEntriesE(newBulkParams),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(params.value, filteredBulkParams)) {
|
||||
params.value = filteredBulkParams
|
||||
}
|
||||
})
|
||||
|
||||
const addParam = () => {
|
||||
workingParams.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateParam = (index: number, param: HoppRESTParam & { id: number }) => {
|
||||
workingParams.value = workingParams.value.map((h, i) =>
|
||||
i === index ? param : h
|
||||
)
|
||||
}
|
||||
|
||||
const deleteParam = (index: number) => {
|
||||
const paramsBeforeDeletion = cloneDeep(workingParams.value)
|
||||
|
||||
if (
|
||||
!(
|
||||
paramsBeforeDeletion.length > 0 &&
|
||||
index === paramsBeforeDeletion.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) => {
|
||||
workingParams.value = paramsBeforeDeletion
|
||||
toastObject.goAway(0)
|
||||
deletionToast.value = null
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
onComplete: () => {
|
||||
deletionToast.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workingParams.value = pipe(
|
||||
workingParams.value,
|
||||
A.deleteAt(index),
|
||||
O.getOrElseW(() => throwError("Working Params Deletion Out of Bounds"))
|
||||
)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set params list to the initial state
|
||||
workingParams.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
|
||||
bulkParams.value = ""
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("preRequest.javascript_code") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/pre-request-script"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div ref="preRequestEditor" class="h-full"></div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.pre_request_script") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
:label="`${t('preRequest.learn')}`"
|
||||
to="https://docs.hoppscotch.io/features/pre-request-script"
|
||||
blank
|
||||
/>
|
||||
<h4 class="pt-6 font-bold text-secondaryLight">
|
||||
{{ t("preRequest.snippets") }}
|
||||
</h4>
|
||||
<div class="flex flex-col pt-4">
|
||||
<TabSecondary
|
||||
v-for="(snippet, index) in snippets"
|
||||
:key="`snippet-${index}`"
|
||||
:label="snippet.name"
|
||||
active
|
||||
@click="useSnippet(snippet.script)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { reactive, ref } from "vue"
|
||||
import { usePreRequestScript } from "~/newstore/RESTSession"
|
||||
import snippets from "@helpers/preRequestScriptSnippets"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import linter from "~/helpers/editor/linting/preRequest"
|
||||
import completer from "~/helpers/editor/completion/preRequest"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const preRequestScript = usePreRequestScript()
|
||||
|
||||
const preRequestEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
preRequestEditor,
|
||||
preRequestScript,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/javascript",
|
||||
lineWrapping: linewrapEnabled,
|
||||
placeholder: `${t("preRequest.javascript_code")}`,
|
||||
},
|
||||
linter,
|
||||
completer,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const useSnippet = (script: string) => {
|
||||
preRequestScript.value += script
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
preRequestScript.value = ""
|
||||
}
|
||||
</script>
|
||||
175
packages/hoppscotch-common/src/components/http/RawBody.vue
Normal file
175
packages/hoppscotch-common/src/components/http/RawBody.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<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-upperMobileStickyFold sm:top-upperMobileTertiaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.raw_body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/body"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="contentType && contentType.endsWith('json')"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.prettify')"
|
||||
:icon="prettifyIcon"
|
||||
@click="prettifyRequestBody"
|
||||
/>
|
||||
<label for="payload">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('import.title')"
|
||||
:icon="IconFilePlus"
|
||||
@click="$refs.payload.click()"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
ref="payload"
|
||||
class="input"
|
||||
name="payload"
|
||||
type="file"
|
||||
@change="uploadPayload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="rawBodyParameters" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconFilePlus from "~icons/lucide/file-plus"
|
||||
import IconWand2 from "~icons/lucide/wand-2"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import { computed, reactive, Ref, ref, watch } from "vue"
|
||||
import * as TO from "fp-ts/TaskOption"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { ValidContentTypes } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { getEditorLangForMimeType } from "@helpers/editorutils"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { isJSONContentType } from "~/helpers/utils/contenttypes"
|
||||
import { useRESTRequestBody } from "~/newstore/RESTSession"
|
||||
|
||||
import jsonLinter from "~/helpers/editor/linting/json"
|
||||
import { readFileAsText } from "~/helpers/functional/files"
|
||||
|
||||
type PossibleContentTypes = Exclude<
|
||||
ValidContentTypes,
|
||||
"multipart/form-data" | "application/x-www-form-urlencoded"
|
||||
>
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
contentType: PossibleContentTypes
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const rawParamsBody = pluckRef(useRESTRequestBody(), "body")
|
||||
|
||||
const prettifyIcon = refAutoReset<
|
||||
typeof IconWand2 | typeof IconCheck | typeof IconInfo
|
||||
>(IconWand2, 1000)
|
||||
|
||||
const rawInputEditorLang = computed(() =>
|
||||
getEditorLangForMimeType(props.contentType)
|
||||
)
|
||||
const langLinter = computed(() =>
|
||||
isJSONContentType(props.contentType) ? jsonLinter : null
|
||||
)
|
||||
|
||||
const linewrapEnabled = ref(true)
|
||||
const rawBodyParameters = ref<any | null>(null)
|
||||
|
||||
const codemirrorValue: Ref<string | undefined> =
|
||||
typeof rawParamsBody.value == "string"
|
||||
? ref(rawParamsBody.value)
|
||||
: ref(undefined)
|
||||
|
||||
watch(rawParamsBody, (newVal) => {
|
||||
typeof newVal == "string"
|
||||
? (codemirrorValue.value = newVal)
|
||||
: (codemirrorValue.value = undefined)
|
||||
})
|
||||
|
||||
// propagate the edits from codemirror back to the body
|
||||
watch(codemirrorValue, (updatedValue) => {
|
||||
if (updatedValue && updatedValue != rawParamsBody.value) {
|
||||
rawParamsBody.value = updatedValue
|
||||
}
|
||||
})
|
||||
|
||||
useCodemirror(
|
||||
rawBodyParameters,
|
||||
codemirrorValue,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
lineWrapping: linewrapEnabled,
|
||||
mode: rawInputEditorLang,
|
||||
placeholder: t("request.raw_body").toString(),
|
||||
},
|
||||
linter: langLinter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
const clearContent = () => {
|
||||
rawParamsBody.value = ""
|
||||
}
|
||||
|
||||
const uploadPayload = async (e: InputEvent) => {
|
||||
await pipe(
|
||||
(e.target as HTMLInputElement).files?.[0],
|
||||
TO.of,
|
||||
TO.chain(TO.fromPredicate((f): f is File => f !== undefined)),
|
||||
TO.chain(readFileAsText),
|
||||
|
||||
TO.matchW(
|
||||
() => toast.error(`${t("action.choose_file")}`),
|
||||
(result) => {
|
||||
rawParamsBody.value = result
|
||||
toast.success(`${t("state.file_imported")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
const prettifyRequestBody = () => {
|
||||
try {
|
||||
const jsonObj = JSON.parse(rawParamsBody.value)
|
||||
rawParamsBody.value = JSON.stringify(jsonObj, null, 2)
|
||||
prettifyIcon.value = IconCheck
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
prettifyIcon.value = IconInfo
|
||||
toast.error(`${t("error.json_prettify_invalid_body")}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('modal.confirm')"
|
||||
aria-modal="true"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
{{ t("confirm.request_change") }}
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
v-focus
|
||||
:label="t('action.save')"
|
||||
outline
|
||||
@click="saveApiChange"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.dont_save')"
|
||||
outline
|
||||
filled
|
||||
@click="discardApiChange"
|
||||
/>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save-change"): void
|
||||
(e: "discard-change"): void
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const saveApiChange = () => {
|
||||
emit("save-change")
|
||||
}
|
||||
|
||||
const discardApiChange = () => {
|
||||
emit("discard-change")
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
593
packages/hoppscotch-common/src/components/http/Request.vue
Normal file
593
packages/hoppscotch-common/src/components/http/Request.vue
Normal file
@@ -0,0 +1,593 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 overflow-x-auto sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||
>
|
||||
<div class="relative flex">
|
||||
<label for="method">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => methodTippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<input
|
||||
id="method"
|
||||
class="flex px-4 py-2 font-semibold transition rounded-l cursor-pointer text-secondaryDark w-26 bg-primaryLight"
|
||||
:value="newMethod"
|
||||
:readonly="!isCustomMethod"
|
||||
:placeholder="`${t('request.method')}`"
|
||||
@input="onSelectMethod($event.target.value)"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="methodTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(method, index) in methods"
|
||||
:key="`method-${index}`"
|
||||
:label="method"
|
||||
@click="
|
||||
() => {
|
||||
onSelectMethod(method)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="newEndpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
@enter="newSendRequest()"
|
||||
@paste="onPasteUrl($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-2 sm:mt-0">
|
||||
<ButtonPrimary
|
||||
id="send"
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t('action.send')} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
|
||||
:label="`${!loading ? t('action.send') : t('action.cancel')}`"
|
||||
class="flex-1 rounded-r-none min-w-20"
|
||||
@click="!loading ? newSendRequest() : cancelRequest()"
|
||||
/>
|
||||
<span class="flex">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => sendTippyActions.focus()"
|
||||
>
|
||||
<ButtonPrimary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('app.options')"
|
||||
:icon="IconChevronDown"
|
||||
filled
|
||||
class="rounded-l-none"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="sendTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.c="curl.$el.click()"
|
||||
@keyup.s="show.$el.click()"
|
||||
@keyup.delete="clearAll.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
ref="curl"
|
||||
:label="`${t('import.curl')}`"
|
||||
:icon="IconFileCode"
|
||||
:shortcut="['C']"
|
||||
@click="
|
||||
() => {
|
||||
showCurlImportModal = !showCurlImportModal
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="show"
|
||||
:label="`${t('show.code')}`"
|
||||
:icon="IconCode2"
|
||||
:shortcut="['S']"
|
||||
@click="
|
||||
() => {
|
||||
showCodegenModal = !showCodegenModal
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="clearAll"
|
||||
:label="`${t('action.clear_all')}`"
|
||||
:icon="IconRotateCCW"
|
||||
:shortcut="['⌫']"
|
||||
@click="
|
||||
() => {
|
||||
clearContent()
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<span
|
||||
class="flex ml-2 transition border rounded border-dividerLight hover:border-dividerDark"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
|
||||
:title="`${t(
|
||||
'request.save'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
|
||||
:label="COLUMN_LAYOUT ? `${t('request.save')}` : ''"
|
||||
filled
|
||||
:icon="IconSave"
|
||||
class="flex-1 rounded rounded-r-none"
|
||||
@click="saveRequest()"
|
||||
/>
|
||||
<span class="flex">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => saveTippyActions.focus()"
|
||||
>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('app.options')"
|
||||
:icon="IconChevronDown"
|
||||
filled
|
||||
class="rounded rounded-l-none"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="saveTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.c="copyRequestAction.$el.click()"
|
||||
@keyup.s="saveRequestAction.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<input
|
||||
id="request-name"
|
||||
v-model="requestName"
|
||||
:placeholder="`${t('request.name')}`"
|
||||
name="request-name"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="mb-2 input !bg-primaryContrast"
|
||||
@keyup.enter="hide()"
|
||||
/>
|
||||
<SmartItem
|
||||
ref="copyRequestAction"
|
||||
:label="shareButtonText"
|
||||
:icon="copyLinkIcon"
|
||||
:loading="fetchingShareLink"
|
||||
:shortcut="['C']"
|
||||
@click="
|
||||
() => {
|
||||
copyRequest()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
:icon="IconLink2"
|
||||
:label="`${t('request.view_my_links')}`"
|
||||
to="/profile"
|
||||
/>
|
||||
<hr />
|
||||
<SmartItem
|
||||
ref="saveRequestAction"
|
||||
:label="`${t('request.save_as')}`"
|
||||
:icon="IconFolderPlus"
|
||||
:shortcut="['S']"
|
||||
@click="
|
||||
() => {
|
||||
showSaveRequestModal = true
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<HttpImportCurl
|
||||
:text="curlText"
|
||||
:show="showCurlImportModal"
|
||||
@hide-modal="showCurlImportModal = false"
|
||||
/>
|
||||
<HttpCodegenModal
|
||||
:show="showCodegenModal"
|
||||
@hide-modal="showCodegenModal = false"
|
||||
/>
|
||||
<CollectionsSaveRequest
|
||||
mode="rest"
|
||||
:show="showSaveRequestModal"
|
||||
@hide-modal="showSaveRequestModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconFileCode from "~icons/lucide/file-code"
|
||||
import IconCode2 from "~icons/lucide/code-2"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import IconSave from "~icons/lucide/save"
|
||||
import IconChevronDown from "~icons/lucide/chevron-down"
|
||||
import IconLink2 from "~icons/lucide/link-2"
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { isLeft, isRight } from "fp-ts/lib/Either"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import {
|
||||
updateRESTResponse,
|
||||
restEndpoint$,
|
||||
setRESTEndpoint,
|
||||
restMethod$,
|
||||
updateRESTMethod,
|
||||
resetRESTRequest,
|
||||
useRESTRequestName,
|
||||
getRESTSaveContext,
|
||||
getRESTRequest,
|
||||
restRequest$,
|
||||
setRESTSaveContext,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
import { runRESTRequest$ } from "~/helpers/RequestRunner"
|
||||
import {
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
useReadonlyStream,
|
||||
} from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const methods = [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD",
|
||||
"CONNECT",
|
||||
"OPTIONS",
|
||||
"TRACE",
|
||||
"CUSTOM",
|
||||
]
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint)
|
||||
const curlText = ref("")
|
||||
const newMethod = useStream(restMethod$, "", updateRESTMethod)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const showCurlImportModal = ref(false)
|
||||
const showCodegenModal = ref(false)
|
||||
const showSaveRequestModal = ref(false)
|
||||
|
||||
const hasNavigatorShare = !!navigator.share
|
||||
|
||||
// Template refs
|
||||
const methodTippyActions = ref<any | null>(null)
|
||||
const sendTippyActions = ref<any | null>(null)
|
||||
const saveTippyActions = ref<any | null>(null)
|
||||
const curl = ref<any | null>(null)
|
||||
const show = ref<any | null>(null)
|
||||
const clearAll = ref<any | null>(null)
|
||||
const copyRequestAction = ref<any | null>(null)
|
||||
const saveRequestAction = ref<any | null>(null)
|
||||
|
||||
// Update Nuxt Loading bar
|
||||
watch(loading, () => {
|
||||
if (loading.value) {
|
||||
startPageProgress()
|
||||
} else {
|
||||
completePageProgress()
|
||||
}
|
||||
})
|
||||
|
||||
const newSendRequest = async () => {
|
||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||
toast.error(`${t("empty.endpoint")}`)
|
||||
return
|
||||
}
|
||||
|
||||
ensureMethodInEndpoint()
|
||||
|
||||
loading.value = true
|
||||
|
||||
// Double calling is because the function returns a TaskEither than should be executed
|
||||
const streamResult = await runRESTRequest$()()
|
||||
|
||||
if (isRight(streamResult)) {
|
||||
subscribeToStream(
|
||||
streamResult.right,
|
||||
(responseState) => {
|
||||
if (loading.value) {
|
||||
// Check exists because, loading can be set to false
|
||||
// when cancelled
|
||||
updateRESTResponse(responseState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
loading.value = false
|
||||
},
|
||||
() => {
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
} else if (isLeft(streamResult)) {
|
||||
loading.value = false
|
||||
toast.error(`${t("error.script_fail")}`)
|
||||
let error: Error
|
||||
if (typeof streamResult.left === "string") {
|
||||
error = { name: "RequestFailure", message: streamResult.left }
|
||||
} else {
|
||||
error = streamResult.left
|
||||
}
|
||||
updateRESTResponse({
|
||||
type: "script_fail",
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ensureMethodInEndpoint = () => {
|
||||
if (
|
||||
!/^http[s]?:\/\//.test(newEndpoint.value) &&
|
||||
!newEndpoint.value.startsWith("<<")
|
||||
) {
|
||||
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
|
||||
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
|
||||
setRESTEndpoint("http://" + newEndpoint.value)
|
||||
} else {
|
||||
setRESTEndpoint("https://" + newEndpoint.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPasteUrl = (e: { pastedValue: string; prevValue: string }) => {
|
||||
if (!e) return
|
||||
|
||||
const pastedData = e.pastedValue
|
||||
|
||||
if (isCURL(pastedData)) {
|
||||
showCurlImportModal.value = true
|
||||
curlText.value = pastedData
|
||||
newEndpoint.value = e.prevValue
|
||||
}
|
||||
}
|
||||
|
||||
function isCURL(curl: string) {
|
||||
return curl.includes("curl ")
|
||||
}
|
||||
|
||||
const cancelRequest = () => {
|
||||
loading.value = false
|
||||
updateRESTResponse(null)
|
||||
}
|
||||
|
||||
const updateMethod = (method: string) => {
|
||||
updateRESTMethod(method)
|
||||
}
|
||||
|
||||
const onSelectMethod = (method: string) => {
|
||||
updateMethod(method)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
resetRESTRequest()
|
||||
}
|
||||
|
||||
const copyLinkIcon = refAutoReset<
|
||||
typeof IconShare2 | typeof IconCopy | typeof IconCheck
|
||||
>(hasNavigatorShare ? IconShare2 : IconCopy, 1000)
|
||||
|
||||
const shareLink = ref<string | null>("")
|
||||
const fetchingShareLink = ref(false)
|
||||
|
||||
const shareButtonText = computed(() => {
|
||||
if (shareLink.value) {
|
||||
return shareLink.value
|
||||
} else if (fetchingShareLink.value) {
|
||||
return t("state.loading")
|
||||
} else {
|
||||
return t("request.copy_link")
|
||||
}
|
||||
})
|
||||
|
||||
const request = useReadonlyStream(restRequest$, getRESTRequest())
|
||||
|
||||
watch(request, () => {
|
||||
shareLink.value = null
|
||||
})
|
||||
|
||||
const copyRequest = async () => {
|
||||
if (shareLink.value) {
|
||||
copyShareLink(shareLink.value)
|
||||
} else {
|
||||
shareLink.value = ""
|
||||
fetchingShareLink.value = true
|
||||
const request = getRESTRequest()
|
||||
const shortcodeResult = await createShortcode(request)()
|
||||
if (E.isLeft(shortcodeResult)) {
|
||||
toast.error(`${shortcodeResult.left.error}`)
|
||||
shareLink.value = `${t("error.something_went_wrong")}`
|
||||
} else if (E.isRight(shortcodeResult)) {
|
||||
shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
|
||||
copyShareLink(shareLink.value)
|
||||
}
|
||||
fetchingShareLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyShareLink = (shareLink: string) => {
|
||||
const link = `${
|
||||
import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
|
||||
}/r${shareLink}`
|
||||
if (navigator.share) {
|
||||
const time = new Date().toLocaleTimeString()
|
||||
const date = new Date().toLocaleDateString()
|
||||
navigator.share({
|
||||
title: "Hoppscotch",
|
||||
text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
|
||||
url: link,
|
||||
})
|
||||
} else {
|
||||
copyLinkIcon.value = IconCheck
|
||||
copyToClipboard(link)
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const cycleUpMethod = () => {
|
||||
const currentIndex = methods.indexOf(newMethod.value)
|
||||
if (currentIndex === -1) {
|
||||
// Most probs we are in CUSTOM mode
|
||||
// Cycle up from CUSTOM is PATCH
|
||||
updateMethod("PATCH")
|
||||
} else if (currentIndex === 0) {
|
||||
updateMethod("CUSTOM")
|
||||
} else {
|
||||
updateMethod(methods[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
|
||||
const cycleDownMethod = () => {
|
||||
const currentIndex = methods.indexOf(newMethod.value)
|
||||
if (currentIndex === -1) {
|
||||
// Most probs we are in CUSTOM mode
|
||||
// Cycle down from CUSTOM is GET
|
||||
updateMethod("GET")
|
||||
} else if (currentIndex === methods.length - 1) {
|
||||
updateMethod("GET")
|
||||
} else {
|
||||
updateMethod(methods[currentIndex + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const saveRequest = () => {
|
||||
const saveCtx = getRESTSaveContext()
|
||||
if (!saveCtx) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
const req = getRESTRequest()
|
||||
|
||||
try {
|
||||
editRESTRequest(
|
||||
saveCtx.folderPath,
|
||||
saveCtx.requestIndex,
|
||||
getRESTRequest()
|
||||
)
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: saveCtx.folderPath,
|
||||
requestIndex: saveCtx.requestIndex,
|
||||
req: cloneDeep(req),
|
||||
})
|
||||
toast.success(`${t("request.saved")}`)
|
||||
} catch (e) {
|
||||
setRESTSaveContext(null)
|
||||
saveRequest()
|
||||
}
|
||||
} else if (saveCtx.originLocation === "team-collection") {
|
||||
const req = getRESTRequest()
|
||||
|
||||
// TODO: handle error case (NOTE: overwriteRequestTeams is async)
|
||||
try {
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
},
|
||||
})().then((result) => {
|
||||
if (E.isLeft(result)) {
|
||||
toast.error(`${t("profile.no_permission")}`)
|
||||
} else {
|
||||
setRESTSaveContext({
|
||||
originLocation: "team-collection",
|
||||
requestID: saveCtx.requestID,
|
||||
req: cloneDeep(req),
|
||||
})
|
||||
toast.success(`${t("request.saved")}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
showSaveRequestModal.value = true
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineActionHandler("request.send-cancel", () => {
|
||||
if (!loading.value) newSendRequest()
|
||||
else cancelRequest()
|
||||
})
|
||||
defineActionHandler("request.reset", clearContent)
|
||||
defineActionHandler("request.copy-link", copyRequest)
|
||||
defineActionHandler("request.method.next", cycleDownMethod)
|
||||
defineActionHandler("request.method.prev", cycleUpMethod)
|
||||
defineActionHandler("request.save", saveRequest)
|
||||
defineActionHandler(
|
||||
"request.save-as",
|
||||
() => (showSaveRequestModal.value = true)
|
||||
)
|
||||
defineActionHandler("request.method.get", () => updateMethod("GET"))
|
||||
defineActionHandler("request.method.post", () => updateMethod("POST"))
|
||||
defineActionHandler("request.method.put", () => updateMethod("PUT"))
|
||||
defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
|
||||
defineActionHandler("request.method.head", () => updateMethod("HEAD"))
|
||||
|
||||
const isCustomMethod = computed(() => {
|
||||
return newMethod.value === "CUSTOM" || !methods.includes(newMethod.value)
|
||||
})
|
||||
|
||||
const requestName = useRESTRequestName()
|
||||
|
||||
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-model="selectedRealtimeTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'params'"
|
||||
:label="`${t('tab.parameters')}`"
|
||||
:info="`${newActiveParamsCount$}`"
|
||||
>
|
||||
<HttpParameters />
|
||||
</SmartTab>
|
||||
<SmartTab :id="'bodyParams'" :label="`${t('tab.body')}`">
|
||||
<HttpBody @change-tab="changeTab" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'headers'"
|
||||
:label="`${t('tab.headers')}`"
|
||||
:info="`${newActiveHeadersCount$}`"
|
||||
>
|
||||
<HttpHeaders @change-tab="changeTab" />
|
||||
</SmartTab>
|
||||
<SmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
|
||||
<HttpAuthorization />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'preRequestScript'"
|
||||
:label="`${t('tab.pre_request_script')}`"
|
||||
:indicator="
|
||||
preRequestScript && preRequestScript.length > 0 ? true : false
|
||||
"
|
||||
>
|
||||
<HttpPreRequestScript />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'tests'"
|
||||
:label="`${t('tab.tests')}`"
|
||||
:indicator="testScript && testScript.length > 0 ? true : false"
|
||||
>
|
||||
<HttpTests />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { map } from "rxjs/operators"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
restActiveHeadersCount$,
|
||||
restActiveParamsCount$,
|
||||
usePreRequestScript,
|
||||
useTestScript,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export type RequestOptionTabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const selectedRealtimeTab = ref<RequestOptionTabs>("params")
|
||||
|
||||
const changeTab = (e: RequestOptionTabs) => {
|
||||
selectedRealtimeTab.value = e
|
||||
}
|
||||
|
||||
const newActiveParamsCount$ = useReadonlyStream(
|
||||
restActiveParamsCount$.pipe(
|
||||
map((e) => {
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
})
|
||||
),
|
||||
null
|
||||
)
|
||||
|
||||
const newActiveHeadersCount$ = useReadonlyStream(
|
||||
restActiveHeadersCount$.pipe(
|
||||
map((e) => {
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
})
|
||||
),
|
||||
null
|
||||
)
|
||||
|
||||
const preRequestScript = usePreRequestScript()
|
||||
|
||||
const testScript = useTestScript()
|
||||
</script>
|
||||
42
packages/hoppscotch-common/src/components/http/Response.vue
Normal file
42
packages/hoppscotch-common/src/components/http/Response.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1">
|
||||
<HttpResponseMeta :response="response" />
|
||||
<LensesResponseBodyRenderer
|
||||
v-if="!loading && hasResponse"
|
||||
:response="response"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, watch } from "vue"
|
||||
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { restResponse$ } from "~/newstore/RESTSession"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const response = useReadonlyStream(restResponse$, null)
|
||||
|
||||
const hasResponse = computed(
|
||||
() =>
|
||||
response.value?.type === "success" || response.value?.type === "fail"
|
||||
)
|
||||
|
||||
const loading = computed(
|
||||
() => response.value === null || response.value.type === "loading"
|
||||
)
|
||||
|
||||
watch(response, () => {
|
||||
if (response.value?.type === "loading") startPageProgress()
|
||||
else completePageProgress()
|
||||
})
|
||||
|
||||
return {
|
||||
hasResponse,
|
||||
response,
|
||||
loading,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
149
packages/hoppscotch-common/src/components/http/ResponseMeta.vue
Normal file
149
packages/hoppscotch-common/src/components/http/ResponseMeta.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-start justify-center flex-shrink-0 p-4 overflow-auto overflow-x-auto bg-primary whitespace-nowrap"
|
||||
>
|
||||
<AppShortcutsPrompt v-if="response == null" class="flex-1" />
|
||||
<div v-else class="flex flex-col flex-1">
|
||||
<div
|
||||
v-if="response.type === 'loading'"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<SmartSpinner class="my-4" />
|
||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="response.type === 'network_fail'"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
|
||||
:alt="`${t('error.network_fail')}`"
|
||||
/>
|
||||
<span class="mb-2 font-semibold text-center">
|
||||
{{ t("error.network_fail") }}
|
||||
</span>
|
||||
<span
|
||||
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
|
||||
>
|
||||
{{ t("helpers.network_fail") }}
|
||||
</span>
|
||||
<AppInterceptor class="p-2 border rounded border-dividerLight" />
|
||||
</div>
|
||||
<div
|
||||
v-if="response.type === 'script_fail'"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
|
||||
:alt="`${t('error.script_fail')}`"
|
||||
/>
|
||||
<span class="mb-2 font-semibold text-center">
|
||||
{{ t("error.script_fail") }}
|
||||
</span>
|
||||
<span
|
||||
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
|
||||
>
|
||||
{{ t("helpers.script_fail") }}
|
||||
</span>
|
||||
<div
|
||||
class="w-full px-4 py-2 overflow-auto font-mono text-red-400 whitespace-normal rounded bg-primaryLight"
|
||||
>
|
||||
{{ response.error.name }}: {{ response.error.message }}<br />
|
||||
{{ response.error.stack }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="response.type === 'success' || response.type === 'fail'"
|
||||
class="flex items-center font-semibold text-tiny"
|
||||
>
|
||||
<div
|
||||
:class="statusCategory.className"
|
||||
class="inline-flex flex-1 space-x-4"
|
||||
>
|
||||
<span v-if="response.statusCode">
|
||||
<span class="text-secondary"> {{ t("response.status") }}: </span>
|
||||
{{ `${response.statusCode}\xA0 • \xA0`
|
||||
}}{{ getStatusCodeReasonPhrase(response.statusCode) }}
|
||||
</span>
|
||||
<span v-if="response.meta && response.meta.responseDuration">
|
||||
<span class="text-secondary"> {{ t("response.time") }}: </span>
|
||||
{{ `${response.meta.responseDuration} ms` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="response.meta && response.meta.responseSize"
|
||||
v-tippy="
|
||||
readableResponseSize
|
||||
? { theme: 'tooltip' }
|
||||
: { onShow: () => false }
|
||||
"
|
||||
:title="`${response.meta.responseSize} B`"
|
||||
>
|
||||
<span class="text-secondary"> {{ t("response.size") }}: </span>
|
||||
{{
|
||||
readableResponseSize
|
||||
? readableResponseSize
|
||||
: `${response.meta.responseSize} B`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import findStatusGroup from "@helpers/findStatusGroup"
|
||||
import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Gives the response size in a human readable format
|
||||
* (changes unit from B to MB/KB depending on the size)
|
||||
* If no changes (error res state) or value can be made (size < 1KB ?),
|
||||
* it returns undefined
|
||||
*/
|
||||
const readableResponseSize = computed(() => {
|
||||
if (
|
||||
props.response.type === "loading" ||
|
||||
props.response.type === "network_fail" ||
|
||||
props.response.type === "script_fail" ||
|
||||
props.response.type === "fail"
|
||||
)
|
||||
return undefined
|
||||
|
||||
const size = props.response.meta.responseSize
|
||||
|
||||
if (size >= 100000) return (size / 1000000).toFixed(2) + " MB"
|
||||
if (size >= 1000) return (size / 1000).toFixed(2) + " KB"
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const statusCategory = computed(() => {
|
||||
if (
|
||||
props.response.type === "loading" ||
|
||||
props.response.type === "network_fail" ||
|
||||
props.response.type === "script_fail" ||
|
||||
props.response.type === "fail"
|
||||
)
|
||||
return {
|
||||
name: "error",
|
||||
className: "text-red-500",
|
||||
}
|
||||
return findStatusGroup(props.response.statusCode)
|
||||
})
|
||||
</script>
|
||||
40
packages/hoppscotch-common/src/components/http/Sidebar.vue
Normal file
40
packages/hoppscotch-common/src/components/http/Sidebar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-model="selectedNavigationTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary z-10 top-0"
|
||||
vertical
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab :id="'history'" :icon="IconClock" :label="`${t('tab.history')}`">
|
||||
<History :page="'rest'" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'collections'"
|
||||
:icon="IconFolder"
|
||||
:label="`${t('tab.collections')}`"
|
||||
>
|
||||
<Collections />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
:id="'env'"
|
||||
:icon="IconLayers"
|
||||
:label="`${t('environment.title')}`"
|
||||
>
|
||||
<Environments />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconClock from "~icons/lucide/clock"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import { ref } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type RequestOptionTabs = "history" | "collections" | "env"
|
||||
|
||||
const selectedNavigationTab = ref<RequestOptionTabs>("history")
|
||||
</script>
|
||||
300
packages/hoppscotch-common/src/components/http/TestResult.vue
Normal file
300
packages/hoppscotch-common/src/components/http/TestResult.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="
|
||||
testResults &&
|
||||
(testResults.expectResults.length ||
|
||||
testResults.tests.length ||
|
||||
haveEnvVariables)
|
||||
"
|
||||
>
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("test.report") }}
|
||||
</label>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-b divide-y-4 divide-dividerLight border-dividerLight">
|
||||
<div v-if="haveEnvVariables" class="flex flex-col">
|
||||
<details class="flex flex-col divide-y divide-dividerLight" open>
|
||||
<summary
|
||||
class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary"
|
||||
>
|
||||
<icon-lucide-chevron-right class="mr-2 indicator" />
|
||||
<span class="truncate capitalize-first">
|
||||
{{ t("environment.title") }}
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="divide-y divide-dividerLight">
|
||||
<div
|
||||
v-if="noEnvSelected && !globalHasAdditions"
|
||||
class="flex p-4 bg-error text-secondaryDark"
|
||||
role="alert"
|
||||
>
|
||||
<component :is="IconAlertTriangle" class="mr-4 svg-icons" />
|
||||
<div class="flex flex-col">
|
||||
<p>
|
||||
{{ t("environment.no_environment_description") }}
|
||||
</p>
|
||||
<p class="flex mt-3 space-x-2">
|
||||
<ButtonSecondary
|
||||
:label="t('environment.add_to_global')"
|
||||
class="text-tiny !bg-primary"
|
||||
filled
|
||||
@click="addEnvToGlobal()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('environment.create_new')"
|
||||
class="text-tiny !bg-primary"
|
||||
filled
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.global.additions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="additions"
|
||||
global
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.global.updations"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="updations"
|
||||
global
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.additions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="additions"
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.updations"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="updations"
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.deletions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="deletions"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div v-if="testResults.tests" class="divide-y-4 divide-dividerLight">
|
||||
<HttpTestResultEntry
|
||||
v-for="(result, index) in testResults.tests"
|
||||
:key="`result-${index}`"
|
||||
:test-results="result"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="testResults.expectResults"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<HttpTestResultReport
|
||||
v-if="testResults.expectResults.length"
|
||||
:test-results="testResults"
|
||||
/>
|
||||
<div
|
||||
v-for="(result, index) in testResults.expectResults"
|
||||
:key="`result-${index}`"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-shrink flex-shrink-0 overflow-x-auto"
|
||||
>
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="mr-4 svg-icons"
|
||||
:class="
|
||||
result.status === 'pass' ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center flex-shrink flex-shrink-0 space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span
|
||||
v-if="result.message"
|
||||
class="inline-flex text-secondaryDark"
|
||||
>
|
||||
{{ result.message }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryLight">
|
||||
<icon-lucide-minus class="mr-2 svg-icons" />
|
||||
{{
|
||||
result.status === "pass"
|
||||
? t("test.passed")
|
||||
: t("test.failed")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="testResults && testResults.scriptError"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-32 h-32 my-4"
|
||||
:alt="`${t('error.test_script_fail')}`"
|
||||
/>
|
||||
<span class="mb-2 font-semibold text-center">
|
||||
{{ t("error.test_script_fail") }}
|
||||
</span>
|
||||
<span
|
||||
class="max-w-sm mb-6 text-center whitespace-normal text-secondaryLight"
|
||||
>
|
||||
{{ t("helpers.test_script_fail") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/validation.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.tests')}`"
|
||||
/>
|
||||
<span class="pb-2 text-center">
|
||||
{{ t("empty.tests") }}
|
||||
</span>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("helpers.tests") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="`${t('action.learn_more')}`"
|
||||
to="https://docs.hoppscotch.io/features/tests"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="my-4"
|
||||
/>
|
||||
</div>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showModalDetails"
|
||||
action="new"
|
||||
:env-vars="getAdditionVars"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, Ref, ref } from "vue"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
globalEnv$,
|
||||
selectedEnvironmentIndex$,
|
||||
setGlobalEnvVariables,
|
||||
setSelectedEnvironmentIndex,
|
||||
} from "~/newstore/environments"
|
||||
import { restTestResults$, setRESTTestResults } from "~/newstore/RESTSession"
|
||||
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
|
||||
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconAlertTriangle from "~icons/lucide/alert-triangle"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClose from "~icons/lucide/x"
|
||||
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const showModalDetails = ref(false)
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
showModalDetails.value = shouldDisplay
|
||||
}
|
||||
|
||||
const testResults = useReadonlyStream(
|
||||
restTestResults$,
|
||||
null
|
||||
) as Ref<HoppTestResult | null>
|
||||
|
||||
/**
|
||||
* Get the "addition" environment variables
|
||||
* @returns Array of objects with key-value pairs of arguments
|
||||
*/
|
||||
const getAdditionVars = () =>
|
||||
testResults?.value?.envDiff?.selected?.additions
|
||||
? testResults.value.envDiff.selected.additions
|
||||
: []
|
||||
|
||||
const clearContent = () => setRESTTestResults(null)
|
||||
|
||||
const haveEnvVariables = computed(() => {
|
||||
if (!testResults.value) return false
|
||||
return (
|
||||
testResults.value.envDiff.global.additions.length ||
|
||||
testResults.value.envDiff.global.updations.length ||
|
||||
testResults.value.envDiff.global.deletions.length ||
|
||||
testResults.value.envDiff.selected.additions.length ||
|
||||
testResults.value.envDiff.selected.updations.length ||
|
||||
testResults.value.envDiff.selected.deletions.length
|
||||
)
|
||||
})
|
||||
|
||||
const selectedEnvironmentIndex = useStream(
|
||||
selectedEnvironmentIndex$,
|
||||
{ type: "NO_ENV_SELECTED" },
|
||||
setSelectedEnvironmentIndex
|
||||
)
|
||||
|
||||
const globalEnvVars = useReadonlyStream(globalEnv$, []) as Ref<
|
||||
Array<{
|
||||
key: string
|
||||
value: string
|
||||
}>
|
||||
>
|
||||
|
||||
const noEnvSelected = computed(
|
||||
() => selectedEnvironmentIndex.value.type === "NO_ENV_SELECTED"
|
||||
)
|
||||
|
||||
const globalHasAdditions = computed(() => {
|
||||
if (!testResults.value?.envDiff.selected.additions) return false
|
||||
return (
|
||||
testResults.value.envDiff.selected.additions.every(
|
||||
(x) => globalEnvVars.value.findIndex((y) => isEqual(x, y)) !== -1
|
||||
) ?? false
|
||||
)
|
||||
})
|
||||
|
||||
const addEnvToGlobal = () => {
|
||||
if (!testResults.value?.envDiff.selected.additions) return
|
||||
setGlobalEnvVariables([
|
||||
...globalEnvVars.value,
|
||||
...testResults.value.envDiff.selected.additions,
|
||||
])
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<span
|
||||
v-if="testResults.description"
|
||||
class="flex items-center px-4 py-2 font-bold text-secondaryDark"
|
||||
>
|
||||
{{ testResults.description }}
|
||||
</span>
|
||||
<div v-if="testResults.expectResults" class="divide-y divide-dividerLight">
|
||||
<HttpTestResultReport
|
||||
v-if="testResults.expectResults.length"
|
||||
:test-results="testResults"
|
||||
/>
|
||||
<div
|
||||
v-for="(result, index) in testResults.expectResults"
|
||||
:key="`result-${index}`"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-shrink flex-shrink-0 overflow-x-auto"
|
||||
>
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="mr-4 svg-icons"
|
||||
:class="
|
||||
result.status === 'pass' ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center flex-shrink flex-shrink-0 space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span v-if="result.message" class="inline-flex text-secondaryDark">
|
||||
{{ result.message }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryLight">
|
||||
<icon-lucide-minus class="mr-2 svg-icons" />
|
||||
{{
|
||||
result.status === "pass" ? t("test.passed") : t("test.failed")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue"
|
||||
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClose from "~icons/lucide/x"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps({
|
||||
testResults: {
|
||||
type: Object as PropType<HoppTestResult>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex items-center flex-shrink flex-shrink-0 overflow-x-auto">
|
||||
<component
|
||||
:is="getIcon(status)"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
class="mr-4 svg-icons cursor-help"
|
||||
:class="getStyle(status)"
|
||||
:title="`${t(getTooltip(status))}`"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center flex-shrink flex-shrink-0 space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span class="inline-flex text-secondaryDark">
|
||||
{{ env.key }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryDark">
|
||||
<icon-lucide-minus class="mr-2 svg-icons" />
|
||||
{{ env.value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="status === 'updations'"
|
||||
class="inline-flex text-secondaryLight"
|
||||
>
|
||||
<icon-lucide-arrow-left class="mr-2 svg-icons" />
|
||||
{{ env.previousValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="global"
|
||||
class="px-1 ml-4 rounded bg-accentLight text-accentContrast text-tiny"
|
||||
>
|
||||
Global
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
import IconPlusCircle from "~icons/lucide/plus-circle"
|
||||
import IconCheckCircle2 from "~icons/lucide/check-circle-2"
|
||||
import IconMinusCircle from "~icons/lucide/minus-circle"
|
||||
|
||||
type Status = "updations" | "additions" | "deletions"
|
||||
type Props = {
|
||||
env: {
|
||||
key: string
|
||||
value: string
|
||||
previousValue?: string
|
||||
}
|
||||
status: Status
|
||||
global: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
global: false,
|
||||
})
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const getIcon = (status: Status) => {
|
||||
switch (status) {
|
||||
case "additions":
|
||||
return IconPlusCircle
|
||||
case "updations":
|
||||
return IconCheckCircle2
|
||||
case "deletions":
|
||||
return IconMinusCircle
|
||||
}
|
||||
}
|
||||
|
||||
const getStyle = (status: Status) => {
|
||||
switch (status) {
|
||||
case "additions":
|
||||
return "text-green-500"
|
||||
case "updations":
|
||||
return "text-yellow-500"
|
||||
case "deletions":
|
||||
return "text-red-500"
|
||||
}
|
||||
}
|
||||
|
||||
const getTooltip = (status: Status) => {
|
||||
switch (status) {
|
||||
case "additions":
|
||||
return "environment.added"
|
||||
case "updations":
|
||||
return "environment.updated"
|
||||
case "deletions":
|
||||
return "environment.deleted"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex items-center px-4 py-2">
|
||||
<SmartProgressRing
|
||||
class="mr-2 text-red-500"
|
||||
:radius="8"
|
||||
:stroke="1.5"
|
||||
:progress="(failedTests / totalTests) * 100"
|
||||
/>
|
||||
<div class="ml-2">
|
||||
<span v-if="failedTests" class="text-red-500">
|
||||
{{ failedTests }} failing,
|
||||
</span>
|
||||
<span v-if="passedTests" class="text-green-500">
|
||||
{{ passedTests }} successful,
|
||||
</span>
|
||||
<span> out of {{ totalTests }} tests. </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, PropType } from "vue"
|
||||
import {
|
||||
HoppTestExpectResult,
|
||||
HoppTestResult,
|
||||
} from "~/helpers/types/HoppTestResult"
|
||||
|
||||
const props = defineProps({
|
||||
testResults: {
|
||||
type: Object as PropType<HoppTestResult>,
|
||||
required: true,
|
||||
expectResults: [] as HoppTestExpectResult[],
|
||||
},
|
||||
})
|
||||
|
||||
const totalTests = computed(() => props.testResults.expectResults.length)
|
||||
const failedTests = computed(
|
||||
() =>
|
||||
props.testResults.expectResults.filter(
|
||||
(result: { status: string }) => result.status === "fail"
|
||||
).length
|
||||
)
|
||||
const passedTests = computed(
|
||||
() =>
|
||||
props.testResults.expectResults.filter(
|
||||
(result: { status: string }) => result.status === "pass"
|
||||
).length
|
||||
)
|
||||
</script>
|
||||
105
packages/hoppscotch-common/src/components/http/Tests.vue
Normal file
105
packages/hoppscotch-common/src/components/http/Tests.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<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-upperMobileSecondaryStickyFold sm:top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("test.javascript_code") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/tests"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div ref="testScriptEditor" class="h-full"></div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.post_request_tests") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
:label="`${t('test.learn')}`"
|
||||
to="https://docs.hoppscotch.io/features/tests"
|
||||
blank
|
||||
/>
|
||||
<h4 class="pt-6 font-bold text-secondaryLight">
|
||||
{{ t("test.snippets") }}
|
||||
</h4>
|
||||
<div class="flex flex-col pt-4">
|
||||
<TabSecondary
|
||||
v-for="(snippet, index) in testSnippets"
|
||||
:key="`snippet-${index}`"
|
||||
:label="snippet.name"
|
||||
active
|
||||
@click="useSnippet(snippet.script)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import { reactive, ref } from "vue"
|
||||
import { useTestScript } from "~/newstore/RESTSession"
|
||||
import testSnippets from "~/helpers/testSnippets"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import linter from "~/helpers/editor/linting/testScript"
|
||||
import completer from "~/helpers/editor/completion/testScript"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const testScript = useTestScript()
|
||||
|
||||
const testScriptEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
testScriptEditor,
|
||||
testScript,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/javascript",
|
||||
lineWrapping: linewrapEnabled,
|
||||
placeholder: `${t("test.javascript_code")}`,
|
||||
},
|
||||
linter,
|
||||
completer,
|
||||
environmentHighlights: false,
|
||||
})
|
||||
)
|
||||
|
||||
const useSnippet = (script: string) => {
|
||||
testScript.value += script
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
testScript.value = ""
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,419 @@
|
||||
<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-upperMobileStickyFold sm:top-upperMobileTertiaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/body"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="bulkMode"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.bulk_mode')"
|
||||
:icon="IconEdit"
|
||||
:class="{ '!text-accent': bulkMode }"
|
||||
@click="bulkMode = !bulkMode"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
:disabled="bulkMode"
|
||||
@click="addUrlEncodedParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-col flex-1"></div>
|
||||
<div v-else>
|
||||
<draggable
|
||||
v-model="workingUrlEncodedParams"
|
||||
:item-key="(param) => `param-${param.id}`"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: param, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== workingUrlEncodedParams?.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 !== workingUrlEncodedParams?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<SmartEnvInput
|
||||
v-model="param.key"
|
||||
:placeholder="`${t('count.parameter', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateUrlEncodedParam(index, {
|
||||
id: param.id,
|
||||
key: $event,
|
||||
value: param.value,
|
||||
active: param.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<SmartEnvInput
|
||||
v-model="param.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
@change="
|
||||
updateUrlEncodedParam(index, {
|
||||
id: param.id,
|
||||
key: param.key,
|
||||
value: $event,
|
||||
active: param.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
param.hasOwnProperty('active')
|
||||
? param.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
param.hasOwnProperty('active')
|
||||
? param.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateUrlEncodedParam(index, {
|
||||
id: param.id,
|
||||
key: param.key,
|
||||
value: param.value,
|
||||
active: !param.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteUrlEncodedParam(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="workingUrlEncodedParams.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.body')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.body") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
filled
|
||||
:label="`${t('add.new')}`"
|
||||
:icon="IconPlus"
|
||||
class="mb-4"
|
||||
@click="addUrlEncodedParam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { computed, reactive, ref, watch } from "vue"
|
||||
import { isEqual, cloneDeep } from "lodash-es"
|
||||
import {
|
||||
parseRawKeyValueEntries,
|
||||
parseRawKeyValueEntriesE,
|
||||
rawKeyValueEntriesToString,
|
||||
RawKeyValueEntry,
|
||||
} from "@hoppscotch/data"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as E from "fp-ts/Either"
|
||||
import draggable from "vuedraggable"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import linter from "~/helpers/editor/linting/rawKeyValue"
|
||||
import { useRESTRequestBody } from "~/newstore/RESTSession"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { objRemoveKey } from "~/helpers/functional/object"
|
||||
import { throwError } from "~/helpers/functional/error"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const idTicker = ref(0)
|
||||
|
||||
const bulkMode = ref(false)
|
||||
const bulkUrlEncodedParams = ref("")
|
||||
const bulkEditor = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
|
||||
|
||||
useCodemirror(
|
||||
bulkEditor,
|
||||
bulkUrlEncodedParams,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/x-yaml",
|
||||
placeholder: `${t("state.bulk_mode_placeholder")}`,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
// The functional urlEncodedParams list (the urlEncodedParams actually in the system)
|
||||
const urlEncodedParamsRaw = pluckRef(useRESTRequestBody(), "body")
|
||||
|
||||
const urlEncodedParams = computed<RawKeyValueEntry[]>({
|
||||
get() {
|
||||
return typeof urlEncodedParamsRaw.value == "string"
|
||||
? parseRawKeyValueEntries(urlEncodedParamsRaw.value)
|
||||
: []
|
||||
},
|
||||
set(newValue) {
|
||||
urlEncodedParamsRaw.value = rawKeyValueEntriesToString(newValue)
|
||||
},
|
||||
})
|
||||
|
||||
// The UI representation of the urlEncodedParams list (has the empty end urlEncodedParam)
|
||||
const workingUrlEncodedParams = ref<Array<RawKeyValueEntry & { id: number }>>([
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
])
|
||||
|
||||
// Rule: Working urlEncodedParams always have one empty urlEncodedParam or the last element is always an empty urlEncodedParams
|
||||
watch(workingUrlEncodedParams, (urlEncodedParamList) => {
|
||||
if (
|
||||
urlEncodedParamList.length > 0 &&
|
||||
urlEncodedParamList[urlEncodedParamList.length - 1].key !== ""
|
||||
) {
|
||||
workingUrlEncodedParams.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sync logic between urlEncodedParams and working urlEncodedParams
|
||||
watch(
|
||||
urlEncodedParams,
|
||||
(newurlEncodedParamList) => {
|
||||
const filteredWorkingUrlEncodedParams = pipe(
|
||||
workingUrlEncodedParams.value,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((x) => x.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredBulkUrlEncodedParams = pipe(
|
||||
parseRawKeyValueEntriesE(bulkUrlEncodedParams.value),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(newurlEncodedParamList, filteredWorkingUrlEncodedParams)) {
|
||||
workingUrlEncodedParams.value = pipe(
|
||||
newurlEncodedParamList,
|
||||
A.map((x) => ({ id: idTicker.value++, ...x }))
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEqual(newurlEncodedParamList, filteredBulkUrlEncodedParams)) {
|
||||
bulkUrlEncodedParams.value = rawKeyValueEntriesToString(
|
||||
newurlEncodedParamList
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workingUrlEncodedParams, (newWorkingUrlEncodedParams) => {
|
||||
const fixedUrlEncodedParams = pipe(
|
||||
newWorkingUrlEncodedParams,
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((e) => e.key !== ""),
|
||||
O.map(objRemoveKey("id"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (!isEqual(urlEncodedParams.value, fixedUrlEncodedParams)) {
|
||||
urlEncodedParams.value = fixedUrlEncodedParams
|
||||
}
|
||||
})
|
||||
|
||||
watch(bulkUrlEncodedParams, (newBulkUrlEncodedParams) => {
|
||||
const filteredBulkParams = pipe(
|
||||
parseRawKeyValueEntriesE(newBulkUrlEncodedParams),
|
||||
E.map(
|
||||
flow(
|
||||
RA.filter((e) => e.key !== ""),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
E.getOrElse(() => [] as RawKeyValueEntry[])
|
||||
)
|
||||
|
||||
if (!isEqual(urlEncodedParams.value, filteredBulkParams)) {
|
||||
urlEncodedParams.value = filteredBulkParams
|
||||
}
|
||||
})
|
||||
|
||||
const addUrlEncodedParam = () => {
|
||||
workingUrlEncodedParams.value.push({
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const updateUrlEncodedParam = (
|
||||
index: number,
|
||||
param: RawKeyValueEntry & { id: number }
|
||||
) => {
|
||||
workingUrlEncodedParams.value = workingUrlEncodedParams.value.map((p, i) =>
|
||||
i === index ? param : p
|
||||
)
|
||||
}
|
||||
|
||||
const deleteUrlEncodedParam = (index: number) => {
|
||||
const urlEncodedParamsBeforeDeletion = cloneDeep(
|
||||
workingUrlEncodedParams.value
|
||||
)
|
||||
|
||||
if (
|
||||
!(
|
||||
urlEncodedParamsBeforeDeletion.length > 0 &&
|
||||
index === urlEncodedParamsBeforeDeletion.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) => {
|
||||
workingUrlEncodedParams.value = urlEncodedParamsBeforeDeletion
|
||||
toastObject.goAway(0)
|
||||
deletionToast.value = null
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
onComplete: () => {
|
||||
deletionToast.value = null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
workingUrlEncodedParams.value = pipe(
|
||||
workingUrlEncodedParams.value,
|
||||
A.deleteAt(index),
|
||||
O.getOrElseW(() =>
|
||||
throwError("Working URL Encoded Params Deletion Out of Bounds")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
// set urlEncodedParams list to the initial state
|
||||
workingUrlEncodedParams.value = [
|
||||
{
|
||||
id: idTicker.value++,
|
||||
key: "",
|
||||
value: "",
|
||||
active: true,
|
||||
},
|
||||
]
|
||||
|
||||
bulkUrlEncodedParams.value = ""
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("request.header_list") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="headers"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
@click="copyHeaders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LensesHeadersRendererEntry
|
||||
v-for="(header, index) in headers"
|
||||
:key="index"
|
||||
:header="header"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { HoppRESTHeader } from "@hoppscotch/data"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
headers: Array<HoppRESTHeader>
|
||||
}>()
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const copyHeaders = () => {
|
||||
copyToClipboard(JSON.stringify(props.headers))
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight group"
|
||||
>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate rounded-sm select-all">
|
||||
{{ header.key }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="flex justify-between flex-1 min-w-0 py-2 pl-4 transition group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate rounded-sm select-all">
|
||||
{{ header.value }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.copy')"
|
||||
:icon="copyIcon"
|
||||
class="hidden group-hover:inline-flex !py-0"
|
||||
@click="copyHeader(header.value)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import type { HoppRESTHeader } from "@hoppscotch/data"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
defineProps<{
|
||||
header: HoppRESTHeader
|
||||
}>()
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const copyHeader = (headerValue: string) => {
|
||||
copyToClipboard(headerValue)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-if="response"
|
||||
v-model="selectedLensTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 z-10 bg-primary top-lowerPrimaryStickyFold"
|
||||
>
|
||||
<SmartTab
|
||||
v-for="(lens, index) in validLenses"
|
||||
:id="lens.renderer"
|
||||
:key="`lens-${index}`"
|
||||
:label="t(lens.lensName)"
|
||||
class="flex flex-col flex-1 w-full h-full"
|
||||
>
|
||||
<component :is="lens.renderer" :response="response" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
v-if="headerLength"
|
||||
id="headers"
|
||||
:label="t('response.headers')"
|
||||
:info="`${headerLength}`"
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<LensesHeadersRenderer :headers="response.headers" />
|
||||
</SmartTab>
|
||||
<SmartTab
|
||||
id="results"
|
||||
:label="t('test.results')"
|
||||
:indicator="
|
||||
testResults &&
|
||||
(testResults.expectResults.length ||
|
||||
testResults.tests.length ||
|
||||
testResults.envDiff.selected.additions.length ||
|
||||
testResults.envDiff.selected.updations.length ||
|
||||
testResults.envDiff.global.updations.length)
|
||||
? true
|
||||
: false
|
||||
"
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<HttpTestResult />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { getSuitableLenses, getLensRenderers } from "~/helpers/lenses/lenses"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { restTestResults$ } from "~/newstore/RESTSession"
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
// Lens Renderers
|
||||
...getLensRenderers(),
|
||||
},
|
||||
props: {
|
||||
response: { type: Object, default: () => ({}) },
|
||||
},
|
||||
setup() {
|
||||
const testResults = useReadonlyStream(restTestResults$, null)
|
||||
|
||||
return {
|
||||
testResults,
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedLensTab: "",
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
headerLength() {
|
||||
if (!this.response || !this.response.headers) return 0
|
||||
|
||||
return Object.keys(this.response.headers).length
|
||||
},
|
||||
validLenses() {
|
||||
if (!this.response) return []
|
||||
|
||||
return getSuitableLenses(this.response)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
validLenses: {
|
||||
handler(newValue) {
|
||||
if (newValue.length === 0) return
|
||||
this.selectedLensTab = newValue[0].renderer
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${
|
||||
previewEnabled ? t('hide.preview') : t('response.preview_html')
|
||||
} <kbd>${getSpecialKey()}</kbd><kbd>Shift</kbd><kbd>P</kbd>`"
|
||||
:icon="!previewEnabled ? IconEye : IconEyeOff"
|
||||
@click.prevent="togglePreview"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!previewEnabled"
|
||||
ref="htmlResponse"
|
||||
class="flex flex-col flex-1"
|
||||
></div>
|
||||
<iframe
|
||||
v-show="previewEnabled"
|
||||
ref="previewFrame"
|
||||
class="covers-response"
|
||||
src="about:blank"
|
||||
loading="lazy"
|
||||
sandbox=""
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
import IconEyeOff from "~icons/lucide/eye-off"
|
||||
import { ref, reactive } from "vue"
|
||||
import {
|
||||
usePreview,
|
||||
useResponseBody,
|
||||
useCopyResponse,
|
||||
useDownloadResponse,
|
||||
} from "@composables/lens-actions"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse & { type: "success" | "fail" }
|
||||
}>()
|
||||
|
||||
const htmlResponse = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const { responseBodyText } = useResponseBody(props.response)
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
"text/html",
|
||||
responseBodyText
|
||||
)
|
||||
const { previewFrame, previewEnabled, togglePreview } = usePreview(
|
||||
false,
|
||||
responseBodyText
|
||||
)
|
||||
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
|
||||
|
||||
useCodemirror(
|
||||
htmlResponse,
|
||||
responseBodyText,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "htmlmixed",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
defineActionHandler("response.preview.toggle", () => togglePreview())
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.covers-response {
|
||||
@apply bg-white;
|
||||
@apply h-full;
|
||||
@apply w-full;
|
||||
@apply border;
|
||||
@apply border-dividerLight;
|
||||
@apply z-5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
class="flex max-w-full border-b border-dividerLight"
|
||||
:src="imageSource"
|
||||
loading="lazy"
|
||||
:alt="imageSource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { useDownloadResponse } from "~/composables/lens-actions"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { objFieldMatches } from "~/helpers/functional/object"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse & { type: "success" | "fail" }
|
||||
}>()
|
||||
|
||||
const imageSource = ref("")
|
||||
|
||||
const responseType = computed(() =>
|
||||
pipe(
|
||||
props.response,
|
||||
O.fromPredicate(objFieldMatches("type", ["fail", "success"] as const)),
|
||||
O.chain(
|
||||
// Try getting content-type
|
||||
flow(
|
||||
(res) => res.headers,
|
||||
A.findFirst((h) => h.key.toLowerCase() === "content-type"),
|
||||
O.map(flow((h) => h.value, S.split(";"), RNEA.head, S.toLowerCase))
|
||||
)
|
||||
),
|
||||
O.getOrElse(() => "text/plain")
|
||||
)
|
||||
)
|
||||
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
responseType.value,
|
||||
computed(() => props.response.body)
|
||||
)
|
||||
|
||||
watch(props.response, () => {
|
||||
imageSource.value = ""
|
||||
const buf = props.response.body
|
||||
const bytes = new Uint8Array(buf)
|
||||
const blob = new Blob([bytes.buffer])
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({ target }) => {
|
||||
// target.result will always be string because we're using FileReader.readAsDataURL
|
||||
imageSource.value = target!.result as string
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
imageSource.value = ""
|
||||
const buf = props.response.body
|
||||
const bytes = new Uint8Array(buf)
|
||||
const blob = new Blob([bytes.buffer])
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = ({ target }) => {
|
||||
// target.result will always be string because we're using FileReader.readAsDataURL
|
||||
imageSource.value = target!.result as string
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
</script>
|
||||
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="response.type === 'success' || response.type === 'fail'"
|
||||
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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.filter')"
|
||||
:icon="IconFilter"
|
||||
:class="{ '!text-accent': toggleFilter }"
|
||||
@click.prevent="toggleFilterState"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="toggleFilter"
|
||||
class="sticky z-10 flex flex-shrink-0 overflow-x-auto border-b bg-primary top-lowerTertiaryStickyFold border-dividerLight"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center flex-1 bg-primaryLight border-divider text-secondaryDark"
|
||||
>
|
||||
<span class="inline-flex items-center flex-1 px-4">
|
||||
<icon-lucide-search class="w-4 h-4 text-secondaryLight" />
|
||||
<input
|
||||
v-model="filterQueryText"
|
||||
v-focus
|
||||
class="input !border-0 !px-2"
|
||||
:placeholder="`${t('response.filter_response_body')}`"
|
||||
type="text"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="filterResponseError"
|
||||
class="flex items-center justify-center px-2 py-1 rounded text-tiny text-accentContrast"
|
||||
:class="{
|
||||
'bg-red-500':
|
||||
filterResponseError.type === 'JSON_PARSE_FAILED' ||
|
||||
filterResponseError.type === 'JSON_PATH_QUERY_ERROR',
|
||||
'bg-amber-500': filterResponseError.type === 'RESPONSE_EMPTY',
|
||||
}"
|
||||
>
|
||||
<icon-lucide-info class="svg-icons mr-1.5" />
|
||||
<span>{{ filterResponseError.error }}</span>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
to="https://github.com/JSONPath-Plus/JSONPath"
|
||||
blank
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="jsonResponse" class="flex flex-col flex-1 h-auto h-full"></div>
|
||||
<div
|
||||
v-if="outlinePath"
|
||||
class="sticky bottom-0 z-10 flex flex-shrink-0 px-2 overflow-auto overflow-x-auto border-t bg-primaryLight border-dividerLight flex-nowrap"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in outlinePath"
|
||||
:key="`item-${index}`"
|
||||
class="flex items-center"
|
||||
>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions[index].focus()"
|
||||
>
|
||||
<div v-if="item.kind === 'RootObject'" class="outline-item">{}</div>
|
||||
<div v-if="item.kind === 'RootArray'" class="outline-item">[]</div>
|
||||
<div v-if="item.kind === 'ArrayMember'" class="outline-item">
|
||||
{{ item.index }}
|
||||
</div>
|
||||
<div v-if="item.kind === 'ObjectMember'" class="outline-item">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-if="item.kind === 'ArrayMember' || item.kind === 'ObjectMember'"
|
||||
>
|
||||
<div
|
||||
v-if="item.kind === 'ArrayMember'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(arrayMember, astIndex) in item.astParent.values"
|
||||
:key="`ast-${astIndex}`"
|
||||
:label="`${astIndex}`"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(arrayMember)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'ObjectMember'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(objectMember, astIndex) in item.astParent.members"
|
||||
:key="`ast-${astIndex}`"
|
||||
:label="objectMember.key.value"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(objectMember)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'RootObject'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<SmartItem
|
||||
label="{}"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(item.astValue)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.kind === 'RootArray'"
|
||||
ref="tippyActions"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<SmartItem
|
||||
label="[]"
|
||||
@click="
|
||||
() => {
|
||||
jumpCursor(item.astValue)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<icon-lucide-chevron-right
|
||||
v-if="index + 1 !== outlinePath.length"
|
||||
class="opacity-50 text-secondaryLight svg-icons"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import IconFilter from "~icons/lucide/filter"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import * as LJSON from "lossless-json"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { computed, ref, reactive } from "vue"
|
||||
import { JSONPath } from "jsonpath-plus"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
|
||||
import { getJSONOutlineAtPos } from "~/helpers/newOutline"
|
||||
import {
|
||||
convertIndexToLineCh,
|
||||
convertLineChToIndex,
|
||||
} from "~/helpers/editor/utils"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
useCopyResponse,
|
||||
useResponseBody,
|
||||
useDownloadResponse,
|
||||
} from "@composables/lens-actions"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse
|
||||
}>()
|
||||
|
||||
const { responseBodyText } = useResponseBody(props.response)
|
||||
|
||||
const toggleFilter = ref(false)
|
||||
const filterQueryText = ref("")
|
||||
|
||||
type BodyParseError =
|
||||
| { type: "JSON_PARSE_FAILED" }
|
||||
| { type: "JSON_PATH_QUERY_FAILED"; error: Error }
|
||||
|
||||
const responseJsonObject = computed(() =>
|
||||
pipe(
|
||||
responseBodyText.value,
|
||||
E.tryCatchK(
|
||||
LJSON.parse,
|
||||
(): BodyParseError => ({ type: "JSON_PARSE_FAILED" })
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const jsonResponseBodyText = computed(() => {
|
||||
if (filterQueryText.value.length > 0) {
|
||||
return pipe(
|
||||
responseJsonObject.value,
|
||||
E.chain((parsedJSON) =>
|
||||
E.tryCatch(
|
||||
() =>
|
||||
JSONPath({
|
||||
path: filterQueryText.value,
|
||||
json: parsedJSON,
|
||||
}) as undefined,
|
||||
(err): BodyParseError => ({
|
||||
type: "JSON_PATH_QUERY_FAILED",
|
||||
error: err as Error,
|
||||
})
|
||||
)
|
||||
),
|
||||
E.map(JSON.stringify)
|
||||
)
|
||||
} else {
|
||||
return E.right(responseBodyText.value)
|
||||
}
|
||||
})
|
||||
|
||||
const jsonBodyText = computed(() =>
|
||||
pipe(
|
||||
jsonResponseBodyText.value,
|
||||
E.getOrElse(() => responseBodyText.value),
|
||||
O.tryCatchK(LJSON.parse),
|
||||
O.map((val) => LJSON.stringify(val, undefined, 2)),
|
||||
O.getOrElse(() => responseBodyText.value)
|
||||
)
|
||||
)
|
||||
|
||||
const ast = computed(() =>
|
||||
pipe(
|
||||
jsonBodyText.value,
|
||||
O.tryCatchK(jsonParse),
|
||||
O.getOrElseW(() => null)
|
||||
)
|
||||
)
|
||||
|
||||
const filterResponseError = computed(() =>
|
||||
pipe(
|
||||
jsonResponseBodyText.value,
|
||||
E.match(
|
||||
(e) => {
|
||||
switch (e.type) {
|
||||
case "JSON_PATH_QUERY_FAILED":
|
||||
return { type: "JSON_PATH_QUERY_ERROR", error: e.error.message }
|
||||
case "JSON_PARSE_FAILED":
|
||||
return {
|
||||
type: "JSON_PARSE_FAILED",
|
||||
error: t("error.json_parsing_failed").toString(),
|
||||
}
|
||||
}
|
||||
},
|
||||
(result) =>
|
||||
result === "[]"
|
||||
? {
|
||||
type: "RESPONSE_EMPTY",
|
||||
error: t("error.no_results_found").toString(),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(jsonBodyText)
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
"application/json",
|
||||
jsonBodyText
|
||||
)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const jsonResponse = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
const { cursor } = useCodemirror(
|
||||
jsonResponse,
|
||||
jsonBodyText,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/ld+json",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
|
||||
const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
|
||||
pos.line--
|
||||
cursor.value = pos
|
||||
}
|
||||
|
||||
const outlinePath = computed(() =>
|
||||
pipe(
|
||||
ast.value,
|
||||
O.fromNullable,
|
||||
O.map((ast) =>
|
||||
getJSONOutlineAtPos(
|
||||
ast,
|
||||
convertLineChToIndex(jsonBodyText.value, cursor.value)
|
||||
)
|
||||
),
|
||||
O.getOrElseW(() => null)
|
||||
)
|
||||
)
|
||||
|
||||
const toggleFilterState = () => {
|
||||
filterQueryText.value = ""
|
||||
toggleFilter.value = !toggleFilter.value
|
||||
}
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.outline-item {
|
||||
@apply cursor-pointer;
|
||||
@apply flex-grow-0 flex-shrink-0;
|
||||
@apply text-secondaryLight;
|
||||
@apply inline-flex;
|
||||
@apply items-center;
|
||||
@apply px-2;
|
||||
@apply py-1;
|
||||
@apply transition;
|
||||
@apply hover: text-secondary;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<vue-pdf-embed
|
||||
:source="pdfsrc"
|
||||
class="flex flex-1 overflow-auto border-b border-dividerLight"
|
||||
type="application/pdf"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import VuePdfEmbed from "vue-pdf-embed"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useDownloadResponse } from "@composables/lens-actions"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse & {
|
||||
type: "success" | "fail"
|
||||
}
|
||||
}>()
|
||||
|
||||
const pdfsrc = computed(() =>
|
||||
URL.createObjectURL(
|
||||
new Blob([props.response.body], {
|
||||
type: "application/pdf",
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
"application/pdf",
|
||||
computed(() => props.response.body)
|
||||
)
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="rawResponse" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { ref, computed, reactive } from "vue"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
useResponseBody,
|
||||
useDownloadResponse,
|
||||
useCopyResponse,
|
||||
} from "@composables/lens-actions"
|
||||
import { objFieldMatches } from "~/helpers/functional/object"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse & { type: "success" | "fail" }
|
||||
}>()
|
||||
|
||||
const { responseBodyText } = useResponseBody(props.response)
|
||||
|
||||
const rawResponseBody = computed(() =>
|
||||
props.response.type === "fail" || props.response.type === "success"
|
||||
? props.response.body
|
||||
: new ArrayBuffer(0)
|
||||
)
|
||||
|
||||
const responseType = computed(() =>
|
||||
pipe(
|
||||
props.response,
|
||||
O.fromPredicate(objFieldMatches("type", ["fail", "success"] as const)),
|
||||
O.chain(
|
||||
// Try getting content-type
|
||||
flow(
|
||||
(res) => res.headers,
|
||||
A.findFirst((h) => h.key.toLowerCase() === "content-type"),
|
||||
O.map(flow((h) => h.value, S.split(";"), RNEA.head, S.toLowerCase))
|
||||
)
|
||||
),
|
||||
O.getOrElse(() => "text/plain")
|
||||
)
|
||||
)
|
||||
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
responseType.value,
|
||||
rawResponseBody
|
||||
)
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
|
||||
|
||||
const rawResponse = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
rawResponse,
|
||||
responseBodyText,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "text/plain",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<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-lowerSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("response.body") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': linewrapEnabled }"
|
||||
:icon="IconWrapText"
|
||||
@click.prevent="linewrapEnabled = !linewrapEnabled"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.download_file'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
|
||||
:icon="downloadIcon"
|
||||
@click="downloadResponse"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="response.body"
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'action.copy'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
|
||||
:icon="copyIcon"
|
||||
@click="copyResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="xmlResponse" class="flex flex-col flex-1"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconWrapText from "~icons/lucide/wrap-text"
|
||||
import { computed, ref, reactive } from "vue"
|
||||
import { flow, pipe } from "fp-ts/function"
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { useCodemirror } from "@composables/codemirror"
|
||||
import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
useResponseBody,
|
||||
useDownloadResponse,
|
||||
useCopyResponse,
|
||||
} from "@composables/lens-actions"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { objFieldMatches } from "~/helpers/functional/object"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
response: HoppRESTResponse & { type: "success" | "fail" }
|
||||
}>()
|
||||
|
||||
const { responseBodyText } = useResponseBody(props.response)
|
||||
|
||||
const responseType = computed(() =>
|
||||
pipe(
|
||||
props.response,
|
||||
O.fromPredicate(objFieldMatches("type", ["fail", "success"] as const)),
|
||||
O.chain(
|
||||
// Try getting content-type
|
||||
flow(
|
||||
(res) => res.headers,
|
||||
A.findFirst((h) => h.key.toLowerCase() === "content-type"),
|
||||
O.map(flow((h) => h.value, S.split(";"), RNEA.head, S.toLowerCase))
|
||||
)
|
||||
),
|
||||
O.getOrElse(() => "text/plain")
|
||||
)
|
||||
)
|
||||
|
||||
const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||
responseType.value,
|
||||
responseBodyText
|
||||
)
|
||||
|
||||
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
|
||||
|
||||
const xmlResponse = ref<any | null>(null)
|
||||
const linewrapEnabled = ref(true)
|
||||
|
||||
useCodemirror(
|
||||
xmlResponse,
|
||||
responseBodyText,
|
||||
reactive({
|
||||
extendedEditorConfig: {
|
||||
mode: "application/xml",
|
||||
readOnly: true,
|
||||
lineWrapping: linewrapEnabled,
|
||||
},
|
||||
linter: null,
|
||||
completer: null,
|
||||
environmentHighlights: true,
|
||||
})
|
||||
)
|
||||
|
||||
defineActionHandler("response.file.download", () => downloadResponse())
|
||||
defineActionHandler("response.copy", () => copyResponse())
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user