12033: MultipageLoader and MultisiteLoader offer the same interface.
[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 // MultisiteLoader loads pages of results from multiple API sessions
55 // and merges them into a single result set.
56 //
57 // The constructor implicitly starts an initial page load for each
58 // session.
59 //
60 // new MultisiteLoader({loadFunc: function(session, filters){...},
61 // sessionDB: new window.models.SessionDB()}
62 //
63 // loadFunc() must retrieve results in "modified_at desc" order.
64 //
65 // (TODO? This could split into two parts: "make a loader for each
66 // session, attaching session to each returned item", and "merge items
67 // from N loaders".)
68 window.models = window.models || {}
69 window.models.MultisiteLoader = function(config) {
70     var loader = this
71     if (!(config.loadFunc && config.sessionDB))
72         throw new Error("MultisiteLoader constructor requires loadFunc and sessionDB")
73     Object.assign(loader, config, {
74         sessions: config.sessionDB.loadActive(),
75         // Sorted items ready to display, merged from all children.
76         items: m.stream(),
77         done: false,
78         children: {},
79         loading: false,
80         loadable: function() {
81             // Return an array of children that we could call
82             // loadMore() on. Update loader.done and loader.loading.
83             loader.done = true
84             loader.loading = false
85             return Object.keys(loader.children)
86                 .map(function(key) { return loader.children[key] })
87                 .filter(function(child) {
88                     if (child.done)
89                         return false
90                     loader.done = false
91                     if (!child.loading)
92                         return true
93                     loader.loading = true
94                     return false
95                 })
96         },
97         loadMore: function() {
98             // Call loadMore() on children that have reached
99             // lowWaterMark.
100             loader.loadable().map(function(child) {
101                 if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
102                     loader.loading = true
103                     child.loadMore()
104                 }
105             })
106         },
107         mergeItems: function() {
108             var keys = Object.keys(loader.sessions)
109             // cutoff is the topmost (recent) of {bottom (oldest) entry of
110             // any child that still has more pages left to fetch}
111             var cutoff
112             keys.forEach(function(key) {
113                 var child = loader.children[key]
114                 var items = child.items()
115                 if (items.length == 0 || child.done)
116                     return
117                 var last = items[items.length-1].modified_at
118                 if (!cutoff || cutoff < last)
119                     cutoff = last
120             })
121             var combined = []
122             keys.forEach(function(key) {
123                 var child = loader.children[key]
124                 child.itemsDisplayed = 0
125                 child.items().every(function(item) {
126                     if (cutoff && item.modified_at < cutoff)
127                         // Some other children haven't caught up to this
128                         // point, so don't display this item or anything
129                         // after it.
130                         return false
131                     item.session = loader.sessions[key]
132                     combined.push(item)
133                     child.itemsDisplayed++
134                     return true // continue
135                 })
136             })
137             loader.items(combined.sort(function(a, b) {
138                 return a.modified_at < b.modified_at ? 1 : -1
139             }))
140         },
141         // Number of undisplayed items to keep on hand for each result
142         // set. When hitting "load more", if a result set already has
143         // this many additional results available, we don't bother
144         // fetching a new page. This is the _minimum_ number of rows
145         // that will be added to loader.items in each "load more"
146         // event (except for the case where all items are displayed).
147         lowWaterMark: 23,
148     })
149     var childrenItems = Object.keys(loader.sessions).map(function(key) {
150         var child = new window.models.MultipageLoader({
151             loadFunc: loader.loadFunc.bind(null, loader.sessions[key]),
152         })
153         loader.children[key] = child
154         // Resolve with the session key whenever results arrive for
155         // that session.
156         return child.items
157     })
158     var childrenReady = m.stream.merge(childrenItems)
159     childrenReady.map(loader.loadable)
160     childrenReady.map(loader.mergeItems)
161 }