TeamCollectionAdapter.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import * as E from "fp-ts/Either"
  2. import { BehaviorSubject, Subscription } from "rxjs"
  3. import { translateToNewRequest } from "@hoppscotch/data"
  4. import pull from "lodash/pull"
  5. import remove from "lodash/remove"
  6. import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
  7. import { TeamCollection } from "./TeamCollection"
  8. import { TeamRequest } from "./TeamRequest"
  9. import {
  10. RootCollectionsOfTeamDocument,
  11. TeamCollectionAddedDocument,
  12. TeamCollectionUpdatedDocument,
  13. TeamCollectionRemovedDocument,
  14. TeamRequestAddedDocument,
  15. TeamRequestUpdatedDocument,
  16. TeamRequestDeletedDocument,
  17. GetCollectionChildrenDocument,
  18. GetCollectionRequestsDocument,
  19. } from "~/helpers/backend/graphql"
  20. const TEAMS_BACKEND_PAGE_SIZE = 10
  21. /**
  22. * Finds the parent of a collection and returns the REFERENCE (or null)
  23. *
  24. * @param {TeamCollection[]} tree - The tree to look in
  25. * @param {string} collID - ID of the collection to find the parent of
  26. * @param {TeamCollection} currentParent - (used for recursion, do not set) The parent in the current iteration (undefined if root)
  27. *
  28. * @returns REFERENCE to the collection or null if not found or the collection is in root
  29. */
  30. function findParentOfColl(
  31. tree: TeamCollection[],
  32. collID: string,
  33. currentParent?: TeamCollection
  34. ): TeamCollection | null {
  35. for (const coll of tree) {
  36. // If the root is parent, return null
  37. if (coll.id === collID) return currentParent || null
  38. // Else run it in children
  39. if (coll.children) {
  40. const result = findParentOfColl(coll.children, collID, coll)
  41. if (result) return result
  42. }
  43. }
  44. return null
  45. }
  46. /**
  47. * Finds and returns a REFERENCE collection in the given tree (or null)
  48. *
  49. * @param {TeamCollection[]} tree - The tree to look in
  50. * @param {string} targetID - The ID of the collection to look for
  51. *
  52. * @returns REFERENCE to the collection or null if not found
  53. */
  54. function findCollInTree(
  55. tree: TeamCollection[],
  56. targetID: string
  57. ): TeamCollection | null {
  58. for (const coll of tree) {
  59. // If the direct child matched, then return that
  60. if (coll.id === targetID) return coll
  61. // Else run it in the children
  62. if (coll.children) {
  63. const result = findCollInTree(coll.children, targetID)
  64. if (result) return result
  65. }
  66. }
  67. // If nothing matched, return null
  68. return null
  69. }
  70. /**
  71. * Deletes a collection in the tree
  72. *
  73. * @param {TeamCollection[]} tree - The tree to delete in (THIS WILL BE MUTATED!)
  74. * @param {string} targetID - ID of the collection to delete
  75. */
  76. function deleteCollInTree(tree: TeamCollection[], targetID: string) {
  77. // Get the parent owning the collection
  78. const parent = findParentOfColl(tree, targetID)
  79. // If we found a parent, update it
  80. if (parent && parent.children) {
  81. parent.children = parent.children.filter((coll) => coll.id !== targetID)
  82. }
  83. // If there is no parent, it could mean:
  84. // 1. The collection with that ID does not exist
  85. // 2. The collection is in root (therefore, no parent)
  86. // Let's look for element, if not exist, then stop
  87. const el = findCollInTree(tree, targetID)
  88. if (!el) return
  89. // Collection exists, so this should be in root, hence removing element
  90. pull(tree, el)
  91. }
  92. /**
  93. * Updates a collection in the tree with the specified data
  94. *
  95. * @param {TeamCollection[]} tree - The tree to update in (THIS WILL BE MUTATED!)
  96. * @param {Partial<TeamCollection> & Pick<TeamCollection, "id">} updateColl - An object defining all the fields that should be updated (ID is required to find the target collection)
  97. */
  98. function updateCollInTree(
  99. tree: TeamCollection[],
  100. updateColl: Partial<TeamCollection> & Pick<TeamCollection, "id">
  101. ) {
  102. const el = findCollInTree(tree, updateColl.id)
  103. // If no match, stop the operation
  104. if (!el) return
  105. // Update all the specified keys
  106. Object.assign(el, updateColl)
  107. }
  108. /**
  109. * Finds and returns a REFERENCE to the request with the given ID (or null)
  110. *
  111. * @param {TeamCollection[]} tree - The tree to look in
  112. * @param {string} reqID - The ID of the request to look for
  113. *
  114. * @returns REFERENCE to the request or null if request not found
  115. */
  116. function findReqInTree(
  117. tree: TeamCollection[],
  118. reqID: string
  119. ): TeamRequest | null {
  120. for (const coll of tree) {
  121. // Check in root collections (if expanded)
  122. if (coll.requests) {
  123. const match = coll.requests.find((req) => req.id === reqID)
  124. if (match) return match
  125. }
  126. // Check in children of collections
  127. if (coll.children) {
  128. const match = findReqInTree(coll.children, reqID)
  129. if (match) return match
  130. }
  131. }
  132. // No matches
  133. return null
  134. }
  135. /**
  136. * Finds and returns a REFERENCE to the collection containing a given request ID in tree (or null)
  137. *
  138. * @param {TeamCollection[]} tree - The tree to look in
  139. * @param {string} reqID - The ID of the request to look for
  140. *
  141. * @returns REFERENCE to the collection or null if request not found
  142. */
  143. function findCollWithReqIDInTree(
  144. tree: TeamCollection[],
  145. reqID: string
  146. ): TeamCollection | null {
  147. for (const coll of tree) {
  148. // Check in root collections (if expanded)
  149. if (coll.requests) {
  150. if (coll.requests.find((req) => req.id === reqID)) return coll
  151. }
  152. // Check in children of collections
  153. if (coll.children) {
  154. const result = findCollWithReqIDInTree(coll.children, reqID)
  155. if (result) return result
  156. }
  157. }
  158. // No matches
  159. return null
  160. }
  161. export default class NewTeamCollectionAdapter {
  162. collections$: BehaviorSubject<TeamCollection[]>
  163. // Stream to the list of collections/folders that are being loaded in
  164. loadingCollections$: BehaviorSubject<string[]>
  165. private teamCollectionAdded$: Subscription | null
  166. private teamCollectionUpdated$: Subscription | null
  167. private teamCollectionRemoved$: Subscription | null
  168. private teamRequestAdded$: Subscription | null
  169. private teamRequestUpdated$: Subscription | null
  170. private teamRequestDeleted$: Subscription | null
  171. constructor(private teamID: string | null) {
  172. this.collections$ = new BehaviorSubject<TeamCollection[]>([])
  173. this.loadingCollections$ = new BehaviorSubject<string[]>([])
  174. this.teamCollectionAdded$ = null
  175. this.teamCollectionUpdated$ = null
  176. this.teamCollectionRemoved$ = null
  177. this.teamRequestAdded$ = null
  178. this.teamRequestDeleted$ = null
  179. this.teamRequestUpdated$ = null
  180. if (this.teamID) this.initialize()
  181. }
  182. changeTeamID(newTeamID: string | null) {
  183. this.teamID = newTeamID
  184. this.collections$.next([])
  185. this.loadingCollections$.next([])
  186. this.unsubscribeSubscriptions()
  187. if (this.teamID) this.initialize()
  188. }
  189. /**
  190. * Unsubscribes from the subscriptions
  191. * NOTE: Once this is called, no new updates to the tree will be detected
  192. */
  193. unsubscribeSubscriptions() {
  194. this.teamCollectionAdded$?.unsubscribe()
  195. this.teamCollectionUpdated$?.unsubscribe()
  196. this.teamCollectionRemoved$?.unsubscribe()
  197. this.teamRequestAdded$?.unsubscribe()
  198. this.teamRequestDeleted$?.unsubscribe()
  199. this.teamRequestUpdated$?.unsubscribe()
  200. }
  201. private async initialize() {
  202. await this.loadRootCollections()
  203. this.registerSubscriptions()
  204. }
  205. /**
  206. * Performs addition of a collection to the tree
  207. *
  208. * @param {TeamCollection} collection - The collection to add to the tree
  209. * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root
  210. */
  211. private addCollection(
  212. collection: TeamCollection,
  213. parentCollectionID: string | null
  214. ) {
  215. const tree = this.collections$.value
  216. if (!parentCollectionID) {
  217. tree.push(collection)
  218. } else {
  219. const parentCollection = findCollInTree(tree, parentCollectionID)
  220. if (!parentCollection) return
  221. if (parentCollection.children != null) {
  222. parentCollection.children.push(collection)
  223. } else {
  224. parentCollection.children = [collection]
  225. }
  226. }
  227. this.collections$.next(tree)
  228. }
  229. private async loadRootCollections() {
  230. if (this.teamID === null) throw new Error("Team ID is null")
  231. this.loadingCollections$.next([
  232. ...this.loadingCollections$.getValue(),
  233. "root",
  234. ])
  235. const totalCollections: TeamCollection[] = []
  236. while (true) {
  237. const result = await runGQLQuery({
  238. query: RootCollectionsOfTeamDocument,
  239. variables: {
  240. teamID: this.teamID,
  241. cursor:
  242. totalCollections.length > 0
  243. ? totalCollections[totalCollections.length - 1].id
  244. : undefined,
  245. },
  246. })
  247. if (E.isLeft(result)) {
  248. this.loadingCollections$.next(
  249. this.loadingCollections$.getValue().filter((x) => x !== "root")
  250. )
  251. throw new Error(`Error fetching root collections: ${result}`)
  252. }
  253. totalCollections.push(
  254. ...result.right.rootCollectionsOfTeam.map(
  255. (x) =>
  256. <TeamCollection>{
  257. ...x,
  258. children: null,
  259. requests: null,
  260. }
  261. )
  262. )
  263. if (result.right.rootCollectionsOfTeam.length !== TEAMS_BACKEND_PAGE_SIZE)
  264. break
  265. }
  266. this.loadingCollections$.next(
  267. this.loadingCollections$.getValue().filter((x) => x !== "root")
  268. )
  269. this.collections$.next(totalCollections)
  270. }
  271. /**
  272. * Updates an existing collection in tree
  273. *
  274. * @param {Partial<TeamCollection> & Pick<TeamCollection, "id">} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target)
  275. */
  276. private updateCollection(
  277. collectionUpdate: Partial<TeamCollection> & Pick<TeamCollection, "id">
  278. ) {
  279. const tree = this.collections$.value
  280. updateCollInTree(tree, collectionUpdate)
  281. this.collections$.next(tree)
  282. }
  283. /**
  284. * Removes a collection from the tree
  285. *
  286. * @param {string} collectionID - ID of the collection to remove
  287. */
  288. private removeCollection(collectionID: string) {
  289. const tree = this.collections$.value
  290. deleteCollInTree(tree, collectionID)
  291. this.collections$.next(tree)
  292. }
  293. /**
  294. * Adds a request to the tree
  295. *
  296. * @param {TeamRequest} request - The request to add to the tree
  297. */
  298. private addRequest(request: TeamRequest) {
  299. const tree = this.collections$.value
  300. // Check if we have the collection (if not, then not loaded?)
  301. const coll = findCollInTree(tree, request.collectionID)
  302. if (!coll) return // Ignore add request
  303. // Collection is not expanded
  304. if (!coll.requests) return
  305. // Collection is expanded hence append request
  306. coll.requests.push(request)
  307. this.collections$.next(tree)
  308. }
  309. /**
  310. * Updates the request in tree
  311. *
  312. * @param {Partial<TeamRequest> & Pick<TeamRequest, 'id'>} requestUpdate - Object defining all the fields to update in request (ID of the request is required)
  313. */
  314. private updateRequest(
  315. requestUpdate: Partial<TeamRequest> & Pick<TeamRequest, "id">
  316. ) {
  317. const tree = this.collections$.value
  318. // Find request, if not present, don't update
  319. const req = findReqInTree(tree, requestUpdate.id)
  320. if (!req) return
  321. Object.assign(req, requestUpdate)
  322. this.collections$.next(tree)
  323. }
  324. /**
  325. * Removes a request from the tree
  326. *
  327. * @param {string} requestID - ID of the request to remove
  328. */
  329. private removeRequest(requestID: string) {
  330. const tree = this.collections$.value
  331. // Find request in tree, don't attempt if no collection or no requests (expansion?)
  332. const coll = findCollWithReqIDInTree(tree, requestID)
  333. if (!coll || !coll.requests) return
  334. // Remove the collection
  335. remove(coll.requests, (req) => req.id === requestID)
  336. // Publish new tree
  337. this.collections$.next(tree)
  338. }
  339. private registerSubscriptions() {
  340. if (!this.teamID) return
  341. this.teamCollectionAdded$ = runGQLSubscription({
  342. query: TeamCollectionAddedDocument,
  343. variables: {
  344. teamID: this.teamID,
  345. },
  346. }).subscribe((result) => {
  347. if (E.isLeft(result))
  348. throw new Error(`Team Collection Added Error: ${result.left}`)
  349. this.addCollection(
  350. {
  351. id: result.right.teamCollectionAdded.id,
  352. children: null,
  353. requests: null,
  354. title: result.right.teamCollectionAdded.title,
  355. },
  356. result.right.teamCollectionAdded.parent?.id ?? null
  357. )
  358. })
  359. this.teamCollectionUpdated$ = runGQLSubscription({
  360. query: TeamCollectionUpdatedDocument,
  361. variables: {
  362. teamID: this.teamID,
  363. },
  364. }).subscribe((result) => {
  365. if (E.isLeft(result))
  366. throw new Error(`Team Collection Updated Error: ${result.left}`)
  367. this.updateCollection({
  368. id: result.right.teamCollectionUpdated.id,
  369. title: result.right.teamCollectionUpdated.title,
  370. })
  371. })
  372. this.teamCollectionRemoved$ = runGQLSubscription({
  373. query: TeamCollectionRemovedDocument,
  374. variables: {
  375. teamID: this.teamID,
  376. },
  377. }).subscribe((result) => {
  378. if (E.isLeft(result))
  379. throw new Error(`Team Collection Removed Error: ${result.left}`)
  380. this.removeCollection(result.right.teamCollectionRemoved)
  381. })
  382. this.teamRequestAdded$ = runGQLSubscription({
  383. query: TeamRequestAddedDocument,
  384. variables: {
  385. teamID: this.teamID,
  386. },
  387. }).subscribe((result) => {
  388. if (E.isLeft(result))
  389. throw new Error(`Team Request Added Error: ${result.left}`)
  390. this.addRequest({
  391. id: result.right.teamRequestAdded.id,
  392. collectionID: result.right.teamRequestAdded.collectionID,
  393. request: translateToNewRequest(
  394. JSON.parse(result.right.teamRequestAdded.request)
  395. ),
  396. title: result.right.teamRequestAdded.title,
  397. })
  398. })
  399. this.teamRequestUpdated$ = runGQLSubscription({
  400. query: TeamRequestUpdatedDocument,
  401. variables: {
  402. teamID: this.teamID,
  403. },
  404. }).subscribe((result) => {
  405. if (E.isLeft(result))
  406. throw new Error(`Team Request Updated Error: ${result.left}`)
  407. this.updateRequest({
  408. id: result.right.teamRequestUpdated.id,
  409. collectionID: result.right.teamRequestUpdated.collectionID,
  410. request: JSON.parse(result.right.teamRequestUpdated.request),
  411. title: result.right.teamRequestUpdated.title,
  412. })
  413. })
  414. this.teamRequestDeleted$ = runGQLSubscription({
  415. query: TeamRequestDeletedDocument,
  416. variables: {
  417. teamID: this.teamID,
  418. },
  419. }).subscribe((result) => {
  420. if (E.isLeft(result))
  421. throw new Error(`Team Request Deleted Error ${result.left}`)
  422. this.removeRequest(result.right.teamRequestDeleted)
  423. })
  424. }
  425. /**
  426. * Expands a collection on the tree
  427. *
  428. * When a collection is loaded initially in the adapter, children and requests are not loaded (they will be set to null)
  429. * Upon expansion those two fields will be populated
  430. *
  431. * @param {string} collectionID - The ID of the collection to expand
  432. */
  433. async expandCollection(collectionID: string): Promise<void> {
  434. // TODO: While expanding one collection, block (or queue) the expansion of the other, to avoid race conditions
  435. const tree = this.collections$.value
  436. const collection = findCollInTree(tree, collectionID)
  437. if (!collection) return
  438. if (collection.children != null) return
  439. const collections: TeamCollection[] = []
  440. this.loadingCollections$.next([
  441. ...this.loadingCollections$.getValue(),
  442. collectionID,
  443. ])
  444. while (true) {
  445. const data = await runGQLQuery({
  446. query: GetCollectionChildrenDocument,
  447. variables: {
  448. collectionID,
  449. cursor:
  450. collections.length > 0
  451. ? collections[collections.length - 1].id
  452. : undefined,
  453. },
  454. })
  455. if (E.isLeft(data)) {
  456. this.loadingCollections$.next(
  457. this.loadingCollections$.getValue().filter((x) => x !== collectionID)
  458. )
  459. throw new Error(
  460. `Child Collection Fetch Error for ${collectionID}: ${data.left}`
  461. )
  462. }
  463. collections.push(
  464. ...data.right.collection!.children.map(
  465. (el) =>
  466. <TeamCollection>{
  467. id: el.id,
  468. title: el.title,
  469. children: null,
  470. requests: null,
  471. }
  472. )
  473. )
  474. if (data.right.collection!.children.length !== TEAMS_BACKEND_PAGE_SIZE)
  475. break
  476. }
  477. const requests: TeamRequest[] = []
  478. while (true) {
  479. const data = await runGQLQuery({
  480. query: GetCollectionRequestsDocument,
  481. variables: {
  482. collectionID,
  483. cursor:
  484. requests.length > 0 ? requests[requests.length - 1].id : undefined,
  485. },
  486. })
  487. if (E.isLeft(data)) {
  488. this.loadingCollections$.next(
  489. this.loadingCollections$.getValue().filter((x) => x !== collectionID)
  490. )
  491. throw new Error(`Child Request Fetch Error for ${data}: ${data.left}`)
  492. }
  493. requests.push(
  494. ...data.right.requestsInCollection.map<TeamRequest>((el) => {
  495. return {
  496. id: el.id,
  497. collectionID,
  498. title: el.title,
  499. request: translateToNewRequest(JSON.parse(el.request)),
  500. }
  501. })
  502. )
  503. if (data.right.requestsInCollection.length !== TEAMS_BACKEND_PAGE_SIZE)
  504. break
  505. }
  506. collection.children = collections
  507. collection.requests = requests
  508. this.loadingCollections$.next(
  509. this.loadingCollections$.getValue().filter((x) => x !== collectionID)
  510. )
  511. this.collections$.next(tree)
  512. }
  513. }