Request.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  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. svg="link-2"
  175. :label="`${t('request.view_my_links')}`"
  176. to="/profile"
  177. />
  178. <hr />
  179. <SmartItem
  180. ref="saveRequestAction"
  181. :label="`${t('request.save_as')}`"
  182. svg="folder-plus"
  183. :shortcut="['S']"
  184. @click.native="
  185. () => {
  186. showSaveRequestModal = true
  187. saveOptions.tippy().hide()
  188. }
  189. "
  190. />
  191. </div>
  192. </tippy>
  193. </span>
  194. </div>
  195. <HttpImportCurl
  196. :text="curlText"
  197. :show="showCurlImportModal"
  198. @hide-modal="showCurlImportModal = false"
  199. />
  200. <HttpCodegenModal
  201. :show="showCodegenModal"
  202. @hide-modal="showCodegenModal = false"
  203. />
  204. <CollectionsSaveRequest
  205. mode="rest"
  206. :show="showSaveRequestModal"
  207. @hide-modal="showSaveRequestModal = false"
  208. />
  209. </div>
  210. </template>
  211. <script setup lang="ts">
  212. import { computed, ref, watch } from "@nuxtjs/composition-api"
  213. import { isLeft, isRight } from "fp-ts/lib/Either"
  214. import * as E from "fp-ts/Either"
  215. import cloneDeep from "lodash/cloneDeep"
  216. import { refAutoReset } from "@vueuse/core"
  217. import {
  218. updateRESTResponse,
  219. restEndpoint$,
  220. setRESTEndpoint,
  221. restMethod$,
  222. updateRESTMethod,
  223. resetRESTRequest,
  224. useRESTRequestName,
  225. getRESTSaveContext,
  226. getRESTRequest,
  227. restRequest$,
  228. setRESTSaveContext,
  229. } from "~/newstore/RESTSession"
  230. import { editRESTRequest } from "~/newstore/collections"
  231. import { runRESTRequest$ } from "~/helpers/RequestRunner"
  232. import {
  233. useStreamSubscriber,
  234. useStream,
  235. useNuxt,
  236. useI18n,
  237. useToast,
  238. useReadonlyStream,
  239. } from "~/helpers/utils/composables"
  240. import { defineActionHandler } from "~/helpers/actions"
  241. import { copyToClipboard } from "~/helpers/utils/clipboard"
  242. import { useSetting } from "~/newstore/settings"
  243. import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
  244. import { runMutation } from "~/helpers/backend/GQLClient"
  245. import { UpdateRequestDocument } from "~/helpers/backend/graphql"
  246. const t = useI18n()
  247. const methods = [
  248. "GET",
  249. "POST",
  250. "PUT",
  251. "PATCH",
  252. "DELETE",
  253. "HEAD",
  254. "CONNECT",
  255. "OPTIONS",
  256. "TRACE",
  257. "CUSTOM",
  258. ]
  259. const toast = useToast()
  260. const nuxt = useNuxt()
  261. const { subscribeToStream } = useStreamSubscriber()
  262. const newEndpoint = useStream(restEndpoint$, "", setRESTEndpoint)
  263. const curlText = ref("")
  264. const newMethod = useStream(restMethod$, "", updateRESTMethod)
  265. const loading = ref(false)
  266. const showCurlImportModal = ref(false)
  267. const showCodegenModal = ref(false)
  268. const showSaveRequestModal = ref(false)
  269. const hasNavigatorShare = !!navigator.share
  270. // Template refs
  271. const methodOptions = ref<any | null>(null)
  272. const saveOptions = ref<any | null>(null)
  273. const sendOptions = ref<any | null>(null)
  274. const sendTippyActions = ref<any | null>(null)
  275. const saveTippyActions = ref<any | null>(null)
  276. const curl = ref<any | null>(null)
  277. const show = ref<any | null>(null)
  278. const clearAll = ref<any | null>(null)
  279. const copyRequestAction = ref<any | null>(null)
  280. const saveRequestAction = ref<any | null>(null)
  281. // Update Nuxt Loading bar
  282. watch(loading, () => {
  283. if (loading.value) {
  284. nuxt.value.$loading.start()
  285. } else {
  286. nuxt.value.$loading.finish()
  287. }
  288. })
  289. const newSendRequest = async () => {
  290. if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
  291. toast.error(`${t("empty.endpoint")}`)
  292. return
  293. }
  294. ensureMethodInEndpoint()
  295. loading.value = true
  296. // Double calling is because the function returns a TaskEither than should be executed
  297. const streamResult = await runRESTRequest$()()
  298. if (isRight(streamResult)) {
  299. subscribeToStream(
  300. streamResult.right,
  301. (responseState) => {
  302. if (loading.value) {
  303. // Check exists because, loading can be set to false
  304. // when cancelled
  305. updateRESTResponse(responseState)
  306. }
  307. },
  308. () => {
  309. loading.value = false
  310. },
  311. () => {
  312. loading.value = false
  313. }
  314. )
  315. } else if (isLeft(streamResult)) {
  316. loading.value = false
  317. toast.error(`${t("error.script_fail")}`)
  318. let error: Error
  319. if (typeof streamResult.left === "string") {
  320. error = { name: "RequestFailure", message: streamResult.left }
  321. } else {
  322. error = streamResult.left
  323. }
  324. updateRESTResponse({
  325. type: "script_fail",
  326. error,
  327. })
  328. }
  329. }
  330. const ensureMethodInEndpoint = () => {
  331. if (
  332. !/^http[s]?:\/\//.test(newEndpoint.value) &&
  333. !newEndpoint.value.startsWith("<<") &&
  334. !newEndpoint.value.startsWith("{{")
  335. ) {
  336. const domain = newEndpoint.value.split(/[/:#?]+/)[0]
  337. if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
  338. setRESTEndpoint("http://" + newEndpoint.value)
  339. } else {
  340. setRESTEndpoint("https://" + newEndpoint.value)
  341. }
  342. }
  343. }
  344. const onPasteUrl = (e: { pastedValue: string; prevValue: string }) => {
  345. if (!e) return
  346. const pastedData = e.pastedValue
  347. if (isCURL(pastedData)) {
  348. showCurlImportModal.value = true
  349. curlText.value = pastedData
  350. newEndpoint.value = e.prevValue
  351. }
  352. }
  353. function isCURL(curl: string) {
  354. return curl.includes("curl ")
  355. }
  356. const cancelRequest = () => {
  357. loading.value = false
  358. updateRESTResponse(null)
  359. }
  360. const updateMethod = (method: string) => {
  361. updateRESTMethod(method)
  362. }
  363. const onSelectMethod = (method: string) => {
  364. updateMethod(method)
  365. // Vue-tippy has no typescript support yet
  366. methodOptions.value.tippy().hide()
  367. }
  368. const clearContent = () => {
  369. resetRESTRequest()
  370. }
  371. const copyLinkIcon = refAutoReset<"share-2" | "copy" | "check">(
  372. hasNavigatorShare ? "share-2" : "copy",
  373. 1000
  374. )
  375. const shareLink = ref<string | null>("")
  376. const fetchingShareLink = ref(false)
  377. const shareButtonText = computed(() => {
  378. if (shareLink.value) {
  379. return shareLink.value
  380. } else if (fetchingShareLink.value) {
  381. return t("state.loading")
  382. } else {
  383. return t("request.copy_link")
  384. }
  385. })
  386. const request = useReadonlyStream(restRequest$, getRESTRequest())
  387. watch(request, () => {
  388. shareLink.value = null
  389. })
  390. const copyRequest = async () => {
  391. if (shareLink.value) {
  392. copyShareLink(shareLink.value)
  393. } else {
  394. shareLink.value = ""
  395. fetchingShareLink.value = true
  396. const request = getRESTRequest()
  397. const shortcodeResult = await createShortcode(request)()
  398. if (E.isLeft(shortcodeResult)) {
  399. toast.error(`${shortcodeResult.left.error}`)
  400. shareLink.value = `${t("error.something_went_wrong")}`
  401. } else if (E.isRight(shortcodeResult)) {
  402. shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
  403. copyShareLink(shareLink.value)
  404. }
  405. fetchingShareLink.value = false
  406. }
  407. }
  408. const copyShareLink = (shareLink: string) => {
  409. if (navigator.share) {
  410. const time = new Date().toLocaleTimeString()
  411. const date = new Date().toLocaleDateString()
  412. navigator
  413. .share({
  414. title: "Hoppscotch",
  415. text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
  416. url: `https://hopp.sh/r${shareLink}`,
  417. })
  418. .then(() => {})
  419. .catch(() => {})
  420. } else {
  421. copyLinkIcon.value = "check"
  422. copyToClipboard(`https://hopp.sh/r${shareLink}`)
  423. toast.success(`${t("state.copied_to_clipboard")}`)
  424. }
  425. }
  426. const cycleUpMethod = () => {
  427. const currentIndex = methods.indexOf(newMethod.value)
  428. if (currentIndex === -1) {
  429. // Most probs we are in CUSTOM mode
  430. // Cycle up from CUSTOM is PATCH
  431. updateMethod("PATCH")
  432. } else if (currentIndex === 0) {
  433. updateMethod("CUSTOM")
  434. } else {
  435. updateMethod(methods[currentIndex - 1])
  436. }
  437. }
  438. const cycleDownMethod = () => {
  439. const currentIndex = methods.indexOf(newMethod.value)
  440. if (currentIndex === -1) {
  441. // Most probs we are in CUSTOM mode
  442. // Cycle down from CUSTOM is GET
  443. updateMethod("GET")
  444. } else if (currentIndex === methods.length - 1) {
  445. updateMethod("GET")
  446. } else {
  447. updateMethod(methods[currentIndex + 1])
  448. }
  449. }
  450. const saveRequest = () => {
  451. const saveCtx = getRESTSaveContext()
  452. if (!saveCtx) {
  453. showSaveRequestModal.value = true
  454. return
  455. }
  456. if (saveCtx.originLocation === "user-collection") {
  457. const req = getRESTRequest()
  458. try {
  459. editRESTRequest(
  460. saveCtx.folderPath,
  461. saveCtx.requestIndex,
  462. getRESTRequest()
  463. )
  464. setRESTSaveContext({
  465. originLocation: "user-collection",
  466. folderPath: saveCtx.folderPath,
  467. requestIndex: saveCtx.requestIndex,
  468. req: cloneDeep(req),
  469. })
  470. toast.success(`${t("request.saved")}`)
  471. } catch (e) {
  472. setRESTSaveContext(null)
  473. saveRequest()
  474. }
  475. } else if (saveCtx.originLocation === "team-collection") {
  476. const req = getRESTRequest()
  477. // TODO: handle error case (NOTE: overwriteRequestTeams is async)
  478. try {
  479. runMutation(UpdateRequestDocument, {
  480. requestID: saveCtx.requestID,
  481. data: {
  482. title: req.name,
  483. request: JSON.stringify(req),
  484. },
  485. })().then((result) => {
  486. if (E.isLeft(result)) {
  487. toast.error(`${t("profile.no_permission")}`)
  488. } else {
  489. setRESTSaveContext({
  490. originLocation: "team-collection",
  491. requestID: saveCtx.requestID,
  492. req: cloneDeep(req),
  493. })
  494. toast.success(`${t("request.saved")}`)
  495. }
  496. })
  497. } catch (error) {
  498. showSaveRequestModal.value = true
  499. toast.error(`${t("error.something_went_wrong")}`)
  500. console.error(error)
  501. }
  502. }
  503. }
  504. defineActionHandler("request.send-cancel", () => {
  505. if (!loading.value) newSendRequest()
  506. else cancelRequest()
  507. })
  508. defineActionHandler("request.reset", clearContent)
  509. defineActionHandler("request.copy-link", copyRequest)
  510. defineActionHandler("request.method.next", cycleDownMethod)
  511. defineActionHandler("request.method.prev", cycleUpMethod)
  512. defineActionHandler("request.save", saveRequest)
  513. defineActionHandler(
  514. "request.save-as",
  515. () => (showSaveRequestModal.value = true)
  516. )
  517. defineActionHandler("request.method.get", () => updateMethod("GET"))
  518. defineActionHandler("request.method.post", () => updateMethod("POST"))
  519. defineActionHandler("request.method.put", () => updateMethod("PUT"))
  520. defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
  521. defineActionHandler("request.method.head", () => updateMethod("HEAD"))
  522. const isCustomMethod = computed(() => {
  523. return newMethod.value === "CUSTOM" || !methods.includes(newMethod.value)
  524. })
  525. const requestName = useRESTRequestName()
  526. const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
  527. </script>