X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/16231ef0f56005b487cde9a7fb7dfb62952205a6..65f13986f98a75f8da7cfe695ea5960ff741d402:/apps/workbench/app/assets/javascripts/models/loader.js diff --git a/apps/workbench/app/assets/javascripts/models/loader.js b/apps/workbench/app/assets/javascripts/models/loader.js index 0a3181bf2d..0b29de68de 100644 --- a/apps/workbench/app/assets/javascripts/models/loader.js +++ b/apps/workbench/app/assets/javascripts/models/loader.js @@ -2,127 +2,158 @@ // // 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.loadAll() - 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) }