Merge branch 'master' into 12033-multisite-search
[arvados.git] / apps / workbench / app / assets / javascripts / models / loader.js
index 17b2ad58610070e292726f786360e5fad1d50ba0..0b29de68dea7c6f8205365cb377e547e37eb3d24 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-window.models = window.models || {}
-window.models.Pager = function(loadFunc) {
-    // loadFunc(filters) must return a promise for a page of results.
-    var pager = this
-    Object.assign(pager, {
-        done: false,
-        items: m.stream(),
+// MultipageLoader retrieves a multi-page result set from the
+// server. The constructor initiates the first page load.
+//
+// config.loadFunc is a function that accepts an array of
+// paging-related filters, and returns a promise for the API
+// response. loadFunc() must retrieve results in "modified_at desc"
+// order.
+//
+// state is:
+// * 'loading' if a network request is in progress;
+// * 'done' if there are no more items to load;
+// * 'ready' otherwise.
+//
+// items is a stream that resolves to an array of all items retrieved so far.
+//
+// loadMore() loads the next page, if any.
+window.MultipageLoader = function(config) {
+    var loader = this
+    Object.assign(loader, config, {
+        state: 'ready',
+        DONE: 'done',
+        LOADING: 'loading',
+        READY: 'ready',
+
+        items: m.stream([]),
         thresholdItem: null,
         loadMore: function() {
-            // Get the next page, if there are any more items to get.
-            if (pager.done)
+            if (loader.state == loader.DONE || loader.state == loader.LOADING)
                 return
-            var filters = pager.thresholdItem ? [
-                ["modified_at", "<=", pager.thresholdItem.modified_at],
-                ["uuid", "!=", pager.thresholdItem.uuid],
+            var filters = loader.thresholdItem ? [
+                ["modified_at", "<=", loader.thresholdItem.modified_at],
+                ["uuid", "!=", loader.thresholdItem.uuid],
             ] : []
-            loadFunc(filters).then(function(resp) {
-                var items = pager.items() || []
+            loader.state = loader.LOADING
+            loader.loadFunc(filters).then(function(resp) {
+                var items = loader.items()
                 Array.prototype.push.apply(items, resp.items)
-                if (resp.items.length == 0)
-                    pager.done = true
-                else
-                    pager.thresholdItem = resp.items[resp.items.length-1]
-                pager.items(items)
+                if (resp.items.length == 0) {
+                    loader.state = loader.DONE
+                } else {
+                    loader.thresholdItem = resp.items[resp.items.length-1]
+                    loader.state = loader.READY
+                }
+                loader.items(items)
+            }).catch(function(err) {
+                loader.err = err
+                loader.state = loader.READY
             })
         },
     })
+    loader.loadMore()
 }
 
-// MultisiteLoader loads pages of results from multiple API sessions
-// and merges them into a single result set.
+// MergingLoader merges results from multiple loaders (given in the
+// config.children array) into a single result set.
 //
-// The constructor implicitly starts an initial page load for each
-// session.
+// new MergingLoader({children: [loader, loader, ...]})
 //
-// new MultisiteLoader({loadFunc: function(session, filters){...},
-// sessionDB: new window.models.SessionDB()}
-//
-// At any given time, ml.loadMore will be either false (meaning a page
-// load is in progress or there are no more results to fetch) or a
-// function that starts loading more results.
-//
-// loadFunc() must retrieve results in "modified_at desc" order.
-window.models = window.models || {}
-window.models.MultisiteLoader = function(config) {
+// The children must retrieve results in "modified_at desc" order.
+window.MergingLoader = function(config) {
     var loader = this
-    if (!(config.loadFunc && config.sessionDB))
-        throw new Error("MultisiteLoader constructor requires loadFunc and sessionDB")
     Object.assign(loader, config, {
-        // Sorted items ready to display, merged from all pagers.
-        displayable: [],
-        done: false,
-        pagers: {},
-        loadMore: false,
+        // Sorted items ready to display, merged from all children.
+        items: m.stream([]),
+        state: 'ready',
+        DONE: 'done',
+        LOADING: 'loading',
+        READY: 'ready',
+        loadable: function() {
+            // Return an array of children that we could call
+            // loadMore() on. Update loader.state.
+            loader.state = loader.DONE
+            return loader.children.filter(function(child) {
+                if (child.state == child.DONE)
+                    return false
+                if (child.state == child.LOADING) {
+                    loader.state = loader.LOADING
+                    return false
+                }
+                if (loader.state == loader.DONE)
+                    loader.state = loader.READY
+                return true
+            })
+        },
+        loadMore: function() {
+            // Call loadMore() on children that have reached
+            // lowWaterMark.
+            loader.loadable().map(function(child) {
+                if (child.items().length - child.itemsDisplayed < loader.lowWaterMark) {
+                    loader.state = loader.LOADING
+                    child.loadMore()
+                }
+            })
+        },
+        mergeItems: function() {
+            // We want to avoid moving items around on the screen once
+            // they're displayed.
+            //
+            // To this end, here we find the last safely displayable
+            // item ("cutoff") by getting the last item from each
+            // unfinished child, and taking the topmost (most recent)
+            // one of those.
+            //
+            // (If we were to display an item below that cutoff, the
+            // next page of results from an unfinished child could
+            // include items that get inserted above the cutoff,
+            // causing the cutoff item to move down.)
+            var cutoff
+            var cutoffUnknown = false
+            loader.children.forEach(function(child) {
+                if (child.state == child.DONE)
+                    return
+                var items = child.items()
+                if (items.length == 0) {
+                    // No idea what's coming in the next page.
+                    cutoffUnknown = true
+                    return
+                }
+                var last = items[items.length-1].modified_at
+                if (!cutoff || cutoff < last)
+                    cutoff = last
+            })
+            if (cutoffUnknown)
+                return
+            var combined = []
+            loader.children.forEach(function(child) {
+                child.itemsDisplayed = 0
+                child.items().every(function(item) {
+                    if (cutoff && item.modified_at < cutoff)
+                        // Don't display this item or anything after
+                        // it (see "cutoff" comment above).
+                        return false
+                    combined.push(item)
+                    child.itemsDisplayed++
+                    return true // continue
+                })
+            })
+            loader.items(combined.sort(function(a, b) {
+                return a.modified_at < b.modified_at ? 1 : -1
+            }))
+        },
         // Number of undisplayed items to keep on hand for each result
         // set. When hitting "load more", if a result set already has
         // this many additional results available, we don't bother
         // fetching a new page. This is the _minimum_ number of rows
-        // that will be added to loader.displayable in each "load
-        // more" event (except for the case where all items are
-        // displayed).
+        // that will be added to loader.items in each "load more"
+        // event (except for the case where all items are displayed).
         lowWaterMark: 23,
     })
-    var sessions = loader.sessionDB.loadActive()
-    m.stream.merge(Object.keys(sessions).map(function(key) {
-        var pager = new window.models.Pager(loader.loadFunc.bind(null, sessions[key]))
-        loader.pagers[key] = pager
-        pager.loadMore()
-        // Resolve the stream with the session key when the results
-        // arrive.
-        return pager.items.map(function() { return key })
-    })).map(function(keys) {
-        // Top (most recent) of {bottom (oldest) entry of any pager
-        // that still has more pages left to fetch}
-        var cutoff
-        keys.forEach(function(key) {
-            var pager = loader.pagers[key]
-            var items = pager.items()
-            if (items.length == 0 || pager.done)
-                return
-            var last = items[items.length-1].modified_at
-            if (!cutoff || cutoff < last)
-                cutoff = last
-        })
-        var combined = []
-        keys.forEach(function(key) {
-            var pager = loader.pagers[key]
-            pager.itemsDisplayed = 0
-            pager.items().every(function(item) {
-                if (cutoff && item.modified_at < cutoff)
-                    // Some other pagers haven't caught up to this
-                    // point, so don't display this item or anything
-                    // after it.
-                    return false
-                item.session = sessions[key]
-                combined.push(item)
-                pager.itemsDisplayed++
-                return true // continue
-            })
-        })
-        loader.displayable = combined.sort(function(a, b) {
-            return a.modified_at < b.modified_at ? 1 : -1
-        })
-        // Make a new loadMore function that hits the pagers (if
-        // necessary according to lowWaterMark)... or set
-        // loader.loadMore to false if there is nothing left to fetch.
-        var loadable = []
-        Object.keys(loader.pagers).map(function(key) {
-            if (!loader.pagers[key].done)
-                loadable.push(loader.pagers[key])
-        })
-        if (loadable.length == 0) {
-            loader.done = true
-            loader.loadMore = false
-        } else
-            loader.loadMore = function() {
-                loader.loadMore = false
-                loadable.map(function(pager) {
-                    if (pager.items().length - pager.itemsDisplayed < loader.lowWaterMark)
-                        pager.loadMore()
-                })
-            }
-    })
+    var childrenReady = m.stream.merge(loader.children.map(function(child) {
+        return child.items
+    }))
+    childrenReady.map(loader.loadable)
+    childrenReady.map(loader.mergeItems)
 }