BodyParameters.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. <template>
  2. <div>
  3. <div
  4. class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperMobileRawStickyFold sm:top-upperMobileRawTertiaryStickyFold"
  5. >
  6. <label class="font-semibold text-secondaryLight">
  7. {{ $t("request.body") }}
  8. </label>
  9. <div class="flex">
  10. <ButtonSecondary
  11. v-tippy="{ theme: 'tooltip' }"
  12. to="https://docs.hoppscotch.io/features/body"
  13. blank
  14. :title="$t('app.wiki')"
  15. svg="help-circle"
  16. />
  17. <ButtonSecondary
  18. v-tippy="{ theme: 'tooltip' }"
  19. :title="$t('action.clear_all')"
  20. svg="trash-2"
  21. @click.native="clearContent"
  22. />
  23. <ButtonSecondary
  24. v-tippy="{ theme: 'tooltip' }"
  25. :title="$t('add.new')"
  26. svg="plus"
  27. @click.native="addBodyParam"
  28. />
  29. </div>
  30. </div>
  31. <draggable
  32. v-model="workingParams"
  33. animation="250"
  34. handle=".draggable-handle"
  35. draggable=".draggable-content"
  36. ghost-class="cursor-move"
  37. chosen-class="bg-primaryLight"
  38. drag-class="cursor-grabbing"
  39. >
  40. <div
  41. v-for="({ id, entry }, index) in workingParams"
  42. :key="`param=${id}-${index}`"
  43. class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
  44. >
  45. <span>
  46. <ButtonSecondary
  47. svg="grip-vertical"
  48. class="cursor-auto text-primary hover:text-primary"
  49. :class="{
  50. 'draggable-handle group-hover:text-secondaryLight !cursor-grab':
  51. index !== workingParams?.length - 1,
  52. }"
  53. tabindex="-1"
  54. />
  55. </span>
  56. <SmartEnvInput
  57. v-model="entry.key"
  58. :placeholder="`${$t('count.parameter', { count: index + 1 })}`"
  59. @change="
  60. updateBodyParam(index, {
  61. key: $event,
  62. value: entry.value,
  63. active: entry.active,
  64. isFile: entry.isFile,
  65. })
  66. "
  67. />
  68. <div v-if="entry.isFile" class="file-chips-container hide-scrollbar">
  69. <div class="space-x-2 file-chips-wrapper">
  70. <SmartFileChip
  71. v-for="(file, fileIndex) in entry.value"
  72. :key="`param-${index}-file-${fileIndex}`"
  73. >{{ file.name }}</SmartFileChip
  74. >
  75. </div>
  76. </div>
  77. <span v-else class="flex flex-1">
  78. <SmartEnvInput
  79. v-model="entry.value"
  80. :placeholder="`${$t('count.value', { count: index + 1 })}`"
  81. @change="
  82. updateBodyParam(index, {
  83. key: entry.key,
  84. value: $event,
  85. active: entry.active,
  86. isFile: entry.isFile,
  87. })
  88. "
  89. />
  90. </span>
  91. <span>
  92. <label :for="`attachment${index}`" class="p-0">
  93. <input
  94. :id="`attachment${index}`"
  95. :ref="`attachment${index}`"
  96. :name="`attachment${index}`"
  97. type="file"
  98. multiple
  99. class="p-1 cursor-pointer transition file:transition file:cursor-pointer text-secondaryLight hover:text-secondaryDark file:mr-2 file:py-1 file:px-4 file:rounded file:border-0 file:text-tiny text-tiny file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
  100. @change="setRequestAttachment(index, entry, $event)"
  101. />
  102. </label>
  103. </span>
  104. <span>
  105. <ButtonSecondary
  106. v-tippy="{ theme: 'tooltip' }"
  107. :title="
  108. entry.hasOwnProperty('active')
  109. ? entry.active
  110. ? $t('action.turn_off')
  111. : $t('action.turn_on')
  112. : $t('action.turn_off')
  113. "
  114. :svg="
  115. entry.hasOwnProperty('active')
  116. ? entry.active
  117. ? 'check-circle'
  118. : 'circle'
  119. : 'check-circle'
  120. "
  121. color="green"
  122. @click.native="
  123. updateBodyParam(index, {
  124. key: entry.key,
  125. value: entry.value,
  126. active: entry.hasOwnProperty('active') ? !entry.active : false,
  127. isFile: entry.isFile,
  128. })
  129. "
  130. />
  131. </span>
  132. <span>
  133. <ButtonSecondary
  134. v-tippy="{ theme: 'tooltip' }"
  135. :title="$t('action.remove')"
  136. svg="trash"
  137. color="red"
  138. @click.native="deleteBodyParam(index)"
  139. />
  140. </span>
  141. </div>
  142. </draggable>
  143. <div
  144. v-if="workingParams.length === 0"
  145. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  146. >
  147. <img
  148. :src="`/images/states/${$colorMode.value}/upload_single_file.svg`"
  149. loading="lazy"
  150. class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
  151. :alt="`${$t('empty.body')}`"
  152. />
  153. <span class="pb-4 text-center">{{ $t("empty.body") }}</span>
  154. <ButtonSecondary
  155. :label="`${$t('add.new')}`"
  156. filled
  157. svg="plus"
  158. class="mb-4"
  159. @click.native="addBodyParam"
  160. />
  161. </div>
  162. </div>
  163. </template>
  164. <script setup lang="ts">
  165. import { ref, Ref, watch } from "@nuxtjs/composition-api"
  166. import { flow, pipe } from "fp-ts/function"
  167. import * as O from "fp-ts/Option"
  168. import * as A from "fp-ts/Array"
  169. import { FormDataKeyValue } from "@hoppscotch/data"
  170. import isEqual from "lodash/isEqual"
  171. import { clone } from "lodash"
  172. import draggable from "vuedraggable"
  173. import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
  174. import { useRESTRequestBody } from "~/newstore/RESTSession"
  175. type WorkingFormDataKeyValue = { id: number; entry: FormDataKeyValue }
  176. const t = useI18n()
  177. const toast = useToast()
  178. const idTicker = ref(0)
  179. const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
  180. const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref<
  181. FormDataKeyValue[]
  182. >
  183. // The UI representation of the parameters list (has the empty end param)
  184. const workingParams = ref<WorkingFormDataKeyValue[]>([
  185. {
  186. id: idTicker.value++,
  187. entry: {
  188. key: "",
  189. value: "",
  190. active: true,
  191. isFile: false,
  192. },
  193. },
  194. ])
  195. // Rule: Working Params always have last element is always an empty param
  196. watch(workingParams, (paramsList) => {
  197. if (
  198. paramsList.length > 0 &&
  199. paramsList[paramsList.length - 1].entry.key !== ""
  200. ) {
  201. workingParams.value.push({
  202. id: idTicker.value++,
  203. entry: {
  204. key: "",
  205. value: "",
  206. active: true,
  207. isFile: false,
  208. },
  209. })
  210. }
  211. })
  212. // Sync logic between params and working params
  213. watch(
  214. bodyParams,
  215. (newParamsList) => {
  216. // Sync should overwrite working params
  217. const filteredWorkingParams = pipe(
  218. workingParams.value,
  219. A.filterMap(
  220. flow(
  221. O.fromPredicate((e) => e.entry.key !== ""),
  222. O.map((e) => e.entry)
  223. )
  224. )
  225. )
  226. if (!isEqual(newParamsList, filteredWorkingParams)) {
  227. workingParams.value = pipe(
  228. newParamsList,
  229. A.map((x) => ({ id: idTicker.value++, entry: x }))
  230. )
  231. }
  232. },
  233. { immediate: true }
  234. )
  235. watch(workingParams, (newWorkingParams) => {
  236. const fixedParams = pipe(
  237. newWorkingParams,
  238. A.filterMap(
  239. flow(
  240. O.fromPredicate((e) => e.entry.key !== ""),
  241. O.map((e) => e.entry)
  242. )
  243. )
  244. )
  245. if (!isEqual(bodyParams.value, fixedParams)) {
  246. bodyParams.value = fixedParams
  247. }
  248. })
  249. const addBodyParam = () => {
  250. workingParams.value.push({
  251. id: idTicker.value++,
  252. entry: {
  253. key: "",
  254. value: "",
  255. active: true,
  256. isFile: false,
  257. },
  258. })
  259. }
  260. const updateBodyParam = (index: number, entry: FormDataKeyValue) => {
  261. workingParams.value = workingParams.value.map((h, i) =>
  262. i === index ? { id: h.id, entry } : h
  263. )
  264. }
  265. const deleteBodyParam = (index: number) => {
  266. const paramsBeforeDeletion = clone(workingParams.value)
  267. if (
  268. !(
  269. paramsBeforeDeletion.length > 0 &&
  270. index === paramsBeforeDeletion.length - 1
  271. )
  272. ) {
  273. if (deletionToast.value) {
  274. deletionToast.value.goAway(0)
  275. deletionToast.value = null
  276. }
  277. deletionToast.value = toast.success(`${t("state.deleted")}`, {
  278. action: [
  279. {
  280. text: `${t("action.undo")}`,
  281. onClick: (_, toastObject) => {
  282. workingParams.value = paramsBeforeDeletion
  283. toastObject.goAway(0)
  284. deletionToast.value = null
  285. },
  286. },
  287. ],
  288. onComplete: () => {
  289. deletionToast.value = null
  290. },
  291. })
  292. }
  293. workingParams.value.splice(index, 1)
  294. }
  295. const clearContent = () => {
  296. // set params list to the initial state
  297. workingParams.value = [
  298. {
  299. id: idTicker.value++,
  300. entry: {
  301. key: "",
  302. value: "",
  303. active: true,
  304. isFile: false,
  305. },
  306. },
  307. ]
  308. }
  309. const setRequestAttachment = (
  310. index: number,
  311. entry: FormDataKeyValue,
  312. event: InputEvent
  313. ) => {
  314. // check if file exists or not
  315. if ((event.target as HTMLInputElement).files?.length === 0) {
  316. updateBodyParam(index, {
  317. ...entry,
  318. isFile: false,
  319. value: "",
  320. })
  321. return
  322. }
  323. const fileEntry: FormDataKeyValue = {
  324. ...entry,
  325. isFile: true,
  326. value: Array.from((event.target as HTMLInputElement).files!),
  327. }
  328. updateBodyParam(index, fileEntry)
  329. }
  330. </script>
  331. <style scoped lang="scss">
  332. .file-chips-container {
  333. @apply flex flex-1;
  334. @apply whitespace-nowrap;
  335. @apply overflow-auto;
  336. @apply bg-transparent;
  337. .file-chips-wrapper {
  338. @apply flex;
  339. @apply p-1;
  340. @apply w-0;
  341. }
  342. }
  343. </style>