12033: Generalize MultisiteLoader to MergingLoader.
[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 // MultipageLoader retrieves a multi-page result set from the
6 // server. The constructor initiates the first page load.
7 //
8 // config.loadFunc is a function that accepts an array of
9 // paging-related filters, and returns a promise for the API
10 // response. loadFunc() must retrieve results in "modified_at desc"
11 // order.
12 //
13 // done is true if there are no more pages to load.
14 //
15 // loading is true if a network request is in progress.
16 //
17 // items is a stream that resolves to an array of all items retrieved so far.
18 //
19 // loadMore() loads the next page, if any.
20 window.models = window.models || {}
21 window.models.MultipageLoader = function(config) {
22     var loader = this
23     Object.assign(loader, config, {
24         done: false,
25         loading: false,
26         items: m.stream(),
27         thresholdItem: null,
28         loadMore: function() {
29             if (loader.done || loader.loading)
30                 return
31             var filters = loader.thresholdItem ? [
32                 ["modified_at", "<=", loader.thresholdItem.modified_at],
33                 ["uuid", "!=", loader.thresholdItem.uuid],
34             ] : []
35             loader.loading = true
36             loader.loadFunc(filters).then(function(resp) {
37                 var items = loader.items() || []
38                 Array.prototype.push.apply(items, resp.items)
39                 if (resp.items.length == 0)
40                     loader.done = true
41                 else
42                     loader.thresholdItem = resp.items[resp.items.length-1]
43                 loader.loading = false
44                 loader.items(items)
45             }).catch(function(err) {
46                 loader.err = err
47                 loader.loading = false
48             })
49         },
50     })
51     loader.loadMore()
52 }
53
54 // MergingLoader merges results from multiple loaders (given in the
55 // config.children array) into a single result set.
56 //
57 // new MergingLoader({children: [loader, loader, ...]})
58 //
59 // The children must retrieve results in "modified_at desc" order.
60 window.models = window.models || {}
61 window.models.MergingLoader = function(config) {
62     var loader = this
63     Object.assign(loader, config, {
64         // Sorted items ready to display, merged from all children.
65         items: m.stream(),
66         done: false,
67         loading: false,
68         loadable: function() {
69             // Return an array of children that we could call
70             // loadMore() on. Update loader.done and loader.loading.
71             loader.done = true
72             loader.loading = false
73             return loader.children.filter(function(child) {
74                 if (child.done)
75                     return false
76                 loader.done = false
77                 if (!child.loading)
78                     return true
79                 loader.loading = true
80                 return false
81             })
82         },
83         loadMore: function() {
84             // Call loadMore() on children that have reached
85             // lowWaterMark.
86             loader.loadable().map(function(child) {
87                 if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
88                     loader.loading = true
89                     child.loadMore()
90                 }
91             })
92         },
93         mergeItems: function() {
94             // cutoff is the topmost (recent) of {bottom (oldest) entry of
95             // any child that still has more pages left to fetch}
96             var cutoff
97             loader.children.forEach(function(child) {
98                 var items = child.items()
99                 if (items.length == 0 || child.done)
100                     return
101                 var last = items[items.length-1].modified_at
102                 if (!cutoff || cutoff < last)
103                     cutoff = last
104             })
105             var combined = []
106             loader.children.forEach(function(child) {
107                 child.itemsDisplayed = 0
108                 child.items().every(function(item) {
109                     if (cutoff && item.modified_at < cutoff)
110                         // Some other children haven't caught up to this
111                         // point, so don't display this item or anything
112                         // after it.
113                         return false
114                     combined.push(item)
115                     child.itemsDisplayed++
116                     return true // continue
117                 })
118             })
119             loader.items(combined.sort(function(a, b) {
120                 return a.modified_at < b.modified_at ? 1 : -1
121             }))
122         },
123         // Number of undisplayed items to keep on hand for each result
124         // set. When hitting "load more", if a result set already has
125         // this many additional results available, we don't bother
126         // fetching a new page. This is the _minimum_ number of rows
127         // that will be added to loader.items in each "load more"
128         // event (except for the case where all items are displayed).
129         lowWaterMark: 23,
130     })
131     var childrenReady = m.stream.merge(loader.children.map(function(child) {
132         return child.items
133     }))
134     childrenReady.map(loader.loadable)
135     childrenReady.map(loader.mergeItems)
136 }