TeamCollectionAdapter.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import { BehaviorSubject } from "rxjs"
  2. import { gql } from "graphql-tag"
  3. import pull from "lodash/pull"
  4. import remove from "lodash/remove"
  5. import { translateToNewRequest } from "../types/HoppRESTRequest"
  6. import { TeamCollection } from "./TeamCollection"
  7. import { TeamRequest } from "./TeamRequest"
  8. import {
  9. rootCollectionsOfTeam,
  10. getCollectionChildren,
  11. getCollectionRequests,
  12. } from "./utils"
  13. import { apolloClient } from "~/helpers/apollo"
  14. /*
  15. * NOTE: These functions deal with REFERENCES to objects and mutates them, for a simpler implementation.
  16. * Be careful when you play with these.
  17. *
  18. * I am not a fan of mutating references but this is so much simpler compared to mutating clones
  19. * - Andrew
  20. */
  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 collecton 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. * Finds and returns a REFERENCE to the collection containing a given request ID in tree (or null)
  72. *
  73. * @param {TeamCollection[]} tree - The tree to look in
  74. * @param {string} reqID - The ID of the request to look for
  75. *
  76. * @returns REFERENCE to the collection or null if request not found
  77. */
  78. function findCollWithReqIDInTree(
  79. tree: TeamCollection[],
  80. reqID: string
  81. ): TeamCollection | null {
  82. for (const coll of tree) {
  83. // Check in root collections (if expanded)
  84. if (coll.requests) {
  85. if (coll.requests.find((req) => req.id === reqID)) return coll
  86. }
  87. // Check in children of collections
  88. if (coll.children) {
  89. const result = findCollWithReqIDInTree(coll.children, reqID)
  90. if (result) return result
  91. }
  92. }
  93. // No matches
  94. return null
  95. }
  96. /**
  97. * Finds and returns a REFERENCE to the request with the given ID (or null)
  98. *
  99. * @param {TeamCollection[]} tree - The tree to look in
  100. * @param {string} reqID - The ID of the request to look for
  101. *
  102. * @returns REFERENCE to the request or null if request not found
  103. */
  104. function findReqInTree(
  105. tree: TeamCollection[],
  106. reqID: string
  107. ): TeamRequest | null {
  108. for (const coll of tree) {
  109. // Check in root collections (if expanded)
  110. if (coll.requests) {
  111. const match = coll.requests.find((req) => req.id === reqID)
  112. if (match) return match
  113. }
  114. // Check in children of collections
  115. if (coll.children) {
  116. const match = findReqInTree(coll.children, reqID)
  117. if (match) return match
  118. }
  119. }
  120. // No matches
  121. return null
  122. }
  123. /**
  124. * Updates a collection in the tree with the specified data
  125. *
  126. * @param {TeamCollection[]} tree - The tree to update in (THIS WILL BE MUTATED!)
  127. * @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)
  128. */
  129. function updateCollInTree(
  130. tree: TeamCollection[],
  131. updateColl: Partial<TeamCollection> & Pick<TeamCollection, "id">
  132. ) {
  133. const el = findCollInTree(tree, updateColl.id)
  134. // If no match, stop the operation
  135. if (!el) return
  136. // Update all the specified keys
  137. Object.assign(el, updateColl)
  138. }
  139. /**
  140. * Deletes a collection in the tree
  141. *
  142. * @param {TeamCollection[]} tree - The tree to delete in (THIS WILL BE MUTATED!)
  143. * @param {string} targetID - ID of the collection to delete
  144. */
  145. function deleteCollInTree(tree: TeamCollection[], targetID: string) {
  146. // Get the parent owning the collection
  147. const parent = findParentOfColl(tree, targetID)
  148. // If we found a parent, update it
  149. if (parent && parent.children) {
  150. parent.children = parent.children.filter((coll) => coll.id !== targetID)
  151. }
  152. // If there is no parent, it could mean:
  153. // 1. The collection with that ID does not exist
  154. // 2. The collection is in root (therefore, no parent)
  155. // Let's look for element, if not exist, then stop
  156. const el = findCollInTree(tree, targetID)
  157. if (!el) return
  158. // Collection exists, so this should be in root, hence removing element
  159. pull(tree, el)
  160. }
  161. /**
  162. * TeamCollectionAdapter provides a reactive collections list for a specific team
  163. */
  164. export default class TeamCollectionAdapter {
  165. /**
  166. * The reactive list of collections
  167. *
  168. * A new value is emitted when there is a change
  169. * (Use views instead)
  170. */
  171. collections$: BehaviorSubject<TeamCollection[]>
  172. // Fields for subscriptions, used for destroying once not needed
  173. private teamCollectionAdded$: ZenObservable.Subscription | null
  174. private teamCollectionUpdated$: ZenObservable.Subscription | null
  175. private teamCollectionRemoved$: ZenObservable.Subscription | null
  176. private teamRequestAdded$: ZenObservable.Subscription | null
  177. private teamRequestUpdated$: ZenObservable.Subscription | null
  178. private teamRequestDeleted$: ZenObservable.Subscription | null
  179. /**
  180. * @constructor
  181. *
  182. * @param {string | null} teamID - ID of the team to listen to, or null if none decided and the adapter should stand by
  183. */
  184. constructor(private teamID: string | null) {
  185. this.collections$ = new BehaviorSubject<TeamCollection[]>([])
  186. this.teamCollectionAdded$ = null
  187. this.teamCollectionUpdated$ = null
  188. this.teamCollectionRemoved$ = null
  189. this.teamRequestAdded$ = null
  190. this.teamRequestDeleted$ = null
  191. this.teamRequestUpdated$ = null
  192. if (this.teamID) this.initialize()
  193. }
  194. /**
  195. * Updates the team the adapter is looking at
  196. *
  197. * @param {string | null} newTeamID - ID of the team to listen to, or null if none decided and the adapter should stand by
  198. */
  199. changeTeamID(newTeamID: string | null) {
  200. this.collections$.next([])
  201. this.teamID = newTeamID
  202. if (this.teamID) this.initialize()
  203. }
  204. /**
  205. * Unsubscribes from the subscriptions
  206. * NOTE: Once this is called, no new updates to the tree will be detected
  207. */
  208. unsubscribeSubscriptions() {
  209. this.teamCollectionAdded$?.unsubscribe()
  210. this.teamCollectionUpdated$?.unsubscribe()
  211. this.teamCollectionRemoved$?.unsubscribe()
  212. this.teamRequestAdded$?.unsubscribe()
  213. this.teamRequestDeleted$?.unsubscribe()
  214. this.teamRequestUpdated$?.unsubscribe()
  215. }
  216. /**
  217. * Initializes the adapter
  218. */
  219. private async initialize() {
  220. await this.loadRootCollections()
  221. this.registerSubscriptions()
  222. }
  223. /**
  224. * Loads the root collections
  225. */
  226. private async loadRootCollections(): Promise<void> {
  227. const colls = await rootCollectionsOfTeam(apolloClient, this.teamID)
  228. this.collections$.next(colls)
  229. }
  230. /**
  231. * Performs addition of a collection to the tree
  232. *
  233. * @param {TeamCollection} collection - The collection to add to the tree
  234. * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root
  235. */
  236. private addCollection(
  237. collection: TeamCollection,
  238. parentCollectionID: string | null
  239. ) {
  240. const tree = this.collections$.value
  241. if (!parentCollectionID) {
  242. tree.push(collection)
  243. } else {
  244. const parentCollection = findCollInTree(tree, parentCollectionID)
  245. if (!parentCollection) return
  246. if (parentCollection.children != null) {
  247. parentCollection.children.push(collection)
  248. } else {
  249. parentCollection.children = [collection]
  250. }
  251. }
  252. this.collections$.next(tree)
  253. }
  254. /**
  255. * Updates an existing collection in tree
  256. *
  257. * @param {Partial<TeamCollection> & Pick<TeamCollection, "id">} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target)
  258. */
  259. private updateCollection(
  260. collectionUpdate: Partial<TeamCollection> & Pick<TeamCollection, "id">
  261. ) {
  262. const tree = this.collections$.value
  263. updateCollInTree(tree, collectionUpdate)
  264. this.collections$.next(tree)
  265. }
  266. /**
  267. * Removes a collection from the tree
  268. *
  269. * @param {string} collectionID - ID of the collection to remove
  270. */
  271. private removeCollection(collectionID: string) {
  272. const tree = this.collections$.value
  273. deleteCollInTree(tree, collectionID)
  274. this.collections$.next(tree)
  275. }
  276. /**
  277. * Adds a request to the tree
  278. *
  279. * @param {TeamRequest} request - The request to add to the tree
  280. */
  281. private addRequest(request: TeamRequest) {
  282. const tree = this.collections$.value
  283. // Check if we have the collection (if not, then not loaded?)
  284. const coll = findCollInTree(tree, request.collectionID)
  285. if (!coll) return // Ignore add request
  286. // Collection is not expanded
  287. if (!coll.requests) return
  288. // Collection is expanded hence append request
  289. coll.requests.push(request)
  290. this.collections$.next(tree)
  291. }
  292. /**
  293. * Removes a request from the tree
  294. *
  295. * @param {string} requestID - ID of the request to remove
  296. */
  297. private removeRequest(requestID: string) {
  298. const tree = this.collections$.value
  299. // Find request in tree, don't attempt if no collection or no requests (expansion?)
  300. const coll = findCollWithReqIDInTree(tree, requestID)
  301. if (!coll || !coll.requests) return
  302. // Remove the collection
  303. remove(coll.requests, (req) => req.id === requestID)
  304. // Publish new tree
  305. this.collections$.next(tree)
  306. }
  307. /**
  308. * Updates the request in tree
  309. *
  310. * @param {Partial<TeamRequest> & Pick<TeamRequest, 'id'>} requestUpdate - Object defining all the fields to update in request (ID of the request is required)
  311. */
  312. private updateRequest(
  313. requestUpdate: Partial<TeamRequest> & Pick<TeamRequest, "id">
  314. ) {
  315. const tree = this.collections$.value
  316. // Find request, if not present, don't update
  317. const req = findReqInTree(tree, requestUpdate.id)
  318. if (!req) return
  319. Object.assign(req, requestUpdate)
  320. this.collections$.next(tree)
  321. }
  322. /**
  323. * Registers the subscriptions to listen to team collection updates
  324. */
  325. registerSubscriptions() {
  326. this.teamCollectionAdded$ = apolloClient
  327. .subscribe({
  328. query: gql`
  329. subscription TeamCollectionAdded($teamID: String!) {
  330. teamCollectionAdded(teamID: $teamID) {
  331. id
  332. title
  333. parent {
  334. id
  335. }
  336. }
  337. }
  338. `,
  339. variables: {
  340. teamID: this.teamID,
  341. },
  342. })
  343. .subscribe(({ data }) => {
  344. this.addCollection(
  345. {
  346. id: data.teamCollectionAdded.id,
  347. children: null,
  348. requests: null,
  349. title: data.teamCollectionAdded.title,
  350. },
  351. data.teamCollectionAdded.parent?.id
  352. )
  353. })
  354. this.teamCollectionUpdated$ = apolloClient
  355. .subscribe({
  356. query: gql`
  357. subscription TeamCollectionUpdated($teamID: String!) {
  358. teamCollectionUpdated(teamID: $teamID) {
  359. id
  360. title
  361. parent {
  362. id
  363. }
  364. }
  365. }
  366. `,
  367. variables: {
  368. teamID: this.teamID,
  369. },
  370. })
  371. .subscribe(({ data }) => {
  372. this.updateCollection({
  373. id: data.teamCollectionUpdated.id,
  374. title: data.teamCollectionUpdated.title,
  375. })
  376. })
  377. this.teamCollectionRemoved$ = apolloClient
  378. .subscribe({
  379. query: gql`
  380. subscription TeamCollectionRemoved($teamID: String!) {
  381. teamCollectionRemoved(teamID: $teamID)
  382. }
  383. `,
  384. variables: {
  385. teamID: this.teamID,
  386. },
  387. })
  388. .subscribe(({ data }) => {
  389. this.removeCollection(data.teamCollectionRemoved)
  390. })
  391. this.teamRequestAdded$ = apolloClient
  392. .subscribe({
  393. query: gql`
  394. subscription TeamRequestAdded($teamID: String!) {
  395. teamRequestAdded(teamID: $teamID) {
  396. id
  397. collectionID
  398. request
  399. title
  400. }
  401. }
  402. `,
  403. variables: {
  404. teamID: this.teamID,
  405. },
  406. })
  407. .subscribe(({ data }) => {
  408. this.addRequest({
  409. id: data.teamRequestAdded.id,
  410. collectionID: data.teamRequestAdded.collectionID,
  411. request: translateToNewRequest(
  412. JSON.parse(data.teamRequestAdded.request)
  413. ),
  414. title: data.teamRequestAdded.title,
  415. })
  416. })
  417. this.teamRequestUpdated$ = apolloClient
  418. .subscribe({
  419. query: gql`
  420. subscription TeamRequestUpdated($teamID: String!) {
  421. teamRequestUpdated(teamID: $teamID) {
  422. id
  423. collectionID
  424. request
  425. title
  426. }
  427. }
  428. `,
  429. variables: {
  430. teamID: this.teamID,
  431. },
  432. })
  433. .subscribe(({ data }) => {
  434. this.updateRequest({
  435. id: data.teamRequestUpdated.id,
  436. collectionID: data.teamRequestUpdated.collectionID,
  437. request: JSON.parse(data.teamRequestUpdated.request),
  438. title: data.teamRequestUpdated.title,
  439. })
  440. })
  441. this.teamRequestDeleted$ = apolloClient
  442. .subscribe({
  443. query: gql`
  444. subscription TeamRequestDeleted($teamID: String!) {
  445. teamRequestDeleted(teamID: $teamID)
  446. }
  447. `,
  448. variables: {
  449. teamID: this.teamID,
  450. },
  451. })
  452. .subscribe(({ data }) => {
  453. this.removeRequest(data.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. await getCollectionChildren(apolloClient, collectionID)
  472. ).map<TeamCollection>((el) => {
  473. return {
  474. id: el.id,
  475. title: el.title,
  476. children: null,
  477. requests: null,
  478. }
  479. })
  480. const requests: TeamRequest[] = (
  481. await getCollectionRequests(apolloClient, collectionID)
  482. ).map<TeamRequest>((el) => {
  483. return {
  484. id: el.id,
  485. collectionID,
  486. title: el.title,
  487. request: translateToNewRequest(JSON.parse(el.request)),
  488. }
  489. })
  490. collection.children = collections
  491. collection.requests = requests
  492. this.collections$.next(tree)
  493. }
  494. }