From 6e7f048e3657cf1f52a000312034aa3a4c2f4082 Mon Sep 17 00:00:00 2001 From: wattachai <117723407+wattachai-lseg@users.noreply.github.com> Date: Mon, 10 Jun 2024 09:47:53 +0700 Subject: [PATCH] feat(tree): enable custom filter of query (#1172) * feat(tree, tree-select, combo-box): allow filtering latest value of items * docs(combo-box): improve regexp usage * docs(tree-select): add missing filter API reference * test(tree-select): add custom filter unit test * docs(combo-box): use collection composer in filter examples for latest value * docs(tree-select): remove non-existing filter attribute in JSDoc * refactor(elements): remove unnecessary type option in property decorator with no attribute * docs(tree-select): add missing queryDebounceRate API ref * feat(tree): expose filter as property only * test(tree): new unit test for custom filter * docs(tree): add filter section * docs(tree-select): workaround overflow table issue in API reference * refactor(tree-select): fix tree manager prop visibility * docs(tree): use v6 API in the docs * feat: filter with initial value * Revert "feat(tree, tree-select, combo-box): allow filtering latest value of items" This reverts commit 3b3013abbf90d6bc4b67eb290756db2603e6ba3e. * docs(tree-select): improve paragraph format * refactor(combo-box,tree-select, tree): remove unnecessary lastIndex usage * test(combo-box): add custom filter test case --- documents/src/pages/elements/combo-box.md | 32 +- documents/src/pages/elements/tree-select.md | 6 +- documents/src/pages/elements/tree.md | 115 ++++++ .../src/combo-box/__demo__/index.html | 3 - .../src/combo-box/__snapshots__/Filter.md | 60 ++++ .../__test__/combo-box.filter.test.js | 34 ++ .../elements/src/combo-box/helpers/filter.ts | 4 +- packages/elements/src/combo-box/index.ts | 8 +- packages/elements/src/list/elements/list.ts | 2 +- packages/elements/src/swing-gauge/index.ts | 2 +- packages/elements/src/tooltip/index.ts | 4 +- .../src/tree-select/__demo__/countries.js | 2 +- .../src/tree-select/__demo__/index.html | 40 ++- .../__test__/tree-select.filter.test.js | 40 ++- .../src/tree-select/__test__/utils.js | 7 + packages/elements/src/tree-select/index.ts | 5 +- .../elements/src/tree/__demo__/index.html | 153 +++++++- .../src/tree/__test__/helpers/data.js | 338 ++++++++++++++++++ .../elements/src/tree/__test__/tree.test.js | 185 ++++------ packages/elements/src/tree/elements/tree.ts | 6 +- packages/elements/src/tree/helpers/filter.ts | 4 +- packages/elements/src/tree/index.ts | 4 +- 22 files changed, 873 insertions(+), 181 deletions(-) create mode 100644 packages/elements/src/tree/__test__/helpers/data.js diff --git a/documents/src/pages/elements/combo-box.md b/documents/src/pages/elements/combo-box.md index 3d7681ebb5..b70027682a 100644 --- a/documents/src/pages/elements/combo-box.md +++ b/documents/src/pages/elements/combo-box.md @@ -218,25 +218,29 @@ comboBox.data = [ { label: 'Brazil', value: 'br' }, { label: 'Argentina', value: 'ar' } ]; -const customFilter = (comboBox) => { +const createCustomFilter = (comboBox) => { let query = ''; let queryRegExp; const getRegularExpressionOfQuery = () => { if (comboBox.query !== query || !queryRegExp) { query = comboBox.query || ''; + // Non-word characters are escaped to prevent ReDoS attack. + // This serves as a demo only. + // For production, use a proven implementation instead. queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i'); } return queryRegExp; }; return (item) => { + const label = item.label; + const value = item.value; const regex = getRegularExpressionOfQuery(); - const result = query === item.value || regex.test(item.label); - regex.lastIndex = 0; // do not forget to reset last index + const result = regex.test(value) || regex.test(label); return result; }; }; -comboBox.filter = customFilter(comboBox); +comboBox.filter = createCustomFilter(comboBox); ``` ```css .wrapper { @@ -255,7 +259,7 @@ comboBox.filter = customFilter(comboBox); const comboBox = document.querySelector('ef-combo-box'); // Make a scoped re-usable filter for performance -const customFilter = (comboBox) => { +const createCustomFilter = (comboBox) => { let query = ''; // reference query string for validating queryRegExp cache state let queryRegExp; // cache RegExp @@ -265,6 +269,9 @@ const customFilter = (comboBox) => { const getRegularExpressionOfQuery = () => { if (comboBox.query !== query || !queryRegExp) { query = comboBox.query || ''; + // Non-word characters are escaped to prevent ReDoS attack. + // This serves as a demo only. + // For production, use a proven implementation instead. queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i'); } return queryRegExp; @@ -272,18 +279,18 @@ const customFilter = (comboBox) => { // return scoped custom filter return (item) => { + const label = item.label; + const value = item.value; const regex = getRegularExpressionOfQuery(); - const result = query === item.value || regex.test(item.label); - regex.lastIndex = 0; // do not forget to reset last index + const result = regex.test(value) || regex.test(label); return result; }; }; -comboBox.filter = customFilter(comboBox); +comboBox.filter = createCustomFilter(comboBox); ``` - -@> Regardless of filter configuration Combo Box always treats `type: 'header'` items as group headers, which persist as long as at least one item within the group is visible. +@> Regardless of filter configuration, Combo Box always treats `type: 'header'` items as group headers, which persist as long as at least one item within the group is visible. ## Asynchronous filtering @@ -345,6 +352,9 @@ comboBox.filter = null; // A function to make request. In real life scenario it may wrap fetch const request = (query, value) => { + // Non-word characters are escaped to prevent ReDoS attack. + // This serves as a demo only. + // For production, use a proven implementation instead. const regex = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i'); // Always keep a promise to let Combo Box know that the data is loading @@ -360,13 +370,11 @@ const request = (query, value) => { selected: true, hidden: query ? !regex.test(item.label) : false })); - regex.lastIndex = 0; continue; } if (query && regex.test(item.label)) { filterData.push(item); - regex.lastIndex = 0; } } } diff --git a/documents/src/pages/elements/tree-select.md b/documents/src/pages/elements/tree-select.md index bb4070b906..f019c05632 100644 --- a/documents/src/pages/elements/tree-select.md +++ b/documents/src/pages/elements/tree-select.md @@ -300,12 +300,10 @@ setTimeout(() => { el.opened = true; }, 1000); *> If the number of selected items is likely to be large, pills may not be a good choice for display or performance. ## Filtering -Tree select has built in text filtering and selection editing. -By clicking the `Selected` button, Tree Select allows the items to be filtered by selected state, and that subset to be operated on in isolation from the main item list. - -For custom filtering, Tree Select provides an identical interface as Combo Box. You provide a predicate function that tests an item. Please consult the [Combo Box docs](./elements/combo-box) for details on how to construct a compatible filter. +Tree Select has built in text filtering and selection editing. By clicking the `Selected` button, Tree Select allows the items to be filtered by selected state, and that subset to be operated on in isolation from the main item list. +For custom filtering, Tree Select provides an identical interface as Combo Box. You provide a predicate function testing each item. Please consult the [Combo Box docs](./elements/combo-box#filtering) for details on how to construct a compatible filter. ## Limiting Selected Items Tree Select offers a convenient way to limit the number of selected items using `max` property. If users attempt to select more items than the specified limit, "Done" button will be automatically disabled. diff --git a/documents/src/pages/elements/tree.md b/documents/src/pages/elements/tree.md index ec565d41f4..b407f2eb06 100644 --- a/documents/src/pages/elements/tree.md +++ b/documents/src/pages/elements/tree.md @@ -361,7 +361,122 @@ tree.addEventListener('value-changed', (event) => { }); ``` +## Filtering + +Filtering happens when `query` property or attribute is not empty. By Default, the filter is applied on the data `label` property. Developers may wish to do their own filtering by implementing the `filter` property. A typical example is to apply filter on multiple data properties e.g. `label` and `value`. + +:: +```javascript +::import-elements:: +const tree = document.querySelector('ef-tree'); +tree.data = [ + { label: 'EMEA', value: 'emea', expanded: true, items: [ + { label: 'France', value: 'fr' }, + { label: 'Russian Federation', value: 'ru' }, + { label: 'Spain', value: 'es' }, + { label: 'United Kingdom', value: 'gb' } + ]}, + { label: 'APAC', value: 'apac', expanded: true, items: [ + { label: 'China', value: 'ch' }, + { label: 'Australia', value: 'au' }, + { label: 'India', value: 'in' }, + { label: 'Thailand', value: 'th' } + ]}, + { label: 'AMERS', value: 'amers', expanded: true, items: [ + { label: 'Canada', value: 'ca' }, + { label: 'United States', value: 'us' }, + { label: 'Brazil', value: 'br' }, + { label: 'Argentina', value: 'ar' } + ]} +]; +const createCustomFilter = (tree) => { + let query = ''; + let queryRegExp; + const getRegularExpressionOfQuery = () => { + if (tree.query !== query || !queryRegExp) { + query = tree.query || ''; + // Non-word characters are escaped to prevent ReDoS attack. + // This serves as a demo only. + // For production, use a proven implementation instead. + queryRegExp = new RegExp(query.replace(/(\W)/g, '\\$1'), 'i'); + } + return queryRegExp; + }; + return (item) => { + const label = item.label; + const value = item.value; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; +}; +tree.filter = createCustomFilter(tree); + +const input = document.getElementById('query'); +input.addEventListener('value-changed', e => { + tree.query = e.detail.value; +}); +``` +```css +.wrapper { + padding: 5px; + width: 300px; + height: 430px; +} + +#query { + width: 200px; +} +``` +```html +
+ Items could be filtered with case-insensitive partial match of both labels & values.
+ There is a debounce rate of 500ms applied.
+