Request.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. <template>
  2. <div
  3. class="bg-primary flex space-x-2 p-4 top-0 z-10 sticky overflow-x-auto hide-scrollbar"
  4. >
  5. <div class="flex flex-1">
  6. <div class="flex relative">
  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 border-divider rounded-l cursor-pointer flex font-semibold text-secondaryDark py-2 px-4 w-26 hover:border-dividerDark focus-visible:bg-transparent focus-visible:border-dividerDark"
  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="rounded-r-none flex-1 min-w-20"
  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="rounded rounded-r-none ml-2"
  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="mb-2 input"
  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 { isLeft, 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. setRESTSaveContext,
  201. } from "~/newstore/RESTSession"
  202. import { editRESTRequest } from "~/newstore/collections"
  203. import { runRESTRequest$ } from "~/helpers/RequestRunner"
  204. import {
  205. useStreamSubscriber,
  206. useStream,
  207. useNuxt,
  208. useI18n,
  209. useToast,
  210. useReadonlyStream,
  211. } from "~/helpers/utils/composables"
  212. import { defineActionHandler } from "~/helpers/actions"
  213. import { copyToClipboard } from "~/helpers/utils/clipboard"
  214. import { useSetting } from "~/newstore/settings"
  215. import { overwriteRequestTeams } from "~/helpers/teams/utils"
  216. import { apolloClient } from "~/helpers/apollo"
  217. import useWindowSize from "~/helpers/utils/useWindowSize"
  218. import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
  219. const t = useI18n()
  220. const methods = [
  221. "GET",
  222. "POST",
  223. "PUT",
  224. "PATCH",
  225. "DELETE",
  226. "HEAD",
  227. "CONNECT",
  228. "OPTIONS",
  229. "TRACE",
  230. "CUSTOM",
  231. ]
  232. const toast = useToast()
  233. const nuxt = useNuxt()
  234. const { subscribeToStream } = useStreamSubscriber()
  235. const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint)
  236. const newMethod = useStream(restMethod$, "", updateRESTMethod)
  237. const loading = ref(false)
  238. const showCurlImportModal = ref(false)
  239. const showCodegenModal = ref(false)
  240. const showSaveRequestModal = ref(false)
  241. const hasNavigatorShare = !!navigator.share
  242. // Template refs
  243. const methodOptions = ref<any | null>(null)
  244. const saveOptions = ref<any | null>(null)
  245. const sendOptions = ref<any | null>(null)
  246. // Update Nuxt Loading bar
  247. watch(loading, () => {
  248. if (loading.value) {
  249. nuxt.value.$loading.start()
  250. } else {
  251. nuxt.value.$loading.finish()
  252. }
  253. })
  254. const newSendRequest = async () => {
  255. if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
  256. toast.error(`${t("empty.endpoint")}`)
  257. return
  258. }
  259. loading.value = true
  260. // Double calling is because the function returns a TaskEither than should be executed
  261. const streamResult = await runRESTRequest$()()
  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. } else if (isLeft(streamResult)) {
  280. loading.value = false
  281. toast.error(`${t("error.script_fail")}`)
  282. let error: Error
  283. if (typeof streamResult.left === "string") {
  284. error = { name: "RequestFailure", message: streamResult.left }
  285. } else {
  286. error = streamResult.left
  287. }
  288. updateRESTResponse({
  289. type: "script_fail",
  290. error,
  291. })
  292. }
  293. }
  294. const cancelRequest = () => {
  295. loading.value = false
  296. updateRESTResponse(null)
  297. }
  298. const updateMethod = (method: string) => {
  299. updateRESTMethod(method)
  300. }
  301. const onSelectMethod = (method: string) => {
  302. updateMethod(method)
  303. // Vue-tippy has no typescript support yet
  304. methodOptions.value.tippy().hide()
  305. }
  306. const clearContent = () => {
  307. resetRESTRequest()
  308. }
  309. const copyLinkIcon = hasNavigatorShare ? ref("share-2") : ref("copy")
  310. const shareLink = ref<string | null>("")
  311. const fetchingShareLink = ref(false)
  312. const shareButtonText = computed(() => {
  313. if (shareLink.value) {
  314. return shareLink.value
  315. } else if (fetchingShareLink.value) {
  316. return t("state.loading")
  317. } else {
  318. return t("request.copy_link")
  319. }
  320. })
  321. const request = useReadonlyStream(restRequest$, getRESTRequest())
  322. watch(request, () => {
  323. shareLink.value = null
  324. })
  325. const copyRequest = async () => {
  326. if (shareLink.value) {
  327. copyShareLink(shareLink.value)
  328. } else {
  329. shareLink.value = ""
  330. fetchingShareLink.value = true
  331. const request = getRESTRequest()
  332. const shortcodeResult = await createShortcode(request)()
  333. if (E.isLeft(shortcodeResult)) {
  334. toast.error(`${shortcodeResult.left.error}`)
  335. shareLink.value = `${t("error.something_went_wrong")}`
  336. } else if (E.isRight(shortcodeResult)) {
  337. shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
  338. copyShareLink(shareLink.value)
  339. }
  340. fetchingShareLink.value = false
  341. }
  342. }
  343. const copyShareLink = (shareLink: string) => {
  344. if (navigator.share) {
  345. const time = new Date().toLocaleTimeString()
  346. const date = new Date().toLocaleDateString()
  347. navigator
  348. .share({
  349. title: "Hoppscotch",
  350. text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
  351. url: `https://hopp.sh/r${shareLink}`,
  352. })
  353. .then(() => {})
  354. .catch(() => {})
  355. } else {
  356. copyLinkIcon.value = "check"
  357. copyToClipboard(`https://hopp.sh/r${shareLink}`)
  358. toast.success(`${t("state.copied_to_clipboard")}`)
  359. setTimeout(() => (copyLinkIcon.value = "copy"), 2000)
  360. }
  361. }
  362. const cycleUpMethod = () => {
  363. const currentIndex = methods.indexOf(newMethod.value)
  364. if (currentIndex === -1) {
  365. // Most probs we are in CUSTOM mode
  366. // Cycle up from CUSTOM is PATCH
  367. updateMethod("PATCH")
  368. } else if (currentIndex === 0) {
  369. updateMethod("CUSTOM")
  370. } else {
  371. updateMethod(methods[currentIndex - 1])
  372. }
  373. }
  374. const cycleDownMethod = () => {
  375. const currentIndex = methods.indexOf(newMethod.value)
  376. if (currentIndex === -1) {
  377. // Most probs we are in CUSTOM mode
  378. // Cycle down from CUSTOM is GET
  379. updateMethod("GET")
  380. } else if (currentIndex === methods.length - 1) {
  381. updateMethod("GET")
  382. } else {
  383. updateMethod(methods[currentIndex + 1])
  384. }
  385. }
  386. const saveRequest = () => {
  387. const saveCtx = getRESTSaveContext()
  388. if (!saveCtx) {
  389. showSaveRequestModal.value = true
  390. return
  391. }
  392. if (saveCtx.originLocation === "user-collection") {
  393. try {
  394. editRESTRequest(
  395. saveCtx.folderPath,
  396. saveCtx.requestIndex,
  397. getRESTRequest()
  398. )
  399. toast.success(`${t("request.saved")}`)
  400. } catch (e) {
  401. setRESTSaveContext(null)
  402. saveRequest()
  403. }
  404. } else if (saveCtx.originLocation === "team-collection") {
  405. const req = getRESTRequest()
  406. // TODO: handle error case (NOTE: overwriteRequestTeams is async)
  407. try {
  408. overwriteRequestTeams(
  409. apolloClient,
  410. JSON.stringify(req),
  411. req.name,
  412. saveCtx.requestID
  413. )
  414. .then(() => {
  415. toast.success(`${t("request.saved")}`)
  416. })
  417. .catch(() => {
  418. toast.error(`${t("profile.no_permission")}`)
  419. })
  420. } catch (error) {
  421. showSaveRequestModal.value = true
  422. toast.error(`${t("error.something_went_wrong")}`)
  423. console.error(error)
  424. }
  425. }
  426. }
  427. defineActionHandler("request.send-cancel", () => {
  428. if (!loading.value) newSendRequest()
  429. else cancelRequest()
  430. })
  431. defineActionHandler("request.reset", clearContent)
  432. defineActionHandler("request.copy-link", copyRequest)
  433. defineActionHandler("request.method.next", cycleDownMethod)
  434. defineActionHandler("request.method.prev", cycleUpMethod)
  435. defineActionHandler("request.save", saveRequest)
  436. defineActionHandler(
  437. "request.save-as",
  438. () => (showSaveRequestModal.value = true)
  439. )
  440. defineActionHandler("request.method.get", () => updateMethod("GET"))
  441. defineActionHandler("request.method.post", () => updateMethod("POST"))
  442. defineActionHandler("request.method.put", () => updateMethod("PUT"))
  443. defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
  444. defineActionHandler("request.method.head", () => updateMethod("HEAD"))
  445. const isCustomMethod = computed(() => {
  446. return newMethod.value === "CUSTOM" || !methods.includes(newMethod.value)
  447. })
  448. const requestName = useRESTRequestName()
  449. const windowInnerWidth = useWindowSize()
  450. const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
  451. </script>