Fix 2.4.2 upgrade notes formatting refs #19330
[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 // state is:
14 // * 'loading' if a network request is in progress;
15 // * 'done' if there are no more items to load;
16 // * 'ready' otherwise.
17 //
18 // items is a stream that resolves to an array of all items retrieved so far.
19 //
20 // loadMore() loads the next page, if any.
21 window.MultipageLoader = function(config) {
22     var loader = this
23     Object.assign(loader, config, {
24         state: 'ready',
25         DONE: 'done',
26         LOADING: 'loading',
27         READY: 'ready',
28
29         items: m.stream([]),
30         thresholdItem: null,
31         loadMore: function() {
32             if (loader.state == loader.DONE || loader.state == loader.LOADING)
33                 return
34             var filters = loader.thresholdItem ? [
35                 ["modified_at", "<=", loader.thresholdItem.modified_at],
36                 ["uuid", "!=", loader.thresholdItem.uuid],
37             ] : []
38             loader.state = loader.LOADING
39             loader.loadFunc(filters).then(function(resp) {
40                 var items = loader.items()
41                 Array.prototype.push.apply(items, resp.items)
42                 if (resp.items.length == 0) {
43                     loader.state = loader.DONE
44                 } else {
45                     loader.thresholdItem = resp.items[resp.items.length-1]
46                     loader.state = loader.READY
47                 }
48                 loader.items(items)
49             }).catch(function(err) {
50                 loader.err = err
51                 loader.state = loader.READY
52             })
53         },
54     })
55     loader.loadMore()
56 }
57
58 // MergingLoader merges results from multiple loaders (given in the
59 // config.children array) into a single result set.
60 //
61 // new MergingLoader({children: [loader, loader, ...]})
62 //
63 // The children must retrieve results in "modified_at desc" order.
64 window.MergingLoader = function(config) {
65     var loader = this
66     Object.assign(loader, config, {
67         // Sorted items ready to display, merged from all children.
68         items: m.stream([]),
69         state: 'ready',
70         DONE: 'done',
71         LOADING: 'loading',
72         READY: 'ready',
73         loadable: function() {
74             // Return an array of children that we could call
75             // loadMore() on. Update loader.state.
76             loader.state = loader.DONE
77             return loader.children.filter(function(child) {
78                 if (child.state == child.DONE)
79                     return false
80                 if (child.state == child.LOADING) {
81                     loader.state = loader.LOADING
82                     return false
83                 }
84                 if (loader.state == loader.DONE)
85                     loader.state = loader.READY
86                 return true
87             })
88         },
89         loadMore: function() {
90             // Call loadMore() on children that have reached
91             // lowWaterMark.
92             loader.loadable().map(function(child) {
93                 if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
94                     loader.state = loader.LOADING
95                     child.loadMore()
96                 }
97             })
98         },
99         mergeItems: function() {
100             // We want to avoid moving items around on the screen once
101             // they're displayed.
102             //
103             // To this end, here we find the last safely displayable
104             // item ("cutoff") by getting the last item from each
105             // unfinished child, and taking the topmost (most recent)
106             // one of those.
107             //
108             // (If we were to display an item below that cutoff, the
109             // next page of results from an unfinished child could
110             // include items that get inserted above the cutoff,
111             // causing the cutoff item to move down.)
112             var cutoff
113             var cutoffUnknown = false
114             loader.children.forEach(function(child) {
115                 if (child.state == child.DONE)
116                     return
117                 var items = child.items()
118                 if (items.length == 0) {
119                     // No idea what's coming in the next page.
120                     cutoffUnknown = true
121                     return
122                 }
123                 var last = items[items.length-1].modified_at
124                 if (!cutoff || cutoff < last)
125                     cutoff = last
126             })
127             if (cutoffUnknown)
128                 return
129             var combined = []
130             loader.children.forEach(function(child) {
131                 child.itemsDisplayed = 0
132                 child.items().every(function(item) {
133                     if (cutoff && item.modified_at < cutoff)
134                         // Don't display this item or anything after
135                         // it (see "cutoff" comment above).
136                         return false
137                     combined.push(item)
138                     child.itemsDisplayed++
139                     return true // continue
140                 })
141             })
142             loader.items(combined.sort(function(a, b) {
143                 return a.modified_at < b.modified_at ? 1 : -1
144             }))
145         },
146         // Number of undisplayed items to keep on hand for each result
147         // set. When hitting "load more", if a result set already has
148         // this many additional results available, we don't bother
149         // fetching a new page. This is the _minimum_ number of rows
150         // that will be added to loader.items in each "load more"
151         // event (except for the case where all items are displayed).
152         lowWaterMark: 23,
153     })
154     var childrenReady = m.stream.merge(loader.children.map(function(child) {
155         return child.items
156     }))
157     childrenReady.map(loader.loadable)
158     childrenReady.map(loader.mergeItems)
159 }