Skip to content

Commit

Permalink
feat(pat-autosuggest): Add batching support for AJAX requests.
Browse files Browse the repository at this point in the history
This PR introduces three new options for that:
max-initial-size: Defines the batch size for the initial request (default: 10).
ajax-batch-size: Defines the batch size for subsequent requests (default: 10).
ajax-timeout: Defines the timeout in milliseconds before a AJAX request is submitted. (default: 400).

Ref: scrum-1638
  • Loading branch information
reinhardt authored and thet committed Dec 16, 2024
1 parent 70c08a3 commit eecb315
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 10 deletions.
41 changes: 31 additions & 10 deletions src/pat/auto-suggest/auto-suggest.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ const log = logging.getLogger("autosuggest");
export const parser = new Parser("autosuggest");
parser.addArgument("ajax-data-type", "JSON");
parser.addArgument("ajax-search-index", "");
parser.addArgument("ajax-timeout", 400);
parser.addArgument("ajax-url", "");
parser.addArgument("max-initial-size", 10); // AJAX search results limit for the first page.
parser.addArgument("ajax-batch-size", 10); // AJAX search results limit for subsequent pages.
parser.addArgument("allow-new-words", true); // Should custom tags be allowed?
parser.addArgument("max-selection-size", 0);
parser.addArgument("minimum-input-length"); // Don't restrict by default so that all results show
Expand Down Expand Up @@ -54,10 +57,10 @@ export default Base.extend({
separator: this.options.valueSeparator,
tokenSeparators: [","],
openOnEnter: false,
maximumSelectionSize: this.options.maxSelectionSize,
maximumSelectionSize: this.options.max["selection-size"],
minimumInputLength: this.options.minimumInputLength,
allowClear:
this.options.maxSelectionSize === 1 && !this.el.hasAttribute("required"),
this.options.max["selection-size"] === 1 && !this.el.hasAttribute("required"),
};
if (this.el.hasAttribute("readonly")) {
config.placeholder = "";
Expand Down Expand Up @@ -179,7 +182,7 @@ export default Base.extend({
// Even if words was [], we would get a tag stylee select
// That was then properly working with ajax if configured.

if (this.options.maxSelectionSize === 1) {
if (this.options.max["selection-size"] === 1) {
config.data = words;
// We allow exactly one value, use dropdown styles. How do we feed in words?
} else {
Expand All @@ -198,8 +201,8 @@ export default Base.extend({
for (const value of values) {
data.push({ id: value, text: value });
}
if (this.options.maxSelectionSize === 1) {
data = data[0];
if (this.options.max["selection-size"] === 1) {
data = data[0]
}
callback(data);
};
Expand Down Expand Up @@ -234,7 +237,7 @@ export default Base.extend({
_data.push({ id: d, text: data[d] });
}
}
if (this.options.maxSelectionSize === 1) {
if (this.options.max["selection-size"] === 1) {
_data = _data[0];
}
callback(_data);
Expand All @@ -253,19 +256,27 @@ export default Base.extend({
url: this.options.ajax.url,
dataType: this.options.ajax["data-type"],
type: "GET",
quietMillis: 400,
quietMillis: this.options.ajax.timeout,
data: (term, page) => {
return {
index: this.options.ajax["search-index"],
q: term, // search term
page_limit: 10,
page_limit: this.page_limit(page),
page: page,
};
},
results: (data, page) => {
// parse the results into the format expected by Select2.
// Parse the results into the format expected by Select2.
// data must be a list of objects with keys "id" and "text"
return { results: data, page: page };

// Check whether there are more results to come.
// There are maybe more results if the number of
// items is the same as the batch-size.
// We expect the backend to return an empty list if
// a batch page is requested where there are no
// more results.
const load_more = Object.keys(data).length >= this.page_limit(page);
return { results: data, page: page, more: load_more };
},
},
},
Expand All @@ -275,6 +286,16 @@ export default Base.extend({
return config;
},

page_limit(page) {
// Page limit for the first page of a batch.
let page_limit = this.options.max["initial-size"];
if (page > 1) {
// Page limit for subsequent pages.
page_limit = this.options.ajax["batch-size"];
}
return page_limit;
},

destroy($el) {
$el.off(".pat-autosuggest");
$el.select2("destroy");
Expand Down
146 changes: 146 additions & 0 deletions src/pat/auto-suggest/auto-suggest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ import utils from "../../core/utils";
import registry from "../../core/registry";
import { jest } from "@jest/globals";

// Need to import for the ajax mock to work.
import "select2";


const mock_fetch_ajax = (...data) => {
// Data format: [{id: str, text: str}, ... ], ...
// first batch ^ ^ second batch

// NOTE: You need to add a trailing comma if you add only one argument to
// make the multi-argument dereferencing work.

// Mock Select2
$.fn.select2.ajaxDefaults.transport = jest.fn().mockImplementation((opts) => {
// Get the batch page
const page = opts.data.page - 1;

// Return the data for the batch
return opts.success(data[page]);
});
};


var testutils = {
createInputElement: function (c) {
var cfg = c || {};
Expand Down Expand Up @@ -545,4 +567,128 @@ describe("pat-autosuggest", function () {
expect(selected.length).toBe(0);
});
});

describe("6 - AJAX tests", function () {
it("6.1 - AJAX works with a simple data structure.", async function () {
mock_fetch_ajax(
[
{ id: "1", text: "apple" },
{ id: "2", text: "orange" }
], // Note the trailing comma to make the multi-argument dereferencing work.
);

document.body.innerHTML = `
<input
type="text"
class="pat-autosuggest"
data-pat-autosuggest="
ajax-url: http://test.org/test;
ajax-timeout: 1;
" />
`;

const input = document.querySelector("input");
new pattern(input);
await utils.timeout(1); // wait a tick for async to settle.

$(".select2-input").click();
await utils.timeout(1); // wait for ajax to finish.

const results = $(document.querySelectorAll(".select2-results li"));
expect(results.length).toBe(2);

$(results[0]).mouseup();

const selected = document.querySelectorAll(".select2-search-choice");
expect(selected.length).toBe(1);
expect(selected[0].textContent.trim()).toBe("apple");
expect(input.value).toBe("1");
});

it("6.2 - AJAX works with batches.", async function () {
mock_fetch_ajax(
[
{ id: "1", text: "one" },
{ id: "2", text: "two" },
{ id: "3", text: "three" },
{ id: "4", text: "four" },
],
[
{ id: "5", text: "five" },
{ id: "6", text: "six" },
],
[
{ id: "7", text: "three" },
],
);

document.body.innerHTML = `
<input
type="text"
class="pat-autosuggest"
data-pat-autosuggest="
ajax-url: http://test.org/test;
ajax-timeout: 1;
max-initial-size: 4;
ajax-batch-size: 2;
" />
`;

const input = document.querySelector("input");
new pattern(input);
await utils.timeout(1); // wait a tick for async to settle.

// Load batch 1 with batch size 4
$(".select2-input").click();
await utils.timeout(1); // wait for ajax to finish.

const results_1 = $(document.querySelectorAll(".select2-results .select2-result"));
expect(results_1.length).toBe(4);

const load_more_1 = $(document.querySelectorAll(".select2-results .select2-more-results"));
expect(load_more_1.length).toBe(1);

// Load batch 2 with batch size 2
$(load_more_1[0]).mouseup();
// NOTE: Flaky behavior needs multiple timeouts 👌
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.

const results_2 = $(document.querySelectorAll(".select2-results .select2-result"));
expect(results_2.length).toBe(6);

const load_more_2 = $(document.querySelectorAll(".select2-results .select2-more-results"));
expect(load_more_2.length).toBe(1);


// Load final batch 2
$(load_more_2[0]).mouseup();
// NOTE: Flaky behavior needs multiple timeouts 🤘
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.
await utils.timeout(1); // wait for ajax to finish.

const results_3 = $(document.querySelectorAll(".select2-results .select2-result"));
expect(results_3.length).toBe(7);

const load_more_3 = $(document.querySelectorAll(".select2-results .select2-more-results"));
expect(load_more_3.length).toBe(0);
});

});
});
37 changes: 37 additions & 0 deletions src/pat/auto-suggest/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,53 @@ Pre-fill the input element with words in JSON format and don't allow the user to
prefill-json: {"john-snow":"John Snow"};
allow-new-words: false;' type="text"></input>


### Batching support

This pattern can load data in batches via AJAX.
The following example demonstrates how define batch sizes for the initial load (`max-initial-size`) and for subsequent loads (`ajax-batch-size`).
Both values default to 10.

<input
type="text"
class="pat-auto-suggest"
data-pat-auto-suggest="
ajax-url: /path/to/data.json;
ajax-batch-size: 10;
max-initial-size: 10;
"
/>

---
**Note**

The server needs to support batching, otherwise these options do not have any effect.

---

### AJAX parameters submitted to the server

| Parameter | Description |
| --------- | ----------- |
| index | The optional search index to be used on the server, if needed. |
| q | The search term. |
| page_limit | The number of items to be returned per page. Based on the current page it is wether `max-initial-size` (page 1) or `ajax-batch-size` (page 2). |
| page | The current page number. |


### Option reference

You can customise the behaviour of a gallery through options in the `data-pat-auto-suggest` attribute.

| Property | Type | Default Value | Description |
| -------------------- | ------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ajax-batch-size | Number | 10 | Batch size for subsequent pages of a bigger batch. For the first page, `max-initial-size` is used. |
| ajax-data-type | String | "json" | In what format will AJAX fetched data be returned in? |
| ajax-search-index | String | | The index or key which must be used to determine the value from the returned data. |
| ajax-timeout | Number | 400 | Timeout before new ajax requests are sent. The default value is set ot `400` milliseconds and prevents querying the server too often while typing. |
| ajax-url | URL | | The URL which must be called via AJAX to fetch remote data. |
| allow-new-words | Boolean | true | Besides the suggested words, also allow custom user-defined words to be entered. |
| max-initial-size | Number | 10 | Initial batch size. Display `max-initial-size` items on the first page of a bigger result set. |
| max-selection-size | Number | 0 | How many values are allowed? Provide a positive number or 0 for unlimited. |
| placeholder | String | Enter text | The placeholder text for the form input. The `placeholder` attribute of the form element can also be used. |
| prefill | List | | A comma separated list of values with which the form element must be filled in with. The `value-separator` option does not have an effect here. |
Expand Down

0 comments on commit eecb315

Please sign in to comment.