index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <template>
  2. <div>
  3. <div class="sticky top-0 z-10 flex border-b bg-primary border-dividerLight">
  4. <input
  5. v-model="filterText"
  6. type="search"
  7. autocomplete="off"
  8. class="flex flex-1 p-4 py-2 bg-transparent"
  9. :placeholder="`${t('action.search')}`"
  10. />
  11. <div class="flex">
  12. <ButtonSecondary
  13. v-tippy="{ theme: 'tooltip' }"
  14. to="https://docs.hoppscotch.io/features/history"
  15. blank
  16. :title="t('app.wiki')"
  17. svg="help-circle"
  18. />
  19. <ButtonSecondary
  20. v-tippy="{ theme: 'tooltip' }"
  21. data-testid="clear_history"
  22. :disabled="history.length === 0"
  23. svg="trash-2"
  24. :title="t('action.clear_all')"
  25. @click.native="confirmRemove = true"
  26. />
  27. </div>
  28. </div>
  29. <div class="flex flex-col">
  30. <details
  31. v-for="(
  32. filteredHistoryGroup, filteredHistoryGroupIndex
  33. ) in filteredHistoryGroups"
  34. :key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
  35. class="flex flex-col"
  36. open
  37. >
  38. <summary
  39. class="flex items-center justify-between flex-1 min-w-0 cursor-pointer transition focus:outline-none text-secondaryLight text-tiny group"
  40. >
  41. <span
  42. class="px-4 py-2 truncate transition group-hover:text-secondary capitalize-first"
  43. >
  44. {{ filteredHistoryGroupIndex }}
  45. </span>
  46. <ButtonSecondary
  47. v-tippy="{ theme: 'tooltip' }"
  48. svg="trash"
  49. color="red"
  50. :title="$t('action.remove')"
  51. class="hidden group-hover:inline-flex"
  52. @click.native="deleteBatchHistoryEntry(filteredHistoryGroup)"
  53. />
  54. </summary>
  55. <div
  56. v-for="(entry, index) in filteredHistoryGroup"
  57. :key="`entry-${index}`"
  58. >
  59. <component
  60. :is="page == 'rest' ? 'HistoryRestCard' : 'HistoryGraphqlCard'"
  61. :id="index"
  62. :entry="entry.entry"
  63. :show-more="showMore"
  64. @toggle-star="toggleStar(entry.entry)"
  65. @delete-entry="deleteHistory(entry.entry)"
  66. @use-entry="useHistory(entry.entry)"
  67. />
  68. </div>
  69. </details>
  70. </div>
  71. <div
  72. v-if="!(filteredHistory.length !== 0 || history.length === 0)"
  73. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  74. >
  75. <i class="pb-2 opacity-75 material-icons">manage_search</i>
  76. <span class="my-2 text-center">
  77. {{ t("state.nothing_found") }} "{{ filterText }}"
  78. </span>
  79. </div>
  80. <div
  81. v-if="history.length === 0"
  82. class="flex flex-col items-center justify-center p-4 text-secondaryLight"
  83. >
  84. <img
  85. :src="`/images/states/${$colorMode.value}/history.svg`"
  86. loading="lazy"
  87. class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
  88. :alt="`${t('empty.history')}`"
  89. />
  90. <span class="mb-4 text-center">
  91. {{ t("empty.history") }}
  92. </span>
  93. </div>
  94. <SmartConfirmModal
  95. :show="confirmRemove"
  96. :title="`${t('confirm.remove_history')}`"
  97. @hide-modal="confirmRemove = false"
  98. @resolve="clearHistory"
  99. />
  100. <HttpReqChangeConfirmModal
  101. :show="confirmChange"
  102. @hide-modal="confirmChange = false"
  103. @save-change="saveRequestChange"
  104. @discard-change="discardRequestChange"
  105. />
  106. <CollectionsSaveRequest
  107. mode="rest"
  108. :show="showSaveRequestModal"
  109. @hide-modal="showSaveRequestModal = false"
  110. />
  111. </div>
  112. </template>
  113. <script setup lang="ts">
  114. import { computed, ref, Ref } from "@nuxtjs/composition-api"
  115. import {
  116. HoppRESTRequest,
  117. isEqualHoppRESTRequest,
  118. safelyExtractRESTRequest,
  119. } from "@hoppscotch/data"
  120. import groupBy from "lodash/groupBy"
  121. import { useTimeAgo } from "@vueuse/core"
  122. import { pipe } from "fp-ts/function"
  123. import * as A from "fp-ts/Array"
  124. import * as E from "fp-ts/Either"
  125. import {
  126. useI18n,
  127. useReadonlyStream,
  128. useToast,
  129. } from "~/helpers/utils/composables"
  130. import {
  131. restHistory$,
  132. graphqlHistory$,
  133. clearRESTHistory,
  134. clearGraphqlHistory,
  135. toggleGraphqlHistoryEntryStar,
  136. toggleRESTHistoryEntryStar,
  137. deleteGraphqlHistoryEntry,
  138. deleteRESTHistoryEntry,
  139. RESTHistoryEntry,
  140. GQLHistoryEntry,
  141. } from "~/newstore/history"
  142. import {
  143. getDefaultRESTRequest,
  144. getRESTRequest,
  145. getRESTSaveContext,
  146. setRESTRequest,
  147. setRESTSaveContext,
  148. } from "~/newstore/RESTSession"
  149. import { editRESTRequest } from "~/newstore/collections"
  150. import { runMutation } from "~/helpers/backend/GQLClient"
  151. import { UpdateRequestDocument } from "~/helpers/backend/graphql"
  152. import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
  153. type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
  154. type TimedHistoryEntry = {
  155. entry: HistoryEntry
  156. timeAgo: Ref<string>
  157. }
  158. const props = defineProps<{
  159. page: "rest" | "graphql"
  160. }>()
  161. const toast = useToast()
  162. const t = useI18n()
  163. const filterText = ref("")
  164. const showMore = ref(false)
  165. const confirmRemove = ref(false)
  166. const clickedHistory = ref<HistoryEntry | null>(null)
  167. const confirmChange = ref(false)
  168. const showSaveRequestModal = ref(false)
  169. const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
  170. props.page === "rest" ? restHistory$ : graphqlHistory$,
  171. []
  172. )
  173. const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
  174. if (value === null || value === undefined) return false
  175. if (typeof value === "string") return regExp.test(value)
  176. if (typeof value === "number") return regExp.test(value.toString())
  177. if (typeof value === "object")
  178. return Object.values(value).some((input) =>
  179. deepCheckForRegex(input, regExp)
  180. )
  181. if (Array.isArray(value))
  182. return value.some((input) => deepCheckForRegex(input, regExp))
  183. return false
  184. }
  185. const filteredHistory = computed(() =>
  186. pipe(
  187. history.value as HistoryEntry[],
  188. A.filter(
  189. (
  190. input
  191. ): input is HistoryEntry & {
  192. updatedOn: NonNullable<HistoryEntry["updatedOn"]>
  193. } => {
  194. return (
  195. !!input.updatedOn &&
  196. (filterText.value.length === 0 ||
  197. deepCheckForRegex(input, new RegExp(filterText.value, "gi")))
  198. )
  199. }
  200. ),
  201. A.map(
  202. (entry): TimedHistoryEntry => ({
  203. entry,
  204. timeAgo: useTimeAgo(entry.updatedOn),
  205. })
  206. )
  207. )
  208. )
  209. const filteredHistoryGroups = computed(() =>
  210. groupBy(filteredHistory.value, (entry) => entry.timeAgo.value)
  211. )
  212. const clearHistory = () => {
  213. if (props.page === "rest") clearRESTHistory()
  214. else clearGraphqlHistory()
  215. toast.success(`${t("state.history_deleted")}`)
  216. }
  217. const setRestReq = (request: HoppRESTRequest | null | undefined) => {
  218. setRESTRequest(safelyExtractRESTRequest(request, getDefaultRESTRequest()))
  219. }
  220. // NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
  221. // (That is not a really good behaviour tho ¯\_(ツ)_/¯)
  222. const useHistory = (entry: RESTHistoryEntry) => {
  223. const currentFullReq = getRESTRequest()
  224. // Initial state trigers a popup
  225. if (!clickedHistory.value) {
  226. clickedHistory.value = entry
  227. confirmChange.value = true
  228. return
  229. }
  230. // Checks if there are any change done in current request and the history request
  231. if (
  232. !isEqualHoppRESTRequest(
  233. currentFullReq,
  234. clickedHistory.value.request as HoppRESTRequest
  235. )
  236. ) {
  237. clickedHistory.value = entry
  238. confirmChange.value = true
  239. } else {
  240. props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
  241. clickedHistory.value = entry
  242. }
  243. }
  244. /** Save current request to the collection */
  245. const saveRequestChange = () => {
  246. const saveCtx = getRESTSaveContext()
  247. saveCurrentRequest(saveCtx)
  248. confirmChange.value = false
  249. }
  250. /** Discard changes and change the current request and remove the collection context */
  251. const discardRequestChange = () => {
  252. const saveCtx = getRESTSaveContext()
  253. if (saveCtx) {
  254. setRESTSaveContext(null)
  255. }
  256. clickedHistory.value &&
  257. setRestReq(clickedHistory.value.request as HoppRESTRequest)
  258. confirmChange.value = false
  259. }
  260. const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
  261. if (!saveCtx) {
  262. showSaveRequestModal.value = true
  263. return
  264. }
  265. if (saveCtx.originLocation === "user-collection") {
  266. try {
  267. editRESTRequest(
  268. saveCtx.folderPath,
  269. saveCtx.requestIndex,
  270. getRESTRequest()
  271. )
  272. clickedHistory.value &&
  273. setRestReq(clickedHistory.value.request as HoppRESTRequest)
  274. setRESTSaveContext(null)
  275. toast.success(`${t("request.saved")}`)
  276. } catch (e) {
  277. console.error(e)
  278. setRESTSaveContext(null)
  279. saveCurrentRequest(null)
  280. }
  281. } else if (saveCtx.originLocation === "team-collection") {
  282. const req = getRESTRequest()
  283. try {
  284. runMutation(UpdateRequestDocument, {
  285. requestID: saveCtx.requestID,
  286. data: {
  287. title: req.name,
  288. request: JSON.stringify(req),
  289. },
  290. })().then((result) => {
  291. if (E.isLeft(result)) {
  292. toast.error(`${t("profile.no_permission")}`)
  293. } else {
  294. toast.success(`${t("request.saved")}`)
  295. }
  296. })
  297. clickedHistory.value &&
  298. setRestReq(clickedHistory.value.request as HoppRESTRequest)
  299. setRESTSaveContext(null)
  300. } catch (error) {
  301. showSaveRequestModal.value = true
  302. toast.error(`${t("error.something_went_wrong")}`)
  303. console.error(error)
  304. setRESTSaveContext(null)
  305. }
  306. }
  307. }
  308. const isRESTHistoryEntry = (
  309. entries: TimedHistoryEntry[]
  310. ): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
  311. // If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
  312. props.page === "rest"
  313. const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
  314. if (isRESTHistoryEntry(entries)) {
  315. entries.forEach((entry) => {
  316. deleteRESTHistoryEntry(entry.entry)
  317. })
  318. } else {
  319. entries.forEach((entry) => {
  320. deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
  321. })
  322. }
  323. toast.success(`${t("state.deleted")}`)
  324. }
  325. const deleteHistory = (entry: HistoryEntry) => {
  326. if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
  327. else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
  328. toast.success(`${t("state.deleted")}`)
  329. }
  330. const toggleStar = (entry: HistoryEntry) => {
  331. // History entry type specified because function does not know the type
  332. if (props.page === "rest")
  333. toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
  334. else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
  335. }
  336. </script>