Skip to content

Commit

Permalink
refactor: make combo-box use DataProviderController
Browse files Browse the repository at this point in the history
  • Loading branch information
vursen committed Dec 29, 2023
1 parent 7c01115 commit 3fbad4a
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 136 deletions.
229 changes: 109 additions & 120 deletions packages/combo-box/src/vaadin-combo-box-data-provider-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Copyright (c) 2015 - 2023 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { DataProviderController } from '@vaadin/component-base/src/data-provider-controller/data-provider-controller.js';
import { get } from '@vaadin/component-base/src/path-utils.js';
import { ComboBoxPlaceholder } from './vaadin-combo-box-placeholder.js';

/**
Expand Down Expand Up @@ -51,13 +53,6 @@ export const ComboBoxDataProviderMixin = (superClass) =>
observer: '_dataProviderChanged',
},

/** @private */
_pendingRequests: {
value: () => {
return {};
},
},

/** @private */
__placeHolder: {
value: new ComboBoxPlaceholder(),
Expand All @@ -78,10 +73,33 @@ export const ComboBoxDataProviderMixin = (superClass) =>
];
}

constructor() {
super();

/** @type {DataProviderController} */
this._dataProviderController = new DataProviderController(this, {
size: this.size,
pageSize: this.pageSize,
getItemId: (item) => get(this.itemIdPath, item),
placeholder: this.__placeHolder,
dataProvider: this.dataProvider ? this.dataProvider.bind(this) : null,
dataProviderParams: () => ({ filter: this.filter }),
});
}

/** @protected */
ready() {
super.ready();

this._dataProviderController.addEventListener('page-requested', this.__onDataProviderPageRequested.bind(this));
this._dataProviderController.addEventListener('page-received', this.__onDataProviderPageReceived.bind(this));
this._dataProviderController.addEventListener('page-loaded', this.__onDataProviderPageLoaded.bind(this));

this._scroller.addEventListener('index-requested', (e) => {
if (!this._shouldFetchData()) {
return;
}

const index = e.detail.index;
const currentScrollerPos = e.detail.currentScrollerPos;
const allowedIndexRange = Math.floor(this.pageSize * 1.5);
Expand All @@ -95,10 +113,7 @@ export const ComboBoxDataProviderMixin = (superClass) =>
}

if (index !== undefined) {
const page = this._getPageForIndex(index);
if (this._shouldLoadPage(page)) {
this._loadPage(page);
}
this._dataProviderController.ensureFlatIndexLoaded(index);
}
});
}
Expand All @@ -113,19 +128,14 @@ export const ComboBoxDataProviderMixin = (superClass) =>
if (this.__previousDataProviderFilter !== filter) {
this.__previousDataProviderFilter = filter;

this._pendingRequests = {};
// Immediately mark as loading if this refresh leads to re-fetching pages
// This prevents some issues with the properties below triggering
// observers that also rely on the loading state
this.loading = this._shouldFetchData();
// Reset size and internal loading state
this._keepOverlayOpened = true;
this.size = undefined;

this.clearCache();
this._keepOverlayOpened = false;
}
}

