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 +
+ + +
+ +
+``` +:: + +```javascript +const tree = document.querySelector('ef-tree'); + +// Make a scoped re-usable filter for performance +const createCustomFilter = (tree) => { + let query = ''; // reference query string for validating queryRegExp cache state + let queryRegExp; // cache RegExp + + // Get current RegExp, or renew if out of date + // this is fetched on demand by filter/renderer + // only created once per query + 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 scoped custom filter + 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); +``` + +@> Regardless of filter configuration, Tree always shows parent items as long as at least one of their child is visible. + ## Accessibility + ::a11y-intro:: `ef-tree` is assigned `role="tree"` and can include properties such as `aria-multiselectable`, `aria-label`, or `aria-labelledby`. It receives focus once at host and it is navigable through items using `Up` and `Down` arrow keys and expandable or collapsable using `Left` and `Right`. Each item is assigned `role="treeitem"` and can include properties such as `aria-selected` or `aria-checked` in `multiple` mode. diff --git a/packages/elements/src/combo-box/__demo__/index.html b/packages/elements/src/combo-box/__demo__/index.html index be8c51abd0..7e25b695f1 100644 --- a/packages/elements/src/combo-box/__demo__/index.html +++ b/packages/elements/src/combo-box/__demo__/index.html @@ -156,7 +156,6 @@ const regex = getRegularExpressionOfQuery(); // test on label or value const result = query === item.value || regex.test(item.label); - regex.lastIndex = 0; // do not forget to reset last index return result; }; }; @@ -297,13 +296,11 @@ 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/packages/elements/src/combo-box/__snapshots__/Filter.md b/packages/elements/src/combo-box/__snapshots__/Filter.md index bcc36131df..ccc5941d5e 100644 --- a/packages/elements/src/combo-box/__snapshots__/Filter.md +++ b/packages/elements/src/combo-box/__snapshots__/Filter.md @@ -67,9 +67,69 @@ +``` +```html +
+ +
+ + +
+
+ + + + + + + + + + ``` +#### `Should be able to use custom filter function` + ```html
'); + el.data = getData(); + await elementUpdated(el); + + const createCustomFilter = (comboBox) => { + let query = ''; + let queryRegExp; + // Items could be filtered with case-insensitive partial match of both labels & values. + const getRegularExpressionOfQuery = () => { + if (comboBox.query !== query || !queryRegExp) { + query = comboBox.query || ''; + queryRegExp = new RegExp(escapeStringRegexp(query), 'i'); + } + return queryRegExp; + }; + return (item) => { + const value = item.value; + const label = item.label; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; + }; + el.filter = createCustomFilter(el); + const textInput = 'ax'; + await setInputEl(el, textInput); + await elementUpdated(el); + expect(el.query).to.equal(textInput, `Query should be the same as input text: "${textInput}"`); + await expect(el).shadowDom.to.equalSnapshot(snapshotIgnore); + }); }); }); diff --git a/packages/elements/src/combo-box/helpers/filter.ts b/packages/elements/src/combo-box/helpers/filter.ts index 32a1fa4352..ce0061dd93 100644 --- a/packages/elements/src/combo-box/helpers/filter.ts +++ b/packages/elements/src/combo-box/helpers/filter.ts @@ -11,7 +11,7 @@ import type { ComboBoxFilter } from './types'; * @param el ComboBox instance to filter * @returns Filter accepting an item */ -export const defaultFilter = (el: ComboBox): ComboBoxFilter => { +export const createDefaultFilter = (el: ComboBox): ComboBoxFilter => { // reference query string for validating queryRegExp cache state let query = ''; // cache RegExp @@ -32,8 +32,6 @@ export const defaultFilter = (el: ComboBox): C return (item): boolean => { const regex = getRegularExpressionOfQuery(); const result = regex.test((item as unknown as ItemText).label); - // this example uses global scope, so the index needs resetting - regex.lastIndex = 0; return result; }; }; diff --git a/packages/elements/src/combo-box/index.ts b/packages/elements/src/combo-box/index.ts index 8b85a12d39..820a138a65 100644 --- a/packages/elements/src/combo-box/index.ts +++ b/packages/elements/src/combo-box/index.ts @@ -33,7 +33,7 @@ import '../list/index.js'; import '../overlay/index.js'; import { registerOverflowTooltip } from '../tooltip/index.js'; import { VERSION } from '../version.js'; -import { defaultFilter } from './helpers/filter.js'; +import { createDefaultFilter } from './helpers/filter.js'; import { CustomKeyboardEvent } from './helpers/keyboard-event.js'; import { ComboBoxRenderer, createComboBoxRenderer } from './helpers/renderer.js'; import type { ComboBoxData, ComboBoxFilter } from './helpers/types'; @@ -114,13 +114,13 @@ export class ComboBox extends FormFieldElement { * Set this to null when data is filtered externally, eg XHR * @type {ComboBoxFilter | null} */ - @property({ type: Function, attribute: false }) - public filter: ComboBoxFilter | null = defaultFilter(this); + @property({ attribute: false }) + public filter: ComboBoxFilter | null = createDefaultFilter(this); /** * Renderer used to render list item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer = createComboBoxRenderer(this); private _multiple = false; diff --git a/packages/elements/src/list/elements/list.ts b/packages/elements/src/list/elements/list.ts index b5155f7110..c355224ed7 100644 --- a/packages/elements/src/list/elements/list.ts +++ b/packages/elements/src/list/elements/list.ts @@ -86,7 +86,7 @@ export class List extends ControlElement { /** * Renderer used to render list item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer = createListRenderer(this); /** diff --git a/packages/elements/src/swing-gauge/index.ts b/packages/elements/src/swing-gauge/index.ts index 08670a89e0..661fdded65 100644 --- a/packages/elements/src/swing-gauge/index.ts +++ b/packages/elements/src/swing-gauge/index.ts @@ -196,7 +196,7 @@ export class SwingGauge extends ResponsiveElement { * Custom value formatter * @type {SwingGaugeValueFormatter} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public valueFormatter: SwingGaugeValueFormatter = this.defaultValueFormatter; /** diff --git a/packages/elements/src/tooltip/index.ts b/packages/elements/src/tooltip/index.ts index fb07d1337a..7a08bfebb1 100644 --- a/packages/elements/src/tooltip/index.ts +++ b/packages/elements/src/tooltip/index.ts @@ -123,7 +123,7 @@ class Tooltip extends BasicElement { * Return `true` if the target matches * @type {TooltipCondition} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public condition: TooltipCondition | undefined; /** @@ -132,7 +132,7 @@ class Tooltip extends BasicElement { * If the content is not present, tooltip will not be displayed * @type {TooltipRenderer} */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public renderer: TooltipRenderer | undefined; /** diff --git a/packages/elements/src/tree-select/__demo__/countries.js b/packages/elements/src/tree-select/__demo__/countries.js index 1a5841bb27..bf1995dca1 100644 --- a/packages/elements/src/tree-select/__demo__/countries.js +++ b/packages/elements/src/tree-select/__demo__/countries.js @@ -1,5 +1,5 @@ /* eslint-disable */ -const treeCollection = [ +export const countries = [ { value: 'AFR', label: 'Africa', diff --git a/packages/elements/src/tree-select/__demo__/index.html b/packages/elements/src/tree-select/__demo__/index.html index 7ce3b38a84..ad1378c5bc 100644 --- a/packages/elements/src/tree-select/__demo__/index.html +++ b/packages/elements/src/tree-select/__demo__/index.html @@ -4,8 +4,6 @@ Tree Select - - @@ -20,6 +18,8 @@ import '@refinitiv-ui/phrasebook/locale/zh/tree-select.js'; + + +

