20259: Add documentation for banner and tooltip features
[arvados.git] / apps / workbench / app / assets / javascripts / models / loader.js
index 2e517b27cc86161dfea5eedf9ac46cb2bf4a560b..0b29de68dea7c6f8205365cb377e547e37eb3d24 100644 (file)
 // response. loadFunc() must retrieve results in "modified_at desc"
 // order.
 //
-// done is true if there are no more pages to load.
-//
-// loading is true if a network request is in progress.
+// 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.models = window.models || {}
-window.models.MultipageLoader = function(config) {
+window.MultipageLoader = function(config) {
     var loader = this
     Object.assign(loader, config, {
-        done: false,
-        loading: false,
-        items: m.stream(),
+        state: 'ready',
+        DONE: 'done',
+        LOADING: 'loading',
+        READY: 'ready',
+
+        items: m.stream([]),
         thresholdItem: null,
         loadMore: function() {
-            if (loader.done || loader.loading)
+            if (loader.state == loader.DONE || loader.state == loader.LOADING)
                 return
             var filters = loader.thresholdItem ? [
                 ["modified_at", "<=", loader.thresholdItem.modified_at],
                 ["uuid", "!=", loader.thresholdItem.uuid],
             ] : []
-            loader.loading = true
+            loader.state = loader.LOADING
             loader.loadFunc(filters).then(function(resp) {
-                var items = loader.items() || []
+                var items = loader.items()
                 Array.prototype.push.apply(items, resp.items)
-                if (resp.items.length == 0)
-                    loader.done = true
-                else
+                if (resp.items.length == 0) {
+                    loader.state = loader.DONE
+                } else {
                     loader.thresholdItem = resp.items[resp.items.length-1]
-                loader.loading = false
+                    loader.state = loader.READY
+                }
                 loader.items(items)
             }).catch(function(err) {
                 loader.err = err
-                loader.loading = false
+                loader.state = loader.READY
             })
         },
     })
     loader.loadMore()
 }
 
-// MultisiteLoader loads pages of results from multiple API sessions
-// and merges them into a single result set.
-//
-// The constructor implicitly starts an initial page load for each
-// session.
+// MergingLoader merges results from multiple loaders (given in the
+// config.children array) into a single result set.
 //
-// new MultisiteLoader({loadFunc: function(session, filters){...},
-// sessionDB: new window.models.SessionDB()}
+// new MergingLoader({children: [loader, loader, ...]})
 //
-// loadFunc() must retrieve results in "modified_at desc" order.
-//
-// (TODO? This could split into two parts: "make a loader for each
-// session, attaching session to each returned item", and "merge items
-// from N loaders".)
-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, {
-        sessions: config.sessionDB.loadActive(),
         // Sorted items ready to display, merged from all children.
-        items: m.stream(),
-        done: false,
-        children: {},
-        loading: false,
+        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.done and loader.loading.
-            loader.done = true
-            loader.loading = false
-            return Object.keys(loader.children)
-                .map(function(key) { return loader.children[key] })
-                .filter(function(child) {
-                    if (child.done)
-                        return false
-                    loader.done = false
-                    if (!child.loading)
-                        return true
-                    loader.loading = true
+            // 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.loading = true
+                    loader.state = loader.LOADING
                     child.loadMore()
                 }
             })
         },
         mergeItems: function() {
-            var keys = Object.keys(loader.sessions)
-            // cutoff is the topmost (recent) of {bottom (oldest) entry of
-            // any child that still has more pages left to fetch}
+            // 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
-            keys.forEach(function(key) {
-                var child = loader.children[key]
+            var cutoffUnknown = false
+            loader.children.forEach(function(child) {
+                if (child.state == child.DONE)
+                    return
                 var items = child.items()
-                if (items.length == 0 || child.done)
+                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 = []
-            keys.forEach(function(key) {
-                var child = loader.children[key]
+            loader.children.forEach(function(child) {
                 child.itemsDisplayed = 0
                 child.items().every(function(item) {
                     if (cutoff && item.modified_at < cutoff)
-                        // Some other children haven't caught up to this
-                        // point, so don't display this item or anything
-                        // after it.
+                        // Don't display this item or anything after
+                        // it (see "cutoff" comment above).
                         return false
-                    item.session = loader.sessions[key]
                     combined.push(item)
                     child.itemsDisplayed++
                     return true // continue
@@ -146,16 +151,9 @@ window.models.MultisiteLoader = function(config) {
         // event (except for the case where all items are displayed).
         lowWaterMark: 23,
     })
-    var childrenItems = Object.keys(loader.sessions).map(function(key) {
-        var child = new window.models.MultipageLoader({
-            loadFunc: loader.loadFunc.bind(null, loader.sessions[key]),
-        })
-        loader.children[key] = child
-        // Resolve with the session key whenever results arrive for
-        // that session.
+    var childrenReady = m.stream.merge(loader.children.map(function(child) {
         return child.items
-    })
-    var childrenReady = m.stream.merge(childrenItems)
+    }))
     childrenReady.map(loader.loadable)
     childrenReady.map(loader.mergeItems)
 }