TeamCollectionAdapter.ts 19 KB

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