12033: Extract multisite loader to its own class.
[arvados.git] / apps / workbench / app / assets / javascripts / models / loader.js
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 window.models = window.models || {}
6 window.models.Pager = function(loadFunc) {
7     // loadFunc(filters) must return a promise for a page of results.
8     var pager = this
9     Object.assign(pager, {
10         done: false,
11         items: m.stream(),
12         thresholdItem: null,
13         loadNextPage: function() {
14             // Get the next page, if there are any more items to get.
15             if (pager.done)
16                 return
17             var filters = pager.thresholdItem ? [
18                 ["modified_at", "<=", pager.thresholdItem.modified_at],
19                 ["uuid", "!=", pager.thresholdItem.uuid],
20             ] : []
21             loadFunc(filters).then(function(resp) {
22                 var items = pager.items() || []
23                 Array.prototype.push.apply(items, resp.items)
24                 if (resp.items.length == 0)
25                     pager.done = true
26                 else
27                     pager.thresholdItem = resp.items[resp.items.length-1]
28                 pager.items(items)
29             })
30         },
31     })
32 }
33
34 // MultisiteLoader loads pages of results from multiple API sessions
35 // and merges them into a single result set.
36 //
37 // The constructor implicitly starts an initial page load for each
38 // session.
39 //
40 // new MultisiteLoader({loadFunc: function(session, filters){...},
41 // sessionDB: new window.models.SessionDB()}
42 //
43 // At any given time, ml.loadMore will be either false (meaning a page
44 // load is in progress or there are no more results to fetch) or a
45 // function that starts loading more results.
46 //
47 // loadFunc() must retrieve results in "modified_at desc" order.
48 window.models = window.models || {}
49 window.models.MultisiteLoader = function(config) {
50     var loader = this
51     if (!(config.loadFunc && config.sessionDB))
52         throw new Error("MultisiteLoader constructor requires loadFunc and sessionDB")
53     Object.assign(loader, config, {
54         // Sorted items ready to display, merged from all pagers.
55         displayable: [],
56         pagers: {},
57         loadMore: false,
58         // Number of undisplayed items to keep on hand for each result
59         // set. When hitting "load more", if a result set already has
60         // this many additional results available, we don't bother
61         // fetching a new page. This is the _minimum_ number of rows
62         // that will be added to loader.displayable in each "load
63         // more" event (except for the case where all items are
64         // displayed).
65         lowWaterMark: 23,
66     })
67     var sessions = loader.sessionDB.loadAll()
68     m.stream.merge(Object.keys(sessions).map(function(key) {
69         var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
70         loader.pagers[key] = pager
71         pager.loadNextPage()
72         // Resolve the stream with the session key when the results
73         // arrive.
74         return pager.items.map(function() { return key })
75     })).map(function(keys) {
76         // Top (most recent) of {bottom (oldest) entry of any pager
77         // that still has more pages left to fetch}
78         var cutoff
79         keys.forEach(function(key) {
80             var pager = loader.pagers[key]
81             var items = pager.items()
82             if (items.length == 0 || pager.done)
83                 return
84             var last = items[items.length-1].modified_at
85             if (!cutoff || cutoff < last)
86                 cutoff = last
87         })
88         var combined = []
89         keys.forEach(function(key) {
90             var pager = loader.pagers[key]
91             pager.itemsDisplayed = 0
92             pager.items().every(function(item) {
93                 if (cutoff && item.modified_at < cutoff)
94                     // Some other pagers haven't caught up to this
95                     // point, so don't display this item or anything
96                     // after it.
97                     return false
98                 item.session = sessions[key]
99                 combined.push(item)
100                 pager.itemsDisplayed++
101                 return true // continue
102             })
103         })
104         loader.displayable = combined.sort(function(a, b) {
105             return a.modified_at < b.modified_at ? 1 : -1
106         })
107         // Make a new loadMore function that hits the pagers (if
108         // necessary according to lowWaterMark)... or set
109         // loader.loadMore to false if there is nothing left to fetch.
110         var loadable = []
111         Object.keys(loader.pagers).map(function(key) {
112             if (!loader.pagers[key].done)
113                 loadable.push(loader.pagers[key])
114         })
115         if (loadable.length == 0)
116             loader.loadMore = false
117         else
118             loader.loadMore = function() {
119                 loader.loadMore = false
120                 loadable.map(function(pager) {
121                     if (pager.items().length - pager.itemsDisplayed < loader.lowWaterMark)
122                         pager.loadNextPage()
123                 })
124             }
125     })
126 }