diff --git a/README.md b/README.md index fd027a0..cb87c7c 100644 --- a/README.md +++ b/README.md @@ -863,7 +863,9 @@ choices.disable(); **Input types affected:** `select-one`, `select-multiple` -**Usage:** Set choices of select input via an array of objects, a value name and a label name. This behaves the same as passing items via the `choices` option but can be called after initialising Choices. This can also be used to add groups of choices (see example 2); Optionally pass a true `replaceChoices` value to remove any existing choices. Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc). Passing an empty array as the first parameter, and a true `replaceChoices` is the same as calling `clearChoices` (see below). +**Usage:** Set choices of select input via an array of objects (or function that returns array of object or promise of it), a value field name and a label field name. + +This behaves the similar as passing items via the `choices` option but can be called after initialising Choices. This can also be used to add groups of choices (see example 3); Optionally pass a true `replaceChoices` value to remove any existing choices. Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc). Passing an empty array as the first parameter, and a true `replaceChoices` is the same as calling `clearChoices` (see below). **Example 1:** @@ -887,6 +889,22 @@ example.setChoices( ```js const example = new Choices(element); +// Passing a function that returns Promise of choices +example.setChoices(async () => { + try { + const items = await fetch('/items'); + return items.json(); + } catch (err) { + console.error(err); + } +}); +``` + +**Example 3:** + +```js +const example = new Choices(element); + example.setChoices( [ { @@ -1009,49 +1027,6 @@ example.setChoiceByValue('Two'); // Choice with value of 'Two' has now been sele **Usage:** Enables input to accept new values/select further choices. -### ajax(fn); - -**Input types affected:** `select-one`, `select-multiple` - -**Usage:** Populate choices/groups via a callback. - -**Example:** - -```js -var example = new Choices(element); - -example.ajax(function(callback) { - fetch(url) - .then(function(response) { - response.json().then(function(data) { - callback(data, 'value', 'label'); - }); - }) - .catch(function(error) { - console.log(error); - }); -}); -``` - -**Example 2:** -If your structure differs from `data.value` and `data.key` structure you can write your own `key` and `value` into the `callback` function. This could be useful when you don't want to transform the given response. - -```js -const example = new Choices(element); - -example.ajax(function(callback) { - fetch(url) - .then(function(response) { - response.json().then(function(data) { - callback(data, 'data.key', 'data.value'); - }); - }) - .catch(function(error) { - console.log(error); - }); -}); -``` - ## Browser compatibility Choices is compiled using [Babel](https://babeljs.io/) to enable support for [ES5 browsers](http://caniuse.com/#feat=es5). If you need to support a browser that does not support one of the features listed below, I suggest including a polyfill from the very good [polyfill.io](https://cdn.polyfill.io/v2/docs/): diff --git a/public/index.html b/public/index.html index c943526..4a30614 100644 --- a/public/index.html +++ b/public/index.html @@ -320,7 +320,7 @@ Options from remote source (Fetch API) & remove button + + + + + + + + + + +
- @@ -155,7 +246,12 @@
- @@ -171,26 +267,47 @@
- +
- +
-
- - @@ -199,7 +316,12 @@
- @@ -210,13 +332,17 @@ document.addEventListener('DOMContentLoaded', function() { const choicesBasic = new Choices('#choices-basic'); - document.querySelector('button.disable').addEventListener('click', () => { - choicesBasic.disable(); - }); + document + .querySelector('button.disable') + .addEventListener('click', () => { + choicesBasic.disable(); + }); - document.querySelector('button.enable').addEventListener('click', () => { - choicesBasic.enable(); - }); + document + .querySelector('button.enable') + .addEventListener('click', () => { + choicesBasic.enable(); + }); new Choices('#choices-remove-button', { removeItemButton: true, @@ -254,16 +380,9 @@ new Choices('#choices-remote-data', { shouldSort: false, - }).ajax((callback) => { - fetch('/data') - .then((response) => { - response.json().then((data) => { - callback(data, 'value', 'label'); - }); - }) - .catch((error) => { - console.error(error); - }); + }).setChoices(async () => { + const data = await fetch('/data'); + return data.json(); }); new Choices('#choices-scrolling-dropdown', { @@ -331,10 +450,12 @@ new Choices('#choices-within-form'); - new Choices('#choices-set-choice-by-value').setChoiceByValue('Choice 2'); + new Choices('#choices-set-choice-by-value').setChoiceByValue( + 'Choice 2', + ); new Choices('#choices-search-by-label', { searchFields: ['label'] }); }); - + diff --git a/public/test/select-one.html b/public/test/select-one.html index 54657f0..b4881b4 100644 --- a/public/test/select-one.html +++ b/public/test/select-one.html @@ -1,28 +1,58 @@ + + + + Choices + + + + + + + + + - - - - Choices - - - - - - - - - + + + - - - - - - - - + + + + @@ -43,7 +73,11 @@
- @@ -53,7 +87,11 @@
- @@ -63,7 +101,11 @@
- @@ -72,7 +114,12 @@
- @@ -81,7 +128,11 @@
- @@ -90,7 +141,11 @@
- @@ -99,7 +154,11 @@
- @@ -108,7 +167,11 @@
- @@ -117,12 +180,20 @@
- +
- @@ -143,7 +214,12 @@
- @@ -159,7 +235,11 @@
- @@ -175,26 +255,44 @@
- +
- +
-
- - @@ -203,7 +301,11 @@
- @@ -214,13 +316,17 @@ document.addEventListener('DOMContentLoaded', function() { const choicesBasic = new Choices('#choices-basic'); - document.querySelector('button.disable').addEventListener('click', () => { - choicesBasic.disable(); - }); + document + .querySelector('button.disable') + .addEventListener('click', () => { + choicesBasic.disable(); + }); - document.querySelector('button.enable').addEventListener('click', () => { - choicesBasic.enable(); - }); + document + .querySelector('button.enable') + .addEventListener('click', () => { + choicesBasic.enable(); + }); new Choices('#choices-remove-button', { removeItemButton: true, @@ -242,12 +348,12 @@ }); new Choices('#choices-render-choice-limit', { - renderChoiceLimit: 1 + renderChoiceLimit: 1, }); new Choices('#choices-search-disabled', { - searchEnabled: false - }) + searchEnabled: false, + }); new Choices('#choices-search-floor', { searchFloor: 5, @@ -255,16 +361,9 @@ new Choices('#choices-remote-data', { shouldSort: false, - }).ajax((callback) => { - fetch('/data') - .then((response) => { - response.json().then((data) => { - callback(data, 'value', 'label'); - }); - }) - .catch((error) => { - console.error(error); - }); + }).setChoices(async () => { + const res = await fetch('/data'); + return res.json(); }); new Choices('#choices-scrolling-dropdown', { @@ -276,7 +375,7 @@ const parent = new Choices('#choices-parent'); const child = new Choices('#choices-child').disable(); - parent.passedElement.element.addEventListener('change', (event) => { + parent.passedElement.element.addEventListener('change', event => { if (event.detail.value === 'Parent choice 2') { child.enable(); } else { @@ -310,8 +409,8 @@ customProperties: { country: 'Portugal', }, - } - ] + }, + ], }); new Choices('#choices-non-string-values', { @@ -343,10 +442,12 @@ new Choices('#choices-within-form'); - new Choices('#choices-set-choice-by-value').setChoiceByValue('Choice 2'); + new Choices('#choices-set-choice-by-value').setChoiceByValue( + 'Choice 2', + ); new Choices('#choices-search-by-label', { searchFields: ['label'] }); }); - + diff --git a/src/scripts/choices.js b/src/scripts/choices.js index 9e2a865..b8d7213 100644 --- a/src/scripts/choices.js +++ b/src/scripts/choices.js @@ -31,7 +31,6 @@ import { sortByScore, generateId, findAncestorByAttrName, - fetchFromObject, isIE11, existsInArray, cloneObject, @@ -44,6 +43,10 @@ const USER_DEFAULTS = /** @type {Partial */ + +/** + * @typedef {import('../../types/index').Choices.Choice} Choice + */ class Choices { /* ======================================== = Static properties = @@ -222,7 +225,7 @@ class Choices { } /* ======================================== - = Public functions = + = Public methods = ======================================== */ init() { @@ -460,9 +463,93 @@ class Choices { return this; } - setChoices(choices = [], value = '', label = '', replaceChoices = false) { - if (!this._isSelectElement || !value) { - return this; + /** + * Set choices of select input via an array of objects (or function that returns array of object or promise of it), + * a value field name and a label field name. + * This behaves the same as passing items via the choices option but can be called after initialising Choices. + * This can also be used to add groups of choices (see example 2); Optionally pass a true `replaceChoices` value to remove any existing choices. + * Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc). + * + * **Input types affected:** select-one, select-multiple + * + * @template {object[] | ((instance: Choices) => object[] | Promise)} T + * @param {T} [choicesArrayOrFetcher] + * @param {string} [value = 'value'] - name of `value` field + * @param {string} [label = 'label'] - name of 'label' field + * @param {boolean} [replaceChoices = false] - whether to replace of add choices + * @returns {this | Promise} + * + * @example + * ```js + * const example = new Choices(element); + * + * example.setChoices([ + * {value: 'One', label: 'Label One', disabled: true}, + * {value: 'Two', label: 'Label Two', selected: true}, + * {value: 'Three', label: 'Label Three'}, + * ], 'value', 'label', false); + * ``` + * + * @example + * ```js + * const example = new Choices(element); + * + * example.setChoices(async () => { + * try { + * const items = await fetch('/items'); + * return items.json() + * } catch(err) { + * console.error(err) + * } + * }); + * ``` + * + * @example + * ```js + * const example = new Choices(element); + * + * example.setChoices([{ + * label: 'Group one', + * id: 1, + * disabled: false, + * choices: [ + * {value: 'Child One', label: 'Child One', selected: true}, + * {value: 'Child Two', label: 'Child Two', disabled: true}, + * {value: 'Child Three', label: 'Child Three'}, + * ] + * }, + * { + * label: 'Group two', + * id: 2, + * disabled: false, + * choices: [ + * {value: 'Child Four', label: 'Child Four', disabled: true}, + * {value: 'Child Five', label: 'Child Five'}, + * {value: 'Child Six', label: 'Child Six', customProperties: { + * description: 'Custom description about child six', + * random: 'Another random custom property' + * }}, + * ] + * }], 'value', 'label', false); + * ``` + */ + setChoices( + choicesArrayOrFetcher = [], + value = 'value', + label = 'label', + replaceChoices = false, + ) { + if (!this.initialised) + throw new ReferenceError( + `setChoices was called on a non-initialized instance of Choices`, + ); + if (!this._isSelectElement) + throw new TypeError(`setChoices can't be used with INPUT based Choices`); + + if (typeof value !== 'string' || !value) { + throw new TypeError( + `value parameter must be a name of 'value' field in passed objects`, + ); } // Clear choices if needed @@ -470,6 +557,34 @@ class Choices { this.clearChoices(); } + if (!Array.isArray(choicesArrayOrFetcher)) { + if (typeof choicesArrayOrFetcher !== 'function') + throw new TypeError( + `.setChoices must be called either with array of choices with a function resulting into Promise of array of choices`, + ); + + // it's a choices fetcher + requestAnimationFrame(() => this._handleLoadingState(true)); + const fetcher = choicesArrayOrFetcher(this); + if (typeof fetcher === 'object' && typeof fetcher.then === 'function') { + // that's a promise + return fetcher + .then(data => this.setChoices(data, value, label, replaceChoices)) + .catch(err => { + if (!this.config.silent) console.error(err); + }) + .then(() => this._handleLoadingState(false)) + .then(() => this); + } + // function returned something else than promise, let's check if it's an array of choices + if (!Array.isArray(fetcher)) + throw new TypeError( + `.setChoices first argument function must return either array of choices or Promise, got: ${typeof fetcher}`, + ); + // recursion with results, it's sync and choices were cleared already + return this.setChoices(fetcher, value, label, false); + } + this.containerOuter.removeLoadingState(); const addGroupsAndChoices = groupOrChoice => { if (groupOrChoice.choices) { @@ -492,7 +607,7 @@ class Choices { }; this._setLoading(true); - choices.forEach(addGroupsAndChoices); + choicesArrayOrFetcher.forEach(addGroupsAndChoices); this._setLoading(false); return this; @@ -519,18 +634,7 @@ class Choices { return this; } - ajax(fn) { - if (!this.initialised || !this._isSelectElement || !fn) { - return this; - } - - requestAnimationFrame(() => this._handleLoadingState(true)); - fn(this._ajaxCallback()); - - return this; - } - - /* ===== End of Public functions ====== */ + /* ===== End of Public methods ====== */ /* ============================================= = Private functions = @@ -1054,55 +1158,6 @@ class Choices { }; } - _ajaxCallback() { - return (results, value, label) => { - if (!results || !value) { - return; - } - - const parsedResults = isType('Object', results) ? [results] : results; - - if ( - parsedResults && - isType('Array', parsedResults) && - parsedResults.length - ) { - // Remove loading states/text - this._handleLoadingState(false); - this._setLoading(true); - // Add each result as a choice - parsedResults.forEach(result => { - if (result.choices) { - this._addGroup({ - group: result, - id: result.id || null, - valueKey: value, - labelKey: label, - }); - } else { - this._addChoice({ - value: fetchFromObject(result, value), - label: fetchFromObject(result, label), - isSelected: result.selected, - isDisabled: result.disabled, - customProperties: result.customProperties, - placeholder: result.placeholder, - }); - } - }); - - this._setLoading(false); - - if (this._isSelectOneElement) { - this._selectPlaceholderChoice(); - } - } else { - // No results, remove loading state - this._handleLoadingState(false); - } - }; - } - _searchChoices(value) { const newValue = isType('String', value) ? value.trim() : value; const currentValue = isType('String', this._currentValue) diff --git a/src/scripts/choices.test.js b/src/scripts/choices.test.js index 71e116c..d08afb9 100644 --- a/src/scripts/choices.test.js +++ b/src/scripts/choices.test.js @@ -881,84 +881,74 @@ describe('choices', () => { }); }); - describe('ajax', () => { - const callbackoutput = 'worked'; - - let handleLoadingStateStub; - let ajaxCallbackStub; - - const returnsEarly = () => { - it('returns early', () => { - expect(handleLoadingStateStub.called).to.equal(false); - expect(ajaxCallbackStub.called).to.equal(false); - }); - }; - - beforeEach(() => { - handleLoadingStateStub = stub(); - ajaxCallbackStub = stub().returns(callbackoutput); - - instance._ajaxCallback = ajaxCallbackStub; - instance._handleLoadingState = handleLoadingStateStub; - }); - - afterEach(() => { - instance._ajaxCallback.reset(); - instance._handleLoadingState.reset(); - }); - + describe('setChoices with callback/Promise', () => { describe('not initialised', () => { beforeEach(() => { instance.initialised = false; - output = instance.ajax(() => {}); }); - returnsInstance(output); - returnsEarly(); + it('should throw', () => { + expect(() => instance.setChoices(null)).Throw(ReferenceError); + }); }); describe('text element', () => { beforeEach(() => { instance._isSelectElement = false; - output = instance.ajax(() => {}); }); - returnsInstance(output); - returnsEarly(); + it('should throw', () => { + expect(() => instance.setChoices(null)).Throw(TypeError); + }); }); describe('passing invalid function', () => { beforeEach(() => { - output = instance.ajax(null); + instance._isSelectElement = true; }); - returnsInstance(output); - returnsEarly(); + it('should throw on non function', () => { + expect(() => instance.setChoices(null)).Throw(TypeError, /Promise/i); + }); + + it(`should throw on function that doesn't return promise`, () => { + expect(() => instance.setChoices(() => 'boo')).to.throw( + TypeError, + /promise/i, + ); + }); }); describe('select element', () => { - let callback; + it('fetches and sets choices', async () => { + document.body.innerHTML = '