2
0

Request.vue 13 KB

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