diff --git a/addon/components/model-select.hbs b/addon/components/model-select.hbs
index e1eb98c5..46091936 100644
--- a/addon/components/model-select.hbs
+++ b/addon/components/model-select.hbs
@@ -34,7 +34,7 @@
@onKeydown={{@onKeydown}}
@onOpen={{this.onOpen}}
@options={{this._options}}
- @optionsComponent={{component this.optionsComponent infiniteScroll=this.infiniteScroll infiniteModel=this.model withCreate=this.withCreate}}
+ @optionsComponent={{component this.optionsComponent infiniteScroll=this.infiniteScroll onLoadMore=this.onLoadMore isLoadingMore=this.loadMoreOptions.isRunning withCreate=this.withCreate}}
@placeholder={{@placeholder}}
@placeholderComponent={{@placeholderComponent}}
@preventScroll={{@preventScroll}}
diff --git a/addon/components/model-select.js b/addon/components/model-select.js
index ee41ce0c..9c4295dd 100644
--- a/addon/components/model-select.js
+++ b/addon/components/model-select.js
@@ -128,8 +128,14 @@ export default class ModelSelectComponent extends Component {
*/
@tracked _options;
- @tracked model;
@tracked selectedModel;
+
+ /** Paging state for infinite scroll. */
+ @tracked page = 1;
+ @tracked hasMoreOptions = false;
+
+ /** The term the current option list was loaded for, so later pages repeat the same search. */
+ lastTerm = null;
@tracked permissionRequired = null;
@tracked disabled = false;
@tracked doesntHavePermissions = false;
@@ -201,6 +207,45 @@ export default class ModelSelectComponent extends Component {
return;
}
+ this.lastTerm = term;
+ this.page = 1;
+
+ const query = this.queryFor(term, 1);
+ const _options = yield this.fetchPage(query);
+
+ this.hasMoreOptions = this.hasMoreAfter(_options, _options.length);
+
+ this._options = createOption
+ ? // Plain assignment, not `unshiftObjects`: on the customSearchEndpoint path the
+ // results are a plain array from `results.map(...)` with no Ember array methods.
+ [createOption, ...this.toPlainArray(_options)]
+ : _options;
+ };
+
+ /**
+ * Appends the next page to the visible options. Driven by the options component when the
+ * dropdown is scrolled to the bottom.
+ */
+ @dropTask loadMoreOptions = function* () {
+ // `hasMoreOptions` is only ever set by a loadModels run that got past its own
+ // permission/disabled guard, so re-checking those here would be dead code.
+ if (!this.infiniteScroll || !this.hasMoreOptions) {
+ return;
+ }
+
+ const nextPage = this.page + 1;
+
+ // Keep the raw result: `toPlainArray` drops the `meta` the server reports its total in.
+ const raw = yield this.fetchPage(this.queryFor(this.lastTerm, nextPage));
+ const results = this.toPlainArray(raw);
+
+ this.page = nextPage;
+ this._options = [...this.toPlainArray(this._options), ...results];
+ this.hasMoreOptions = results.length > 0 && this.hasMoreAfter(raw, this._options.length);
+ };
+
+ /** The query for one page, built the same way whichever page it is. */
+ queryFor(term, page) {
// query might be an EmptyObject/{{hash}}, make it a normal Object
const query = Object.assign({}, this.args.query);
@@ -208,13 +253,37 @@ export default class ModelSelectComponent extends Component {
set(query, 'query', term);
}
- let _options;
+ set(query, this.pageParam, page);
+ set(query, this.perPageParam, this.pageSize);
+ return query;
+ }
+
+ /** Ember arrays, plain arrays and the custom endpoint's `results.map(...)` all arrive here. */
+ toPlainArray(options) {
+ return typeof options.toArray === 'function' ? options.toArray() : [...options];
+ }
+
+ /**
+ * Whether another page is worth asking for. Prefers the total the server reported; falls back
+ * to "the server filled the page", which is all the custom endpoint gives us.
+ */
+ hasMoreAfter(results, loadedCount) {
+ const total = get(results, this.totalPagesParam);
+
+ if (typeof total === 'number') {
+ return loadedCount < total;
+ }
+
+ return this.toPlainArray(results).length >= this.pageSize;
+ }
+
+ fetchPage(query) {
if (typeof this.args.customSearchEndpoint === 'string') {
- const customQuery = (endpoint, query, options = {}) => {
+ const customQuery = (endpoint, params, options = {}) => {
return new Promise((resolve) => {
this.fetch
- .get(endpoint, query, options)
+ .get(endpoint, params, options)
.then((results) => {
let records = results.map((result) => {
let modelName = this.args.modelName;
@@ -242,22 +311,16 @@ export default class ModelSelectComponent extends Component {
});
};
- _options = yield customQuery(this.args.customSearchEndpoint, query);
- } else {
- set(query, this.pageParam, 1);
- set(query, this.perPageParam, this.pageSize);
-
- _options = yield this.source.query(this.args.modelName, query);
+ return customQuery(this.args.customSearchEndpoint, query);
}
- if (createOption) {
- // Plain assignment, not `unshiftObjects`: on the customSearchEndpoint path `_options`
- // is a plain array from `results.map(...)` and has no Ember array methods.
- _options = [createOption, ..._options];
- }
+ return this.source.query(this.args.modelName, query);
+ }
- this._options = _options;
- };
+ /** Handed to the options component, which calls it when the list is scrolled to the bottom. */
+ @action onLoadMore() {
+ this.loadMoreOptions.perform();
+ }
loadDefaultOptions() {
const { loadDefaultOptions } = this.args;
diff --git a/addon/components/model-select/options.hbs b/addon/components/model-select/options.hbs
index a7de9665..e16ab6ce 100644
--- a/addon/components/model-select/options.hbs
+++ b/addon/components/model-select/options.hbs
@@ -1,4 +1,4 @@
-
+
-
{{#if this.showLoader}}
-
-
-
-
-
+
-
+
{{/if}}
\ No newline at end of file
diff --git a/addon/components/model-select/options.js b/addon/components/model-select/options.js
index edb3c9fc..6c17f471 100644
--- a/addon/components/model-select/options.js
+++ b/addon/components/model-select/options.js
@@ -1,9 +1,58 @@
import Component from '@glimmer/component';
-import { computed } from '@ember/object';
+import { action } from '@ember/object';
+import { registerDestructor } from '@ember/destroyable';
+/** How close to the bottom of the list counts as "scrolled to the end", in pixels. */
+const LOAD_MORE_THRESHOLD = 32;
+
+/**
+ * The dropdown's option list, with infinite scroll.
+ *
+ * This used to render ember-infinity's `` against an `@infiniteModel` that was
+ * never assigned — and ember-infinity was not even a dependency, so the component would not have
+ * resolved had the loader ever been reached. Paging is native now: when the dropdown is scrolled
+ * to the bottom and there is another page to fetch, `@onLoadMore` is called.
+ */
export default class ModelSelectOptionsComponent extends Component {
- @computed('args.{infiniteScroll,infiniteModel,select.loading}')
+ scrollable = null;
+
+ constructor() {
+ super(...arguments);
+ registerDestructor(this, () => this.#stopWatching());
+ }
+
+ /** The spinner shows while a further page is on its way. */
get showLoader() {
- return this.args.infiniteScroll && this.args.infiniteModel && !this.args.select.loading;
+ return Boolean(this.args.infiniteScroll && this.args.isLoadingMore);
+ }
+
+ @action watchForScrollEnd(element) {
+ // ember-basic-dropdown scrolls its content element, not this list. power-select only ever
+ // renders this component inside that element, so in practice the lookup always resolves —
+ // the null guards below are defensive and are not reachable from a rendered dropdown.
+ this.scrollable = element.closest('.ember-basic-dropdown-content');
+
+ if (this.scrollable) {
+ this.scrollable.addEventListener('scroll', this.onScroll, { passive: true });
+ }
+ }
+
+ @action onScroll() {
+ if (!this.args.infiniteScroll || typeof this.args.onLoadMore !== 'function') {
+ return;
+ }
+
+ const { scrollTop, clientHeight, scrollHeight } = this.scrollable;
+
+ if (scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD) {
+ this.args.onLoadMore();
+ }
+ }
+
+ #stopWatching() {
+ if (this.scrollable) {
+ this.scrollable.removeEventListener('scroll', this.onScroll);
+ this.scrollable = null;
+ }
}
}
diff --git a/addon/styles/components/ember-model-select.css b/addon/styles/components/ember-model-select.css
index 29fe7426..48775e56 100644
--- a/addon/styles/components/ember-model-select.css
+++ b/addon/styles/components/ember-model-select.css
@@ -25,6 +25,14 @@
animation: ember-model-select-spin 1s infinite linear;
}
+/* The row that carries the spinner while a further page of options is being fetched. */
+.ember-model-select__loading-more {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 0;
+}
+
.ember-model-select__spinner > circle {
stroke-opacity: 0.1;
}
diff --git a/tests/integration/components/model-select-test.js b/tests/integration/components/model-select-test.js
index 530abb8f..cb43c907 100644
--- a/tests/integration/components/model-select-test.js
+++ b/tests/integration/components/model-select-test.js
@@ -5,6 +5,7 @@ import { hbs } from 'ember-cli-htmlbars';
import Service from '@ember/service';
import { A } from '@ember/array';
import { selectChoose, selectSearch, getDropdownItems } from 'ember-power-select/test-support';
+import { clickTrigger } from 'ember-power-select/test-support/helpers';
const DRIVERS = [
{ id: 'drv_1', name: 'Alex Driver' },
@@ -423,4 +424,116 @@ module('Integration | Component | model-select', function (hooks) {
assert.ok(find('.fleetbase-model-select'), 'no handler is required');
});
});
+ // Infinite scroll never worked: `@infiniteModel` was handed a `model` field that was declared
+ // and never assigned, and the `` it fed came from ember-infinity, which was
+ // not a dependency. Paging is native now.
+ module('infinite scroll', function () {
+ function page(n, size) {
+ return A(Array.from({ length: size }, (_, i) => ({ id: `drv_${n}_${i}`, name: `Driver ${n}-${i}` })));
+ }
+
+ // Dispatch only. Assigning scrollTop fires its own native scroll event, so doing both
+ // would trigger the loader twice per call.
+ async function scrollToBottom() {
+ find('.ember-basic-dropdown-content').dispatchEvent(new Event('scroll'));
+ await settled();
+ }
+
+ test('a full first page is followed by a second when the list is scrolled to the end', async function (assert) {
+ respondWith = (modelName, query) => page(query.page, 25);
+
+ await render(hbs``);
+ await clickTrigger();
+
+ assert.strictEqual(queries.length, 1, 'the first page is requested on open');
+ assert.strictEqual(queries[0].query.page, 1);
+ assert.strictEqual(queries[0].query.limit, 25, 'with the configured page size');
+
+ await scrollToBottom();
+
+ assert.strictEqual(queries.length, 2, 'reaching the bottom asks for the next page');
+ assert.strictEqual(queries[1].query.page, 2, 'and it is the page after');
+ assert.strictEqual(findAll('.ember-power-select-option').length, 50, 'the results are appended, not replaced');
+ });
+
+ test('a short page means there is nothing more to ask for', async function (assert) {
+ respondWith = () => page(1, 3);
+
+ await render(hbs``);
+ await clickTrigger();
+ await scrollToBottom();
+
+ assert.strictEqual(queries.length, 1, 'a page that did not fill is the last one');
+ });
+
+ test('the reported total decides when to stop', async function (assert) {
+ respondWith = (modelName, query) => {
+ const records = page(query.page, 25);
+ records.meta = { total: 30 };
+ return records;
+ };
+
+ await render(hbs``);
+ await clickTrigger();
+ await scrollToBottom();
+
+ assert.strictEqual(queries.length, 2, '25 of 30 loaded, so one more page is fetched');
+
+ await scrollToBottom();
+
+ assert.strictEqual(queries.length, 2, 'and once past the total, no further requests');
+ });
+
+ test('@infiniteScroll={{false}} never asks for another page', async function (assert) {
+ respondWith = (modelName, query) => page(query.page, 25);
+
+ await render(hbs``);
+ await clickTrigger();
+ await scrollToBottom();
+
+ assert.strictEqual(queries.length, 1, 'paging is off');
+ });
+
+ test('a new search starts again from page one', async function (assert) {
+ respondWith = (modelName, query) => page(query.page, 25);
+
+ await render(hbs``);
+ await clickTrigger();
+ await scrollToBottom();
+ assert.strictEqual(queries[1].query.page, 2);
+
+ await selectSearch(TRIGGER, 'hauler');
+
+ const latest = queries[queries.length - 1];
+ assert.strictEqual(latest.query.page, 1, 'the new term is requested from the first page');
+ assert.strictEqual(latest.query.query, 'hauler', 'carrying the search term');
+
+ await scrollToBottom();
+
+ const afterScroll = queries[queries.length - 1];
+ assert.strictEqual(afterScroll.query.page, 2, 'and the next page keeps that term');
+ assert.strictEqual(afterScroll.query.query, 'hauler');
+ });
+
+ test('scrolling while a page is already in flight does not stack requests', async function (assert) {
+ let release;
+ respondWith = (modelName, query) => {
+ if (query.page === 1) return page(1, 25);
+ return new Promise((resolve) => (release = () => resolve(page(2, 25))));
+ };
+
+ await render(hbs``);
+ await clickTrigger();
+
+ const content = find('.ember-basic-dropdown-content');
+ content.dispatchEvent(new Event('scroll'));
+ content.dispatchEvent(new Event('scroll'));
+ content.dispatchEvent(new Event('scroll'));
+
+ assert.strictEqual(queries.length, 2, 'the task drops the overlapping scrolls');
+
+ release();
+ await settled();
+ });
+ });
});
diff --git a/tests/integration/components/model-select/options-test.js b/tests/integration/components/model-select/options-test.js
index 928d78cb..26bd9eb9 100644
--- a/tests/integration/components/model-select/options-test.js
+++ b/tests/integration/components/model-select/options-test.js
@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render, click, findAll, find } from '@ember/test-helpers';
+import { render, click, findAll, find, settled, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Service from '@ember/service';
import { A } from '@ember/array';
@@ -63,26 +63,25 @@ module('Integration | Component | model-select/options', function (hooks) {
);
});
- // DEFECT (see DEFECTS.md #105): showLoader is `infiniteScroll && infiniteModel && !select.loading`,
- // and passes `infiniteModel=this.model` — a @tracked property that is declared but
- // NEVER assigned anywhere in model-select.js. infiniteModel is therefore always undefined, so the
- // InfinityLoader branch of this component is unreachable and ModelSelect's infinite scroll is inert.
- test('the infinity loader never renders, even with infinite scroll on', async function (assert) {
+ // #105 used to live here: this component rendered ember-infinity's against an
+ // @infiniteModel that was never assigned (and ember-infinity was not a dependency), so the loader
+ // was unreachable and paging was inert. Paging is native now, so these assert the real thing.
+ test('nothing is loading when the list first opens', async function (assert) {
await render(hbs``);
await click(TRIGGER);
const items = findAll('ul[role="listbox"] > li');
assert.strictEqual(items.length, 1, 'only the options list item is rendered');
- assert.notOk(find('ul[role="listbox"] .fleetbase-loader'), 'no loader is rendered despite infiniteScroll defaulting to true');
+ assert.dom('.ember-model-select__loading-more').doesNotExist('no spinner while the list sits idle');
});
- test('no infinity loader is rendered when infinite scroll is off', async function (assert) {
+ test('no loading row is rendered when infinite scroll is off', async function (assert) {
await render(hbs``);
await click(TRIGGER);
const items = findAll('ul[role="listbox"] > li');
assert.strictEqual(items.length, 1, 'only the options list item is rendered');
- assert.notOk(find('ul[role="listbox"] .fleetbase-loader'), 'no loader is rendered');
+ assert.dom('.ember-model-select__loading-more').doesNotExist();
});
test('an empty result still renders the listbox', async function (assert) {
@@ -108,4 +107,108 @@ module('Integration | Component | model-select/options', function (hooks) {
assert.strictEqual(changes[0].id, 'drv_2');
assert.notOk(find('ul[role="listbox"]'), 'the list closed itself');
});
+ // The list is scrolled by ember-basic-dropdown's content element, which is what the component
+ // attaches its listener to. These drive that element directly.
+ module('scrolling the dropdown', function (hooks) {
+ let pages;
+
+ // A short page tells there is nothing more to fetch, which would make every
+ // assertion below unfailable. These serve a full page so paging stays live.
+ const FULL_PAGE = Array.from({ length: 25 }, (unused, index) => ({ id: `drv_${index}`, name: `Driver ${index}` }));
+
+ hooks.beforeEach(function () {
+ pages = [];
+ respondWith = (modelName, query) => {
+ pages.push(query.page);
+ return A(FULL_PAGE.slice());
+ };
+ });
+
+ function scrollContent({ top, clientHeight, scrollHeight }) {
+ const content = find('.ember-basic-dropdown-content');
+ Object.defineProperty(content, 'scrollTop', { value: top, configurable: true });
+ Object.defineProperty(content, 'clientHeight', { value: clientHeight, configurable: true });
+ Object.defineProperty(content, 'scrollHeight', { value: scrollHeight, configurable: true });
+ content.dispatchEvent(new Event('scroll'));
+ return settled();
+ }
+
+ test('a scroll short of the bottom asks for nothing', async function (assert) {
+ await render(hbs``);
+ await click(TRIGGER);
+ const afterOpen = pages.length;
+
+ await scrollContent({ top: 0, clientHeight: 100, scrollHeight: 1000 });
+
+ assert.strictEqual(pages.length, afterOpen, 'still 900px from the end, so no further page is fetched');
+ assert.strictEqual(pages.at(-1), 1, 'only the first page has been asked for');
+ });
+
+ test('a scroll within the threshold of the bottom fetches the next page', async function (assert) {
+ await render(hbs``);
+ await click(TRIGGER);
+ const afterOpen = pages.length;
+
+ await scrollContent({ top: 880, clientHeight: 100, scrollHeight: 1000 });
+
+ assert.strictEqual(pages.length, afterOpen + 1, '20px from the end is close enough');
+ assert.strictEqual(pages.at(-1), 2, 'and it is the second page that is asked for');
+ });
+
+ test('reaching the bottom of a complete list asks for nothing', async function (assert) {
+ // A short first page means the server has already sent everything, so hitting the end
+ // of it must not start a second request.
+ respondWith = (modelName, query) => {
+ pages.push(query.page);
+ return A(DRIVERS.slice());
+ };
+
+ await render(hbs``);
+ await click(TRIGGER);
+ const afterOpen = pages.length;
+
+ await scrollContent({ top: 900, clientHeight: 100, scrollHeight: 1000 });
+
+ assert.strictEqual(pages.length, afterOpen, 'the list is already complete');
+ });
+
+ test('the spinner shows while the next page is in flight, then goes away', async function (assert) {
+ // Hold the second page open so the loading state can be observed rather than raced past.
+ let releaseSecondPage;
+ respondWith = (modelName, query) => {
+ pages.push(query.page);
+
+ if (query.page === 1) {
+ return A(FULL_PAGE.slice());
+ }
+
+ return new Promise((resolve) => {
+ releaseSecondPage = () => resolve(A(DRIVERS.slice()));
+ });
+ };
+
+ await render(hbs``);
+ await click(TRIGGER);
+ assert.dom('.ember-model-select__loading-more').doesNotExist('nothing in flight yet');
+
+ const scrolled = scrollContent({ top: 880, clientHeight: 100, scrollHeight: 1000 });
+ await waitFor('.ember-model-select__loading-more');
+ assert.dom('.ember-model-select__loading-more .ember-model-select__spinner').exists('the spinner marks the page being fetched');
+
+ releaseSecondPage();
+ await scrolled;
+
+ assert.dom('.ember-model-select__loading-more').doesNotExist('and it goes once the page has landed');
+ });
+
+ test('with infinite scroll off, reaching the bottom fetches nothing', async function (assert) {
+ await render(hbs``);
+ await click(TRIGGER);
+ const afterOpen = pages.length;
+
+ await scrollContent({ top: 900, clientHeight: 100, scrollHeight: 1000 });
+
+ assert.strictEqual(pages.length, afterOpen, 'paging is off entirely');
+ });
+ });
});