Request.vue 15 KB

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