/** @private */
/** @protected */
_shouldFetchData() {
if (!this.dataProvider) {
return false;
Expand All @@ -136,8 +146,12 @@ export const ComboBoxDataProviderMixin = (superClass) =>

/** @private */
_ensureFirstPage(opened) {
if (opened && this._shouldLoadPage(0)) {
this._loadPage(0);
if (!this._shouldFetchData()) {
return;
}

if (opened && !this._dataProviderController.hasData) {
this._dataProviderController.loadFirstPage();
}
}

Expand All @@ -151,68 +165,24 @@ export const ComboBoxDataProviderMixin = (superClass) =>
}

/** @private */
_shouldLoadPage(page) {
if (!this.filteredItems || this._forceNextRequest) {
this._forceNextRequest = false;
return true;
}

const loadedItem = this.filteredItems[page * this.pageSize];
if (loadedItem !== undefined) {
return loadedItem instanceof ComboBoxPlaceholder;
}
return this.size === undefined;
__onDataProviderPageRequested() {
this.loading = true;
}

/** @private */
_loadPage(page) {
// Make sure same page isn't requested multiple times.
if (this._pendingRequests[page] || !this.dataProvider) {
return;
}

const params = {
page,
pageSize: this.pageSize,
filter: this.filter,
};

const callback = (items, size) => {
if (this._pendingRequests[page] !== callback) {
return;
}

const filteredItems = this.filteredItems ? [...this.filteredItems] : [];
filteredItems.splice(params.page * params.pageSize, items.length, ...items);
this.filteredItems = filteredItems;

if (!this.opened && !this._isInputFocused()) {
this._commitValue();
}

if (size !== undefined) {
this.size = size;
}

delete this._pendingRequests[page];

if (Object.keys(this._pendingRequests).length === 0) {
this.loading = false;
}
};

this._pendingRequests[page] = callback;
// Set the `loading` flag only after marking the request as pending
// to prevent the same page from getting requested multiple times
// as a result of `__loadingChanged` in the scroller which requests
// a virtualizer update which in turn may trigger a data provider page request.
this.loading = true;
this.dataProvider(params, callback);
__onDataProviderPageReceived() {
this.requestContentUpdate();
}

/** @private */
_getPageForIndex(index) {
return Math.floor(index / this.pageSize);
__onDataProviderPageLoaded() {
if (!this.opened && !this._isInputFocused()) {
this._commitValue();
}

if (!this._dataProviderController.isLoading()) {
this.loading = false;
}
}

/**
Expand All @@ -223,32 +193,74 @@ export const ComboBoxDataProviderMixin = (superClass) =>
return;
}

this._pendingRequests = {};
const filteredItems = [];
for (let i = 0; i < (this.size || 0); i++) {
filteredItems.push(this.__placeHolder);
}
this.filteredItems = filteredItems;
this._dataProviderController.clearCache();

this.requestContentUpdate();

if (this._shouldFetchData()) {
this._forceNextRequest = false;
this._loadPage(0);
} else {
this._forceNextRequest = true;
this._dataProviderController.loadFirstPage();
}
}

/** @private */
_sizeChanged(size = 0) {
const filteredItems = (this.filteredItems || []).slice(0, size);
for (let i = 0; i < size; i++) {
filteredItems[i] = filteredItems[i] !== undefined ? filteredItems[i] : this.__placeHolder;
const { rootCache } = this._dataProviderController;
// When the size update originates from the developer,
// sync the new size with the controller and trigger
// a content update to re-render the scroller.
if (rootCache.size !== size) {
rootCache.size = size;
this.requestContentUpdate();
}
}

/**
* @private
* @override
*/
_filteredItemsChanged(items) {
if (!this.dataProvider) {
return super._filteredItemsChanged(items);
}
this.filteredItems = filteredItems;

// Cleans up the redundant pending requests for pages > size
// Refers to https://github.com/vaadin/vaadin-flow-components/issues/229
this._flushPendingRequests(size);
const { rootCache } = this._dataProviderController;
// When the items update originates from the developer,
// sync the new items with the controller and trigger
// a content update to re-render the scroller.
if (rootCache.items !== items) {
rootCache.items = items;
this.requestContentUpdate();
}
}

/** @override */
requestContentUpdate() {
if (this.dataProvider) {
const { rootCache } = this._dataProviderController;

// Sync the controller's size with the component.
// They can be out of sync after, for example,
// the controller received new data.
if ((this.size || 0) !== rootCache.size) {
this.size = rootCache.size;
}

// Sync the controller's items with the component.
// They can be out of sync after, for example,
// the controller received new data.
if (this.filteredItems !== rootCache.items) {
this.filteredItems = rootCache.items;
}

// Sync the controller's loading state with the component.
this.loading = this._dataProviderController.isLoading();

// Set a copy of the controller's items as the dropdown items
// to trigger an update of the focused index in _setDropdownItems.
this._setDropdownItems([...this.filteredItems]);
}

super.requestContentUpdate();
}

/** @private */
Expand All @@ -257,6 +269,8 @@ export const ComboBoxDataProviderMixin = (superClass) =>
this.pageSize = oldPageSize;
throw new Error('`pageSize` value must be an integer > 0');
}

this._dataProviderController.setPageSize(pageSize);
this.clearCache();
}

Expand All @@ -266,6 +280,7 @@ export const ComboBoxDataProviderMixin = (superClass) =>
this.dataProvider = oldDataProvider;
});

this._dataProviderController.setDataProvider(dataProvider);
this.clearCache();
}

Expand All @@ -274,8 +289,6 @@ export const ComboBoxDataProviderMixin = (superClass) =>
if (this.items !== undefined && this.dataProvider !== undefined) {
restoreOldValueCallback();
throw new Error('Using `items` and `dataProvider` together is not supported');
} else if (this.dataProvider && !this.filteredItems) {
this.filteredItems = [];
}
}

Expand All @@ -294,28 +307,4 @@ export const ComboBoxDataProviderMixin = (superClass) =>
}
}
}

/**
* This method cleans up the page callbacks which refers to the
* non-existing pages, i.e. which item indexes are greater than the
* changed size.
* This case is basically happens when:
* 1. Users scroll fast to the bottom and combo box generates the
* redundant page request/callback
* 2. Server side uses undefined size lazy loading and suddenly reaches
* the exact size which is on the range edge
* (for default page size = 50, it will be 100, 200, 300, ...).
* @param size the new size of items
* @private
*/
_flushPendingRequests(size) {
if (this._pendingRequests) {
const lastPage = Math.ceil(size / this.pageSize);
Object.entries(this._pendingRequests).forEach(([page, callback]) => {
if (parseInt(page) >= lastPage) {
callback([], size);
}
});
}
}
};
10 changes: 7 additions & 3 deletions packages/combo-box/src/vaadin-combo-box-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export const ComboBoxMixin = (subclass) =>
static get observers() {
return [
'_selectedItemChanged(selectedItem, itemValuePath, itemLabelPath)',
'_openedOrItemsChanged(opened, _dropdownItems, loading)',
'_openedOrItemsChanged(opened, _dropdownItems, loading, _keepOverlayOpened)',
'_updateScroller(_scroller, _dropdownItems, opened, loading, selectedItem, itemIdPath, _focusedIndex, renderer, theme)',
];
}
Expand Down Expand Up @@ -487,10 +487,10 @@ export const ComboBoxMixin = (subclass) =>
}

/** @private */
_openedOrItemsChanged(opened, items, loading) {
_openedOrItemsChanged(opened, items, loading, keepOverlayOpened) {
// Close the overlay if there are no items to display.
// See https://github.com/vaadin/vaadin-combo-box/pull/964
this._overlayOpened = !!(opened && (loading || (items && items.length)));
this._overlayOpened = !!(opened && (keepOverlayOpened || loading || (items && items.length)));
}

/** @private */
Expand Down Expand Up @@ -1113,6 +1113,10 @@ export const ComboBoxMixin = (subclass) =>
this.items = oldItems;
});

if (this.dataProvider) {
return;
}

if (items) {
this.filteredItems = items.slice(0);
} else if (oldItems) {
Expand Down
Loading

0 comments on commit 3fbad4a

Please sign in to comment.