BodyParameters.vue 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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="(param, index) in workingParams"
  42. :key="`param-${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="param.key"
  58. :placeholder="`${$t('count.parameter', { count: index + 1 })}`"
  59. @change="
  60. updateBodyParam(index, {
  61. key: $event,
  62. value: param.value,
  63. active: param.active,
  64. isFile: param.isFile,
  65. })
  66. "
  67. />
  68. <div v-if="param.isFile" class="file-chips-container hide-scrollbar">
  69. <div class="space-x-2 file-chips-wrapper">
  70. <SmartFileChip
  71. v-for="(file, fileIndex) in param.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="param.value"
  80. :placeholder="`${$t('count.value', { count: index + 1 })}`"
  81. @change="
  82. updateBodyParam(index, {
  83. key: param.key,
  84. value: $event,
  85. active: param.active,
  86. isFile: param.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, param, $event)"
  101. />
  102. </label>
  103. </span>
  104. <span>
  105. <ButtonSecondary
  106. v-tippy="{ theme: 'tooltip' }"
  107. :title="
  108. param.hasOwnProperty('active')
  109. ? param.active
  110. ? $t('action.turn_off')
  111. : $t('action.turn_on')
  112. : $t('action.turn_off')
  113. "
  114. :svg="
  115. param.hasOwnProperty('active')
  116. ? param.active
  117. ? 'check-circle'
  118. : 'circle'
  119. : 'check-circle'
  120. "
  121. color="green"
  122. @click.native="
  123. updateBodyParam(index, {
  124. key: param.key,
  125. value: param.value,
  126. active: param.hasOwnProperty('active') ? !param.active : false,
  127. isFile: param.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 { FormDataKeyValue } from "@hoppscotch/data"
  167. import isEqual from "lodash/isEqual"
  168. import { clone } from "lodash"
  169. import draggable from "vuedraggable"
  170. import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
  171. import { useRESTRequestBody } from "~/newstore/RESTSession"
  172. const t = useI18n()
  173. const toast = useToast()
  174. const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
  175. const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref<
  176. FormDataKeyValue[]
  177. >
  178. // The UI representation of the parameters list (has the empty end param)
  179. const workingParams = ref<FormDataKeyValue[]>([
  180. {
  181. key: "",
  182. value: "",
  183. active: true,
  184. isFile: false,
  185. },
  186. ])
  187. // Rule: Working Params always have last element is always an empty param
  188. watch(workingParams, (paramsList) => {
  189. if (paramsList.length > 0 && paramsList[paramsList.length - 1].key !== "") {
  190. workingParams.value.push({
  191. key: "",
  192. value: "",
  193. active: true,
  194. isFile: false,
  195. })
  196. }
  197. })
  198. // Sync logic between params and working params
  199. watch(
  200. bodyParams,
  201. (newParamsList) => {
  202. // Sync should overwrite working params
  203. const filteredWorkingParams = workingParams.value.filter(
  204. (e) => e.key !== ""
  205. )
  206. if (!isEqual(newParamsList, filteredWorkingParams)) {
  207. workingParams.value = newParamsList
  208. }
  209. },
  210. { immediate: true }
  211. )
  212. watch(workingParams, (newWorkingParams) => {
  213. const fixedParams = newWorkingParams.filter((e) => e.key !== "")
  214. if (!isEqual(bodyParams.value, fixedParams)) {
  215. bodyParams.value = fixedParams
  216. }
  217. })
  218. const addBodyParam = () => {
  219. workingParams.value.push({
  220. key: "",
  221. value: "",
  222. active: true,
  223. isFile: false,
  224. })
  225. }
  226. const updateBodyParam = (index: number, param: FormDataKeyValue) => {
  227. workingParams.value = workingParams.value.map((h, i) =>
  228. i === index ? param : h
  229. )
  230. }
  231. const deleteBodyParam = (index: number) => {
  232. const paramsBeforeDeletion = clone(workingParams.value)
  233. if (
  234. !(
  235. paramsBeforeDeletion.length > 0 &&
  236. index === paramsBeforeDeletion.length - 1
  237. )
  238. ) {
  239. if (deletionToast.value) {
  240. deletionToast.value.goAway(0)
  241. deletionToast.value = null
  242. }
  243. deletionToast.value = toast.success(`${t("state.deleted")}`, {
  244. action: [
  245. {
  246. text: `${t("action.undo")}`,
  247. onClick: (_, toastObject) => {
  248. workingParams.value = paramsBeforeDeletion
  249. toastObject.goAway(0)
  250. deletionToast.value = null
  251. },
  252. },
  253. ],
  254. onComplete: () => {
  255. deletionToast.value = null
  256. },
  257. })
  258. }
  259. workingParams.value.splice(index, 1)
  260. }
  261. const clearContent = () => {
  262. // set params list to the initial state
  263. workingParams.value = [
  264. {
  265. key: "",
  266. value: "",
  267. active: true,
  268. isFile: false,
  269. },
  270. ]
  271. }
  272. const setRequestAttachment = (
  273. index: number,
  274. entry: FormDataKeyValue,
  275. event: InputEvent
  276. ) => {
  277. // check if file exists or not
  278. if ((event.target as HTMLInputElement).files?.length === 0) {
  279. updateBodyParam(index, {
  280. ...entry,
  281. isFile: false,
  282. value: "",
  283. })
  284. return
  285. }
  286. const fileEntry: FormDataKeyValue = {
  287. ...entry,
  288. isFile: true,
  289. value: Array.from((event.target as HTMLInputElement).files!),
  290. }
  291. updateBodyParam(index, fileEntry)
  292. }
  293. </script>
  294. <style scoped lang="scss">
  295. .file-chips-container {
  296. @apply flex flex-1;
  297. @apply whitespace-nowrap;
  298. @apply overflow-auto;
  299. @apply bg-transparent;
  300. .file-chips-wrapper {
  301. @apply flex;
  302. @apply p-1;
  303. @apply w-0;
  304. }
  305. }
  306. </style>