Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addon/components/model-select.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
97 changes: 80 additions & 17 deletions addon/components/model-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -201,20 +207,83 @@ 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);

if (term) {
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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 3 additions & 5 deletions addon/components/model-select/options.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<ul role="listbox" ...attributes>
<ul role="listbox" {{did-insert this.watchForScrollEnd}} ...attributes>
<li>
<PowerSelect::Options
@loadingMessage={{@loadingMessage}}
Expand All @@ -16,10 +16,8 @@
</li>

{{#if this.showLoader}}
<li>
<InfinityLoader @infinityModel={{@infiniteModel}} @hideOnInfinity={{true}} @scrollable={{concat "#ember-basic-dropdown-content-" @select.uniqueId}}>
<ModelSelect::Spinner />
</InfinityLoader>
<li class="ember-model-select__loading-more">
<ModelSelect::Spinner />
</li>
{{/if}}
</ul>
55 changes: 52 additions & 3 deletions addon/components/model-select/options.js
Original file line number Diff line number Diff line change
@@ -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 `<InfinityLoader>` 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;
}
}
}
8 changes: 8 additions & 0 deletions addon/styles/components/ember-model-select.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
113 changes: 113 additions & 0 deletions tests/integration/components/model-select-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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 `<InfinityLoader>` 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`<ModelSelect @modelName="driver" @optionLabel="name" @pageSize={{25}} />`);
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`<ModelSelect @modelName="driver" @optionLabel="name" @pageSize={{25}} />`);
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`<ModelSelect @modelName="driver" @optionLabel="name" @pageSize={{25}} />`);
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`<ModelSelect @modelName="driver" @optionLabel="name" @infiniteScroll={{false}} @pageSize={{25}} />`);
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`<ModelSelect @modelName="driver" @optionLabel="name" @pageSize={{25}} />`);
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`<ModelSelect @modelName="driver" @optionLabel="name" @pageSize={{25}} />`);
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();
});
});
});
Loading