+ Items could be filtered with case-insensitive partial match of both labels & values.
+ There is a debounce rate of 500ms applied. +

+ + +
diff --git a/packages/elements/src/tree-select/__test__/tree-select.filter.test.js b/packages/elements/src/tree-select/__test__/tree-select.filter.test.js index 0b819dadb5..694654e374 100644 --- a/packages/elements/src/tree-select/__test__/tree-select.filter.test.js +++ b/packages/elements/src/tree-select/__test__/tree-select.filter.test.js @@ -4,6 +4,7 @@ import '@formatjs/intl-getcanonicallocales/polyfill.iife'; import '@formatjs/intl-locale/polyfill.iife'; import '@formatjs/intl-pluralrules/locale-data/en'; import '@formatjs/intl-pluralrules/polyfill.iife'; +import escapeStringRegexp from 'escape-string-regexp'; import '@refinitiv-ui/elements/tree-select'; @@ -14,7 +15,7 @@ import { flatData, flatSelection } from './mock_data/flat.js'; import { multiLevelData } from './mock_data/multi-level.js'; import { nestedData, nestedSelection, selectableCount } from './mock_data/nested.js'; import { noRelationData } from './mock_data/no-relation.js'; -import { changeItemSelection, openedUpdated } from './utils.js'; +import { changeItemSelection, getTreeElPart, openedUpdated } from './utils.js'; /* * @@ -378,5 +379,42 @@ describe('tree-select/Filter', function () { 'Show all selected items including readonly' ); }); + + it('Should be able to use custom filter function', async function () { + const el = await fixture(''); + el.data = flatData; + await elementUpdated(el); + + const createCustomFilter = (treeSelect) => { + let query = ''; + let queryRegExp; + // Items could be filtered with case-insensitive partial match of both labels & values. + const getRegularExpressionOfQuery = () => { + if (treeSelect.query !== query || !queryRegExp) { + query = treeSelect.query || ''; + queryRegExp = new RegExp(escapeStringRegexp(query), 'i'); + } + return queryRegExp; + }; + return (item) => { + const value = item.value; + const label = item.label; + const regex = getRegularExpressionOfQuery(); + const result = regex.test(value) || regex.test(label); + return result; + }; + }; + el.filter = createCustomFilter(el); + const query = 'is'; + el.query = query; + await elementUpdated(el); + + const expectedLength = 4; + const treeEl = getTreeElPart(el); + expect(treeEl.children.length).to.equal( + expectedLength, + `there should be only ${expectedLength} children with a query of ${query}` + ); + }); }); }); diff --git a/packages/elements/src/tree-select/__test__/utils.js b/packages/elements/src/tree-select/__test__/utils.js index a4103ddd25..94843c5295 100644 --- a/packages/elements/src/tree-select/__test__/utils.js +++ b/packages/elements/src/tree-select/__test__/utils.js @@ -42,3 +42,10 @@ export const checkMemo = (el, expected) => { expect(el.memo.selectable).to.equal(expected.selectable, 'memo.selectable is incorrect'); expect(el.memo.selected).to.equal(expected.selected, 'memo.selected is incorrect'); }; + +/** + * extract ef-tree element displaying data in tree structure from ef-tree-select element + * @param {TreeSelect} treeSelectEl ef-tree-select element + * @returns {Tree} ef-tree element + */ +export const getTreeElPart = (treeSelectEl) => treeSelectEl.shadowRoot.querySelector('[part="tree"]'); diff --git a/packages/elements/src/tree-select/index.ts b/packages/elements/src/tree-select/index.ts index 5aaa1138dd..c99b9b70ca 100644 --- a/packages/elements/src/tree-select/index.ts +++ b/packages/elements/src/tree-select/index.ts @@ -48,6 +48,9 @@ const valueFormatWarning = new WarningNotice( /** * Dropdown control that allows selection from the tree list * + * @prop {TreeSelectFilter | null} [filter=createDefaultFilter(this)] - Custom filter for static data. Set this to null when data is filtered externally, eg XHR + * @attr {number} [query-debounce-rate] - Control query rate in milliseconds + * @prop {number} [queryDebounceRate] - Control query rate in milliseconds * @attr {boolean} [opened=false] - Set dropdown to open * @prop {boolean} [opened=false] - Set dropdown to open * @attr {string} placeholder - Set placeholder text @@ -239,7 +242,7 @@ export class TreeSelect extends ComboBox { /** * Renderer used to render tree item elements */ - @property({ type: Function, attribute: false }) + @property({ attribute: false }) public override renderer = createTreeSelectRenderer(this); private _max: string | null = null; diff --git a/packages/elements/src/tree/__demo__/index.html b/packages/elements/src/tree/__demo__/index.html index 4146990a6d..37547c12ef 100644 --- a/packages/elements/src/tree/__demo__/index.html +++ b/packages/elements/src/tree/__demo__/index.html @@ -126,6 +126,13 @@ } return element; }; + globalThis.debounce = (callback, delay = 500) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => callback.apply(this, args), delay); + }; + }; @@ -167,19 +174,143 @@ - + + + +

