Request.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. <template>
  2. <div
  3. class="bg-primary hide-scrollbar sticky top-0 z-10 flex p-4 space-x-2 overflow-x-auto"
  4. >
  5. <div class="flex flex-1">
  6. <div class="relative flex">
  7. <label for="method">
  8. <tippy
  9. ref="methodOptions"
  10. interactive
  11. trigger="click"
  12. theme="popover"
  13. arrow
  14. >
  15. <template #trigger>
  16. <span class="select-wrapper">
  17. <input
  18. id="method"
  19. class="bg-primaryLight border-divider text-secondaryDark w-26 hover:border-dividerDark focus-visible:bg-transparent focus-visible:border-dividerDark flex px-4 py-2 font-semibold border rounded-l cursor-pointer"
  20. :value="newMethod"
  21. :readonly="!isCustomMethod"
  22. :placeholder="`${t('request.method')}`"
  23. @input="onSelectMethod($event.target.value)"
  24. />
  25. </span>
  26. </template>
  27. <SmartItem
  28. v-for="(method, index) in methods"
  29. :key="`method-${index}`"
  30. :label="method"
  31. @click.native="onSelectMethod(method)"
  32. />
  33. </tippy>
  34. </label>
  35. </div>
  36. <div class="flex flex-1">
  37. <SmartEnvInput
  38. v-model="newEndpoint"
  39. :placeholder="`${t('request.url')}`"
  40. styles="
  41. bg-primaryLight
  42. border border-divider
  43. flex
  44. flex-1
  45. rounded-r
  46. text-secondaryDark
  47. min-w-32
  48. py-1
  49. px-4
  50. hover:border-dividerDark
  51. focus-visible:border-dividerDark
  52. focus-visible:bg-transparent
  53. "
  54. @enter="newSendRequest()"
  55. />
  56. </div>
  57. </div>
  58. <div class="flex">
  59. <ButtonPrimary
  60. id="send"
  61. class="min-w-20 flex-1 rounded-r-none"
  62. :label="`${!loading ? t('action.send') : t('action.cancel')}`"
  63. @click.native="!loading ? newSendRequest() : cancelRequest()"
  64. />
  65. <span class="flex">
  66. <tippy
  67. ref="sendOptions"
  68. interactive
  69. trigger="click"
  70. theme="popover"
  71. arrow
  72. >
  73. <template #trigger>
  74. <ButtonPrimary class="rounded-l-none" filled svg="chevron-down" />
  75. </template>
  76. <SmartItem
  77. :label="`${t('import.curl')}`"
  78. svg="file-code"
  79. @click.native="
  80. () => {
  81. showCurlImportModal = !showCurlImportModal
  82. sendOptions.tippy().hide()
  83. }
  84. "
  85. />
  86. <SmartItem
  87. :label="`${t('show.code')}`"
  88. svg="code-2"
  89. @click.native="
  90. () => {
  91. showCodegenModal = !showCodegenModal
  92. sendOptions.tippy().hide()
  93. }
  94. "
  95. />
  96. <SmartItem
  97. ref="clearAll"
  98. :label="`${t('action.clear_all')}`"
  99. svg="rotate-ccw"
  100. @click.native="
  101. () => {
  102. clearContent()
  103. sendOptions.tippy().hide()
  104. }
  105. "
  106. />
  107. </tippy>
  108. </span>
  109. <ButtonSecondary
  110. class="ml-2 rounded rounded-r-none"
  111. :label="
  112. windowInnerWidth.x.value >= 768 && COLUMN_LAYOUT
  113. ? `${t('request.save')}`
  114. : ''
  115. "
  116. filled
  117. svg="save"
  118. @click.native="saveRequest()"
  119. />
  120. <span class="flex">
  121. <tippy
  122. ref="saveOptions"
  123. interactive
  124. trigger="click"
  125. theme="popover"
  126. arrow
  127. >
  128. <template #trigger>
  129. <ButtonSecondary
  130. svg="chevron-down"
  131. filled
  132. class="rounded rounded-l-none"
  133. />
  134. </template>
  135. <input
  136. id="request-name"
  137. v-model="requestName"
  138. :placeholder="`${t('request.name')}`"
  139. name="request-name"
  140. type="text"
  141. autocomplete="off"
  142. class="input mb-2"
  143. @keyup.enter="saveOptions.tippy().hide()"
  144. />
  145. <SmartItem
  146. ref="copyRequest"
  147. :label="shareButtonText"
  148. :svg="copyLinkIcon"
  149. :loading="fetchingShareLink"
  150. @click.native="
  151. () => {
  152. copyRequest()
  153. }
  154. "
  155. />
  156. <SmartItem
  157. ref="saveRequest"
  158. :label="`${t('request.save_as')}`"
  159. svg="folder-plus"
  160. @click.native="
  161. () => {
  162. showSaveRequestModal = true
  163. saveOptions.tippy().hide()
  164. }
  165. "
  166. />
  167. </tippy>
  168. </span>
  169. </div>
  170. <HttpImportCurl
  171. :show="showCurlImportModal"
  172. @hide-modal="showCurlImportModal = false"
  173. />
  174. <HttpCodegenModal
  175. :show="showCodegenModal"
  176. @hide-modal="showCodegenModal = false"
  177. />
  178. <CollectionsSaveRequest
  179. mode="rest"
  180. :show="showSaveRequestModal"
  181. @hide-modal="showSaveRequestModal = false"
  182. />
  183. </div>
  184. </template>
  185. <script setup lang="ts">
  186. import { computed, ref, watch } from "@nuxtjs/composition-api"
  187. import { isRight } from "fp-ts/lib/Either"
  188. import * as E from "fp-ts/Either"
  189. import {
  190. updateRESTResponse,
  191. restEndpoint$,
  192. setRESTEndpoint,
  193. restMethod$,
  194. updateRESTMethod,
  195. resetRESTRequest,
  196. useRESTRequestName,
  197. getRESTSaveContext,
  198. getRESTRequest,
  199. restRequest$,
  200. } from "~/newstore/RESTSession"
  201. import { editRESTRequest } from "~/newstore/collections"
  202. import { runRESTRequest$ } from "~/helpers/RequestRunner"
  203. import {
  204. useStreamSubscriber,
  205. useStream,
  206. useNuxt,
  207. useI18n,
  208. useToast,
  209. useReadonlyStream,
  210. } from "~/helpers/utils/composables"
  211. import { defineActionHandler } from "~/helpers/actions"
  212. import { copyToClipboard } from "~/helpers/utils/clipboard"
  213. import { useSetting } from "~/newstore/settings"
  214. import { overwriteRequestTeams } from "~/helpers/teams/utils"
  215. import { apolloClient } from "~/helpers/apollo"
  216. import useWindowSize from "~/helpers/utils/useWindowSize"
  217. import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
  218. const t = useI18n()
  219. const methods = [
  220. "GET",
  221. "POST",
  222. "PUT",
  223. "PATCH",
  224. "DELETE",
  225. "HEAD",
  226. "CONNECT",
  227. "OPTIONS",
  228. "TRACE",
  229. "CUSTOM",
  230. ]
  231. const toast = useToast()
  232. const nuxt = useNuxt()
  233. const { subscribeToStream } = useStreamSubscriber()
  234. const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint)
  235. const newMethod = useStream(restMethod$, "", updateRESTMethod)
  236. const loading = ref(false)
  237. const showCurlImportModal = ref(false)
  238. const showCodegenModal = ref(false)
  239. const showSaveRequestModal = ref(false)
  240. const hasNavigatorShare = !!navigator.share
  241. // Template refs
  242. const methodOptions = ref<any | null>(null)
  243. const saveOptions = ref<any | null>(null)
  244. const sendOptions = ref<any | null>(null)
  245. // Update Nuxt Loading bar
  246. watch(loading, () => {
  247. if (loading.value) {
  248. nuxt.value.$loading.start()
  249. } else {
  250. nuxt.value.$loading.finish()
  251. }
  252. })
  253. const newSendRequest = async () => {
  254. if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
  255. toast.error(`${t("empty.endpoint")}`)
  256. return
  257. }
  258. loading.value = true
  259. // Double calling is because the function returns a TaskEither than should be executed
  260. const streamResult = await runRESTRequest$()()
  261. // TODO: What if stream fetching failed (script execution errors ?) (isLeft)
  262. if (isRight(streamResult)) {
  263. subscribeToStream(
  264. streamResult.right,
  265. (responseState) => {
  266. if (loading.value) {
  267. // Check exists because, loading can be set to false
  268. // when cancelled
  269. updateRESTResponse(responseState)
  270. }
  271. },
  272. () => {
  273. loading.value = false
  274. },
  275. () => {
  276. loading.value = false
  277. }
  278. )
  279. }
  280. }
  281. const cancelRequest = () => {
  282. loading.value = false
  283. updateRESTResponse(null)
  284. }
  285. const updateMethod = (method: string) => {
  286. updateRESTMethod(method)
  287. }
  288. const onSelectMethod = (method: string) => {
  289. updateMethod(method)
  290. // Vue-tippy has no typescript support yet
  291. methodOptions.value.tippy().hide()
  292. }
  293. const clearContent = () => {
  294. resetRESTRequest()
  295. }
  296. const copyLinkIcon = hasNavigatorShare ? ref("share-2") : ref("copy")
  297. const shareLink = ref<string | null>("")
  298. const fetchingShareLink = ref(false)
  299. const shareButtonText = computed(() => {
  300. if (shareLink.value) {
  301. return shareLink.value
  302. } else if (fetchingShareLink.value) {
  303. return t("state.loading")
  304. } else {
  305. return t("request.copy_link")
  306. }
  307. })
  308. const request = useReadonlyStream(restRequest$, getRESTRequest())
  309. watch(request, () => {
  310. shareLink.value = null
  311. })
  312. const copyRequest = async () => {
  313. if (shareLink.value) {
  314. copyShareLink(shareLink.value)
  315. } else {
  316. shareLink.value = ""
  317. fetchingShareLink.value = true
  318. const request = getRESTRequest()
  319. const shortcodeResult = await createShortcode(request)()
  320. if (E.isLeft(shortcodeResult)) {
  321. toast.error(`${shortcodeResult.left.error}`)
  322. shareLink.value = `${t("error.something_went_wrong")}`
  323. } else if (E.isRight(shortcodeResult)) {
  324. shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
  325. copyShareLink(shareLink.value)
  326. }
  327. fetchingShareLink.value = false
  328. }
  329. }
  330. const copyShareLink = (shareLink: string) => {
  331. if (navigator.share) {
  332. const time = new Date().toLocaleTimeString()
  333. const date = new Date().toLocaleDateString()
  334. navigator
  335. .share({
  336. title: "Hoppscotch",
  337. text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
  338. url: `https://hopp.sh/r${shareLink}`,
  339. })
  340. .then(() => {})
  341. .catch(() => {})
  342. } else {
  343. copyLinkIcon.value = "check"
  344. copyToClipboard(`https://hopp.sh/r${shareLink}`)
  345. toast.success(`${t("state.copied_to_clipboard")}`)
  346. setTimeout(() => (copyLinkIcon.value = "copy"), 2000)
  347. }
  348. }
  349. const cycleUpMethod = () => {
  350. const currentIndex = methods.indexOf(newMethod.value)
  351. if (currentIndex === -1) {
  352. // Most probs we are in CUSTOM mode
  353. // Cycle up from CUSTOM is PATCH
  354. updateMethod("PATCH")
  355. } else if (currentIndex === 0) {
  356. updateMethod("CUSTOM")
  357. } else {
  358. updateMethod(methods[currentIndex - 1])
  359. }
  360. }
  361. const cycleDownMethod = () => {
  362. const currentIndex = methods.indexOf(newMethod.value)
  363. if (currentIndex === -1) {
  364. // Most probs we are in CUSTOM mode
  365. // Cycle down from CUSTOM is GET
  366. updateMethod("GET")
  367. } else if (currentIndex === methods.length - 1) {
  368. updateMethod("GET")
  369. } else {
  370. updateMethod(methods[currentIndex + 1])
  371. }
  372. }
  373. const saveRequest = () => {
  374. const saveCtx = getRESTSaveContext()
  375. if (!saveCtx) {
  376. showSaveRequestModal.value = true
  377. return
  378. }
  379. if (saveCtx.originLocation === "user-collection") {
  380. editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, getRESTRequest())
  381. toast.success(`${t("request.saved")}`)
  382. } else if (saveCtx.originLocation === "team-collection") {
  383. const req = getRESTRequest()
  384. // TODO: handle error case (NOTE: overwriteRequestTeams is async)
  385. try {
  386. overwriteRequestTeams(
  387. apolloClient,
  388. JSON.stringify(req),
  389. req.name,
  390. saveCtx.requestID
  391. )
  392. .then(() => {
  393. toast.success(`${t("request.saved")}`)
  394. })
  395. .catch(() => {
  396. toast.error(`${t("profile.no_permission")}`)
  397. })
  398. } catch (error) {
  399. showSaveRequestModal.value = true
  400. toast.error(`${t("error.something_went_wrong")}`)
  401. console.error(error)
  402. }
  403. }
  404. }
  405. defineActionHandler("request.send-cancel", () => {
  406. if (!loading.value) newSendRequest()
  407. else cancelRequest()
  408. })
  409. defineActionHandler("request.reset", clearContent)
  410. defineActionHandler("request.copy-link", copyRequest)
  411. defineActionHandler("request.method.next", cycleDownMethod)
  412. defineActionHandler("request.method.prev", cycleUpMethod)
  413. defineActionHandler("request.save", saveRequest)
  414. defineActionHandler(
  415. "request.save-as",
  416. () => (showSaveRequestModal.value = true)
  417. )
  418. defineActionHandler("request.method.get", () => updateMethod("GET"))
  419. defineActionHandler("request.method.post", () => updateMethod("POST"))
  420. defineActionHandler("request.method.put", () => updateMethod("PUT"))
  421. defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
  422. defineActionHandler("request.method.head", () => updateMethod("HEAD"))
  423. const isCustomMethod = computed(() => {
  424. return newMethod.value === "CUSTOM" || !methods.includes(newMethod.value)
  425. })
  426. const requestName = useRESTRequestName()
  427. const windowInnerWidth = useWindowSize()
  428. const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
  429. </script>