chore: split app to commons and web (squash commit)

This commit is contained in:
Andrew Bastin
2022-12-02 02:57:46 -05:00
parent fb827e3586
commit 3d004f2322
535 changed files with 1487 additions and 501 deletions

View File

@@ -0,0 +1,48 @@
<template>
<div>
<div
v-if="isLoadingInitialRoute"
class="flex flex-col items-center justify-center min-h-screen"
>
<SmartSpinner />
</div>
<ErrorPage v-if="errorInfo !== null" :error="errorInfo" />
<RouterView v-else />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import ErrorPage, { ErrorPageData } from "~/pages/_.vue"
import { HOPP_MODULES } from "@modules/."
import { isLoadingInitialRoute } from "@modules/router"
import { useI18n } from "@composables/i18n"
import { APP_IS_IN_DEV_MODE } from "@helpers/dev"
const t = useI18n()
const errorInfo = ref<ErrorPageData | null>(null)
// App Crash Handler
// If the below code gets more complicated, move this onto a module
const formatErrorMessage = (err: Error | null | undefined) => {
if (!err) return null
return `${err.name}: ${err.message}`
}
// App Crash Handler is only a thing in Dev Mode
if (APP_IS_IN_DEV_MODE) {
window.onerror = (_, _1, _2, _3, err) => {
errorInfo.value = {
statusCode: 500,
message: formatErrorMessage(err) ?? t("error.something_went_wrong"),
}
// Returning false here will not cancel the error and will log it to console
return false
}
}
// Run module root component setup code
HOPP_MODULES.forEach((mod) => mod.onRootSetup?.())
</script>

View File

@@ -0,0 +1,153 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
AppFuse: typeof import('./components/app/Fuse.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default']
AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default']
AppShare: typeof import('./components/app/Share.vue')['default']
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./components/button/Secondary.vue')['default']
Collections: typeof import('./components/collections/index.vue')['default']
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
CollectionsChooseType: typeof import('./components/collections/ChooseType.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
CollectionsGraphql: typeof import('./components/collections/graphql/index.vue')['default']
CollectionsGraphqlAdd: typeof import('./components/collections/graphql/Add.vue')['default']
CollectionsGraphqlAddFolder: typeof import('./components/collections/graphql/AddFolder.vue')['default']
CollectionsGraphqlAddRequest: typeof import('./components/collections/graphql/AddRequest.vue')['default']
CollectionsGraphqlCollection: typeof import('./components/collections/graphql/Collection.vue')['default']
CollectionsGraphqlEdit: typeof import('./components/collections/graphql/Edit.vue')['default']
CollectionsGraphqlEditFolder: typeof import('./components/collections/graphql/EditFolder.vue')['default']
CollectionsGraphqlEditRequest: typeof import('./components/collections/graphql/EditRequest.vue')['default']
CollectionsGraphqlFolder: typeof import('./components/collections/graphql/Folder.vue')['default']
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
CollectionsMyCollection: typeof import('./components/collections/my/Collection.vue')['default']
CollectionsMyFolder: typeof import('./components/collections/my/Folder.vue')['default']
CollectionsMyRequest: typeof import('./components/collections/my/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamsCollection: typeof import('./components/collections/teams/Collection.vue')['default']
CollectionsTeamsFolder: typeof import('./components/collections/teams/Folder.vue')['default']
CollectionsTeamsRequest: typeof import('./components/collections/teams/Request.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default']
EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default']
EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default']
EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default']
FirebaseLogin: typeof import('./components/firebase/Login.vue')['default']
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
GraphqlField: typeof import('./components/graphql/Field.vue')['default']
GraphqlRequest: typeof import('./components/graphql/Request.vue')['default']
GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default']
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpBody: typeof import('./components/http/Body.vue')['default']
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
HttpOAuth2Authorization: typeof import('./components/http/OAuth2Authorization.vue')['default']
HttpParameters: typeof import('./components/http/Parameters.vue')['default']
HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default']
HttpRawBody: typeof import('./components/http/RawBody.vue')['default']
HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default']
HttpRequest: typeof import('./components/http/Request.vue')['default']
HttpRequestOptions: typeof import('./components/http/RequestOptions.vue')['default']
HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default']
LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default']
LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default']
LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfilePicture: typeof import('./components/profile/Picture.vue')['default']
ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
SmartCheckbox: typeof import('./components/smart/Checkbox.vue')['default']
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartConfirmModal: typeof import('./components/smart/ConfirmModal.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartIntersection: typeof import('./components/smart/Intersection.vue')['default']
SmartItem: typeof import('./components/smart/Item.vue')['default']
SmartLink: typeof import('./components/smart/Link.vue')['default']
SmartModal: typeof import('./components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./components/smart/Spinner.vue')['default']
SmartTab: typeof import('./components/smart/Tab.vue')['default']
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./components/smart/Window.vue')['default']
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsModal: typeof import('./components/teams/Modal.vue')['default']
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
}
}

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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("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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More