+ Items could be filtered with case-insensitive partial match of both labels & values through query + input +

+ + + + + + +
diff --git a/packages/elements/src/tree/__test__/helpers/data.js b/packages/elements/src/tree/__test__/helpers/data.js new file mode 100644 index 0000000000..c64c291c65 --- /dev/null +++ b/packages/elements/src/tree/__test__/helpers/data.js @@ -0,0 +1,338 @@ +export const multiLevelData = [ + { + value: 'l11', + label: 'Level 1-1', + items: [ + { + value: 'l21', + label: 'Level 2-1', + expanded: true, + items: [ + { + value: 'l31', + label: 'Level 3-1' + }, + { + value: 'l32', + label: 'Level 3-2' + } + ] + }, + { + value: 'l22', + label: 'Level 2-2', + expanded: true, + items: [ + { + value: 'l33', + label: 'Level 3-3' + } + ] + } + ] + }, + { + value: 'l12', + label: 'Level 1-2', + items: [ + { + value: 'l23', + label: 'Level 2-3', + expanded: true, + items: [ + { + value: 'l34', + label: 'Level 3-4' + }, + { + value: 'l35', + label: 'Level 3-5' + } + ] + }, + { + value: 'l24', + label: 'Level 2-4' + } + ] + } +]; + +export const flatData = [ + { + icon: 'info', + label: 'Item 1', + value: '1' + }, + { + icon: '', + label: 'Item 2', + value: '2', + readonly: true + }, + { + icon: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/icons/favorites.svg', + label: 'Item 3', + value: '3', + disabled: true + }, + { + label: 'Item 4', + value: '4', + selected: true + } +]; + +export const nestedData = [ + { + label: 'Item 1', + value: '1', + expanded: true, + items: [ + { + label: 'Item 1.1', + value: '1.1' + }, + { + label: 'Item 1.2', + value: '1.2', + selected: true + } + ] + }, + { + label: 'Item 2', + value: '2', + readonly: true + }, + { + label: 'Item 3', + value: '3', + disabled: true + }, + { + label: 'Item 4', + value: '4' + } +]; + +export const deepNestedData = [ + { + label: 'Item 1', + value: '1', + items: [ + { + label: 'Item 1.1', + value: '1.1' + }, + { + label: 'Item 1.2', + value: '1.2' + }, + { + label: 'Item 1.3', + value: '1.3', + items: [ + { + label: 'Item 1.3.1', + value: '1.3.1', + items: [ + { + label: 'Item 1.3.1.1', + value: '1.3.1.1', + selected: true + }, + { + label: 'Item 1.3.1.2', + value: '1.3.1.2', + selected: true + }, + { + label: 'Item 1.3.1.3', + value: '1.3.1.3', + selected: true + } + ] + }, + { + label: 'Item 1.3.2', + value: '1.3.2', + items: [ + { + label: 'Item 1.3.2.1', + value: '1.3.2.1', + selected: true + }, + { + label: 'Item 1.3.2.2', + value: '1.3.2.2', + selected: true + }, + { + label: 'Item 1.3.2.3', + value: '1.3.2.3', + selected: true + } + ] + } + ] + } + ] + } +]; + +export const firstFilterData = [ + { + label: 'Group One', + value: '1', + expanded: true, + items: [ + { + label: 'Item One.One', + icon: 'clock', + value: '1.1', + highlighted: true + }, + { + label: 'Item One.Two', + readonly: true, + value: '1.2' + }, + { + label: 'Item One.Three', + icon: 'info', + value: '1.3', + selected: true + }, + { + label: 'Item One.Four', + icon: 'info', + value: '1.4', + selected: true + } + ] + }, + { + label: 'Group Two', + value: '2', + expanded: true, + disabled: true, + items: [ + { + label: 'Item Two.One', + value: '2.1', + hidden: true + }, + { + label: 'Item Two.Two', + value: '2.2', + items: [ + { + label: 'Item Two.Two.One', + value: '2.2.1', + expanded: true + }, + { + label: 'Item Two.Two.Two', + value: '2.2.2' + }, + { + label: 'Item Two.Two.Three', + value: '2.2.3' + } + ] + }, + { + label: 'Item Two.Three', + value: '2.3' + }, + { + label: 'Item Two.Four', + value: '2.4', + disabled: true + } + ] + }, + { + label: 'Item Three', + value: '3' + }, + { + label: 'Item Four', + value: '4' + } +]; + +export const secondFilterData = [ + { + label: 'Group One', + value: '1', + expanded: true, + items: [ + { + label: 'Item One.One', + icon: 'clock', + value: '1.1', + highlighted: true + }, + { + label: 'Item One.Two', + readonly: true, + value: '1.2' + }, + { + label: 'Item One.Three', + icon: 'info', + value: '1.3', + selected: true + }, + { + label: 'Item One.Four', + icon: 'info', + value: '1.4', + selected: true + } + ] + }, + { + label: 'Group Two', + value: '2', + expanded: true, + disabled: true, + items: [ + { + label: 'Item Two.One', + value: '2.1', + hidden: true + }, + { + label: 'Item Two.Two', + value: '2.2', + items: [ + { + label: 'Item Two.Two.One', + value: '2.2.1', + expanded: true + }, + { + label: 'Item Two.Two.Two', + value: '2.2.2' + } + ] + }, + { + label: 'Item Two.Three', + value: '2.3' + }, + { + label: 'Item Two.Four', + value: '2.4', + disabled: true + } + ] + }, + { + label: 'Item Four', + value: '4' + } +]; diff --git a/packages/elements/src/tree/__test__/tree.test.js b/packages/elements/src/tree/__test__/tree.test.js index 83df5d9e84..a7bbfdcc78 100644 --- a/packages/elements/src/tree/__test__/tree.test.js +++ b/packages/elements/src/tree/__test__/tree.test.js @@ -1,4 +1,6 @@ // import element and theme +import escapeStringRegexp from 'escape-string-regexp'; + import '@refinitiv-ui/elements/tree'; import '@refinitiv-ui/elemental-theme/light/ef-tree'; @@ -13,7 +15,14 @@ import { oneEvent } from '@refinitiv-ui/test-helpers'; -import { multiLevelData } from './mock_data/multi-level.js'; +import { + deepNestedData, + firstFilterData, + flatData, + multiLevelData, + nestedData, + secondFilterData +} from './helpers/data.js'; const keyArrowUp = keyboardEvent('keydown', { key: 'Up' }); const keyArrowDown = keyboardEvent('keydown', { key: 'Down' }); @@ -21,129 +30,6 @@ const keyArrowLeft = keyboardEvent('keydown', { key: 'Left' }); const keyArrowRight = keyboardEvent('keydown', { key: 'Right' }); const keyEnter = keyboardEvent('keydown', { key: 'Enter' }); -const flatData = [ - { - icon: 'info', - label: 'Item 1', - value: '1' - }, - { - icon: '', - label: 'Item 2', - value: '2', - readonly: true - }, - { - icon: 'https://cdn.refinitiv.net/public/libs/elf/assets/elf-theme-halo/resources/icons/favorites.svg', - label: 'Item 3', - value: '3', - disabled: true - }, - { - label: 'Item 4', - value: '4', - selected: true - } -]; - -const nestedData = [ - { - label: 'Item 1', - value: '1', - expanded: true, - items: [ - { - label: 'Item 1.1', - value: '1.1' - }, - { - label: 'Item 1.2', - value: '1.2', - selected: true - } - ] - }, - { - label: 'Item 2', - value: '2', - readonly: true - }, - { - label: 'Item 3', - value: '3', - disabled: true - }, - { - label: 'Item 4', - value: '4' - } -]; - -const deepNestedData = [ - { - label: 'Item 1', - value: '1', - items: [ - { - label: 'Item 1.1', - value: '1.1' - }, - { - label: 'Item 1.2', - value: '1.2' - }, - { - label: 'Item 1.3', - value: '1.3', - items: [ - { - label: 'Item 1.3.1', - value: '1.3.1', - items: [ - { - label: 'Item 1.3.1.1', - value: '1.3.1.1', - selected: true - }, - { - label: 'Item 1.3.1.2', - value: '1.3.1.2', - selected: true - }, - { - label: 'Item 1.3.1.3', - value: '1.3.1.3', - selected: true - } - ] - }, - { - label: 'Item 1.3.2', - value: '1.3.2', - items: [ - { - label: 'Item 1.3.2.1', - value: '1.3.2.1', - selected: true - }, - { - label: 'Item 1.3.2.2', - value: '1.3.2.2', - selected: true - }, - { - label: 'Item 1.3.2.3', - value: '1.3.2.3', - selected: true - } - ] - } - ] - } - ] - } -]; - describe('tree/Tree', function () { describe('Basic Tests', function () { it('Label and DOM structure is correct', async function () { @@ -731,5 +617,56 @@ describe('tree/Tree', function () { await elementUpdated(el); expect(el.value).to.equal('4', 'Value should be update when selecting a new item on filter applied.'); }); + + it('should be able to filter items with custom filter & query', async function () { + const el = await fixture(''); + el.data = firstFilterData; + await elementUpdated(el); + + const createCustomFilter = (tree) => { + let query = ''; + let queryRegExp; + const getRegularExpressionOfQuery = () => { + if (tree.query !== query || !queryRegExp) { + query = tree.query || ''; + queryRegExp = new RegExp(escapeStringRegexp(query), '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; + }; + }; + el.filter = createCustomFilter(el); + el.query = '3'; + await elementUpdated(el); + + const firstChildrenCount = 7; + expect(el.children.length).to.equal( + firstChildrenCount, + `there should be ${firstChildrenCount} child(ren) with the provided custom filter & query` + ); + + el.query = 'three'; + await elementUpdated(el); + + expect(el.children.length).to.equal( + firstChildrenCount, + `there should be ${firstChildrenCount} child(ren) with the provided custom filter & query` + ); + + el.data = secondFilterData; + await elementUpdated(el); + + const secondChildrenCount = 4; + expect(el.children.length).to.equal( + secondChildrenCount, + `there should be ${secondChildrenCount} child(ren) with the provided custom filter, query & data` + ); + }); }); }); diff --git a/packages/elements/src/tree/elements/tree.ts b/packages/elements/src/tree/elements/tree.ts index 4636d337cf..bd87dd5a4f 100644 --- a/packages/elements/src/tree/elements/tree.ts +++ b/packages/elements/src/tree/elements/tree.ts @@ -6,7 +6,7 @@ import { CollectionComposer } from '@refinitiv-ui/utils/collection.js'; import { List, valueFormatWarning } from '../../list/index.js'; import { VERSION } from '../../version.js'; -import { defaultFilter } from '../helpers/filter.js'; +import { createDefaultFilter } from '../helpers/filter.js'; import { createTreeRenderer } from '../helpers/renderer.js'; import type { TreeData, TreeDataItem, TreeFilter } from '../helpers/types'; import { TreeManager, TreeManagerMode } from '../managers/tree-manager.js'; @@ -63,9 +63,9 @@ export class Tree extends List { /** * Custom filter for static data * @type {TreeFilter | null} - * @ignore set to protected for now and need to discuss before set to public API */ - protected filter: TreeFilter | null = defaultFilter(this); + @property({ attribute: false }) + public filter: TreeFilter | null = createDefaultFilter(this); /** * Renderer used for generating tree items diff --git a/packages/elements/src/tree/helpers/filter.ts b/packages/elements/src/tree/helpers/filter.ts index 5e54e9f893..79e26c43cb 100644 --- a/packages/elements/src/tree/helpers/filter.ts +++ b/packages/elements/src/tree/helpers/filter.ts @@ -8,7 +8,7 @@ import type { TreeFilter } from './types'; * @param el Tree instance to filter * @returns Filter accepting an item */ -export const defaultFilter = (el: Tree): TreeFilter => { +export const createDefaultFilter = (el: Tree): TreeFilter => { // reference query string for validating queryRegExp cache state let query = ''; // cache RegExp @@ -34,8 +34,6 @@ export const defaultFilter = (el: Tree const regex = getRegularExpressionOfQuery(); const result = regex.test(label); - // this regex uses global scope, so the index needs resetting - regex.lastIndex = 0; return result; }; }; diff --git a/packages/elements/src/tree/index.ts b/packages/elements/src/tree/index.ts index 75ae7d1e9c..7c3329de0b 100644 --- a/packages/elements/src/tree/index.ts +++ b/packages/elements/src/tree/index.ts @@ -1,8 +1,6 @@ -import type { TreeData, TreeDataItem } from './helpers/types'; - export * from './elements/tree.js'; export * from './elements/tree-item.js'; export { TreeRenderer, createTreeRenderer } from './helpers/renderer.js'; export { TreeManager, TreeManagerMode, CheckedState } from './managers/tree-manager.js'; -export type { TreeData, TreeDataItem }; +export type { TreeData, TreeDataItem, TreeFilter } from './helpers/types';