diff --git a/packages/common/src/editors/__tests__/selectEditor.spec.ts b/packages/common/src/editors/__tests__/selectEditor.spec.ts index fb3def09d..69196df34 100644 --- a/packages/common/src/editors/__tests__/selectEditor.spec.ts +++ b/packages/common/src/editors/__tests__/selectEditor.spec.ts @@ -107,7 +107,7 @@ describe('SelectEditor', () => { (mockColumn.internalColumnEditor as ColumnEditor).collection = [{ hello: 'world' }]; editor = new SelectEditor(editorArguments, true); } catch (e) { - expect(e.toString()).toContain(`[select-editor] A collection with value/label (or value/labelKey when using Locale) is required to populate the Select list`); + expect(e.toString()).toContain(`[Slickgrid-Universal] Select Filter/Editor collection with value/label (or value/labelKey when using Locale) is required to populate the Select list`); done(); } }); diff --git a/packages/common/src/editors/selectEditor.ts b/packages/common/src/editors/selectEditor.ts index 4fa106044..47df4afff 100644 --- a/packages/common/src/editors/selectEditor.ts +++ b/packages/common/src/editors/selectEditor.ts @@ -20,8 +20,8 @@ import { SlickGrid, SlickNamespace, } from './../interfaces/index'; -import { CollectionService, findOrDefault, TranslaterService } from '../services/index'; -import { getDescendantProperty, getTranslationPrefix, htmlEncode, sanitizeTextByAvailableSanitizer, setDeepValue } from '../services/utilities'; +import { buildSelectEditorOrFilterDomElement, CollectionService, findOrDefault, TranslaterService } from '../services/index'; +import { getDescendantProperty, getTranslationPrefix, setDeepValue } from '../services/utilities'; // using external non-typed js libraries declare const Slick: SlickNamespace; @@ -696,70 +696,18 @@ export class SelectEditor implements Editor { this.finalCollection = finalCollection; // step 1, create HTML string template - const editorTemplate = this.buildTemplateHtmlString(finalCollection); + const selectBuildResult = buildSelectEditorOrFilterDomElement( + 'editor', + finalCollection, + this.columnDef, + this.grid, + this.isMultipleSelect, + this._translaterService + ); // step 2, create the DOM Element of the editor - // also subscribe to the onClose event - this.createDomElement(editorTemplate); - } - - /** Build the template HTML string */ - protected buildTemplateHtmlString(collection: any[]): string { - let options = ''; - const columnId = this.columnDef?.id ?? ''; - const separatorBetweenLabels = this.collectionOptions?.separatorBetweenTextLabels ?? ''; - const isRenderHtmlEnabled = this.columnEditor?.enableRenderHtml ?? false; - const sanitizedOptions = this.gridOptions?.sanitizeHtmlOptions ?? {}; - - // collection could be an Array of Strings OR Objects - if (collection.every((x: any) => typeof x === 'string')) { - collection.forEach((option: string) => { - options += ``; - }); - } else { - // array of objects will require a label/value pair unless a customStructure is passed - collection.forEach((option: SelectOption) => { - if (!option || (option[this.labelName] === undefined && option.labelKey === undefined)) { - throw new Error('[select-editor] A collection with value/label (or value/labelKey when using ' + - 'Locale) is required to populate the Select list, for example: ' + - '{ collection: [ { value: \'1\', label: \'One\' } ])'); - } - const labelKey = (option.labelKey || option[this.labelName]) as string; - const labelText = ((option.labelKey || (this.enableTranslateLabel && this._translaterService)) && labelKey) ? this._translaterService?.translate(labelKey || ' ') : labelKey; - let prefixText = option[this.labelPrefixName] || ''; - let suffixText = option[this.labelSuffixName] || ''; - let optionLabel = option[this.optionLabel] || ''; - if (optionLabel?.toString) { - optionLabel = optionLabel.toString().replace(/\"/g, '\''); // replace double quotes by single quotes to avoid interfering with regular html - } - - // also translate prefix/suffix if enableTranslateLabel is true and text is a string - prefixText = (this.enableTranslateLabel && this._translaterService && prefixText && typeof prefixText === 'string') ? this._translaterService.translate(prefixText || ' ') : prefixText; - suffixText = (this.enableTranslateLabel && this._translaterService && suffixText && typeof suffixText === 'string') ? this._translaterService.translate(suffixText || ' ') : suffixText; - optionLabel = (this.enableTranslateLabel && this._translaterService && optionLabel && typeof optionLabel === 'string') ? this._translaterService.translate(optionLabel || ' ') : optionLabel; - - // add to a temp array for joining purpose and filter out empty text - const tmpOptionArray = [prefixText, labelText, suffixText].filter(text => (text !== undefined && text !== '')); - let optionText = tmpOptionArray.join(separatorBetweenLabels); - - // if user specifically wants to render html text, he needs to opt-in else it will stripped out by default - // also, the 3rd party lib will saninitze any html code unless it's encoded, so we'll do that - if (isRenderHtmlEnabled) { - // sanitize any unauthorized html tags like script and others - // for the remaining allowed tags we'll permit all attributes - const sanitizedText = sanitizeTextByAvailableSanitizer(this.gridOptions, optionText, sanitizedOptions); - optionText = htmlEncode(sanitizedText); - } - - // html text of each select option - let optionValue = option[this.valueName]; - if (optionValue === undefined || optionValue === null) { - optionValue = ''; - } - options += ``; - }); - } - return ``; + // we will later also subscribe to the onClose event to save the Editor whenever that event is triggered + this.createDomElement(selectBuildResult.selectElement); } /** Create a blank entry that can be added to the collection. It will also reuse the same collection structure provided by the user */ @@ -777,9 +725,12 @@ export class SelectEditor implements Editor { return blankEntry; } - /** From the html template string, create the DOM element of the Multiple/Single Select Editor */ - protected createDomElement(editorTemplate: string) { - this.$editorElm = $(editorTemplate); + /** + * From the Select DOM Element created earlier, create a Multiple/Single Select Editor using the jQuery multiple-select.js lib + * @param {Object} selectElement + */ + protected createDomElement(selectElement: HTMLSelectElement) { + this.$editorElm = $(selectElement); if (this.$editorElm && typeof this.$editorElm.appendTo === 'function') { $(this.args.container).empty(); diff --git a/packages/common/src/filters/__tests__/selectFilter.spec.ts b/packages/common/src/filters/__tests__/selectFilter.spec.ts index b1cf27d6f..6285f0355 100644 --- a/packages/common/src/filters/__tests__/selectFilter.spec.ts +++ b/packages/common/src/filters/__tests__/selectFilter.spec.ts @@ -98,7 +98,7 @@ describe('SelectFilter', () => { mockColumn.filter!.collection = [{ hello: 'world' }]; filter.init(filterArguments); } catch (e) { - expect(e.message).toContain(`[select-filter] A collection with value/label (or value/labelKey when using Locale) is required to populate the Select list`); + expect(e.message).toContain(`[Slickgrid-Universal] Select Filter/Editor collection with value/label (or value/labelKey when using Locale) is required to populate the Select list`); done(); } }); diff --git a/packages/common/src/filters/selectFilter.ts b/packages/common/src/filters/selectFilter.ts index 87e56b8f8..240b472c5 100644 --- a/packages/common/src/filters/selectFilter.ts +++ b/packages/common/src/filters/selectFilter.ts @@ -11,13 +11,12 @@ import { GridOption, Locale, MultipleSelectOption, - SelectOption, SlickGrid, } from './../interfaces/index'; import { CollectionService } from '../services/collection.service'; import { collectionObserver, propertyObserver } from '../services/observers'; -import { getDescendantProperty, getTranslationPrefix, htmlEncode, sanitizeTextByAvailableSanitizer, unsubscribeAll } from '../services/utilities'; -import { RxJsFacade, Subscription, TranslaterService } from '../services/index'; +import { getDescendantProperty, getTranslationPrefix, unsubscribeAll } from '../services/utilities'; +import { buildSelectEditorOrFilterDomElement, RxJsFacade, Subscription, TranslaterService } from '../services/index'; import { renderCollectionOptionsAsync } from './filterUtilities'; export class SelectFilter implements Filter { @@ -342,94 +341,24 @@ export class SelectFilter implements Filter { newCollection = this.filterCollection(newCollection); newCollection = this.sortCollection(newCollection); - // step 1, create HTML string template - const filterTemplate = this.buildTemplateHtmlString(newCollection, this.searchTerms || []); + // step 1, create HTML DOM element + const selectBuildResult = buildSelectEditorOrFilterDomElement( + 'filter', + newCollection, + this.columnDef, + this.grid, + this.isMultipleSelect, + this.translaterService, + this.searchTerms || [] + ); + this.isFilled = selectBuildResult.hasFoundSearchTerm; // step 2, create the DOM Element of the filter & pre-load search terms - // also subscribe to the onClose event - this.createDomElement(filterTemplate); + // we will later also subscribe to the onClose event to filter the data whenever that event is triggered + this.createDomElement(selectBuildResult.selectElement); this._collectionLength = newCollection.length; } - /** - * Create the HTML template as a string - * @param optionCollection array - * @param searchTerms array - * @return html template string - */ - protected buildTemplateHtmlString(optionCollection: any[], searchTerms: SearchTerm[]): string { - let options = ''; - const columnId = this.columnDef?.id ?? ''; - const separatorBetweenLabels = this.collectionOptions?.separatorBetweenTextLabels ?? ''; - const isTranslateEnabled = this.gridOptions?.enableTranslate ?? false; - const isRenderHtmlEnabled = this.columnFilter?.enableRenderHtml ?? false; - const sanitizedOptions = this.gridOptions?.sanitizeHtmlOptions ?? {}; - - // collection could be an Array of Strings OR Objects - if (Array.isArray(optionCollection)) { - if (optionCollection.every((x: any) => typeof x === 'string')) { - optionCollection.forEach((option: string) => { - const selected = (searchTerms.findIndex((term) => term === option) >= 0) ? 'selected' : ''; - options += ``; - - // if there's at least 1 search term found, we will add the "filled" class for styling purposes - // on a single select, we'll also make sure the single value is not an empty string to consider this being filled - if ((selected && this.isMultipleSelect) || (selected && !this.isMultipleSelect && option !== '')) { - this.isFilled = true; - } - }); - } else { - // array of objects will require a label/value pair unless a customStructure is passed - optionCollection.forEach((option: SelectOption) => { - if (!option || (option[this.labelName] === undefined && option.labelKey === undefined)) { - throw new Error(`[select-filter] A collection with value/label (or value/labelKey when using Locale) is required to populate the Select list, for example:: { filter: model: Filters.multipleSelect, collection: [ { value: '1', label: 'One' } ]')`); - } - const labelKey = (option.labelKey || option[this.labelName]) as string; - const selected = (searchTerms.findIndex((term) => `${term}` === `${option[this.valueName]}`) >= 0) ? 'selected' : ''; - const labelText = ((option.labelKey || this.enableTranslateLabel) && labelKey && isTranslateEnabled) ? (this.translaterService?.getCurrentLanguage && this.translaterService?.getCurrentLanguage() && this.translaterService.translate(labelKey) || '') : labelKey; - let prefixText = option[this.labelPrefixName] || ''; - let suffixText = option[this.labelSuffixName] || ''; - let optionLabel = option.hasOwnProperty(this.optionLabel) ? option[this.optionLabel] : ''; - if (optionLabel?.toString) { - optionLabel = optionLabel.toString().replace(/\"/g, '\''); // replace double quotes by single quotes to avoid interfering with regular html - } - - // also translate prefix/suffix if enableTranslateLabel is true and text is a string - prefixText = (this.enableTranslateLabel && isTranslateEnabled && prefixText && typeof prefixText === 'string') ? this.translaterService?.getCurrentLanguage && this.translaterService.getCurrentLanguage() && this.translaterService.translate(prefixText || ' ') : prefixText; - suffixText = (this.enableTranslateLabel && isTranslateEnabled && suffixText && typeof suffixText === 'string') ? this.translaterService?.getCurrentLanguage && this.translaterService.getCurrentLanguage() && this.translaterService.translate(suffixText || ' ') : suffixText; - optionLabel = (this.enableTranslateLabel && isTranslateEnabled && optionLabel && typeof optionLabel === 'string') ? this.translaterService?.getCurrentLanguage && this.translaterService.getCurrentLanguage() && this.translaterService.translate(optionLabel || ' ') : optionLabel; - // add to a temp array for joining purpose and filter out empty text - const tmpOptionArray = [prefixText, (typeof labelText === 'string' || typeof labelText === 'number') ? labelText.toString() : labelText, suffixText].filter((text) => text); - let optionText = tmpOptionArray.join(separatorBetweenLabels); - - // if user specifically wants to render html text, he needs to opt-in else it will stripped out by default - // also, the 3rd party lib will saninitze any html code unless it's encoded, so we'll do that - if (isRenderHtmlEnabled) { - // sanitize any unauthorized html tags like script and others - // for the remaining allowed tags we'll permit all attributes - const sanitizedText = sanitizeTextByAvailableSanitizer(this.gridOptions, optionText, sanitizedOptions); - optionText = htmlEncode(sanitizedText); - } - - // html text of each select option - let optionValue = option[this.valueName]; - if (optionValue === undefined || optionValue === null) { - optionValue = ''; - } - options += ``; - - // if there's a search term, we will add the "filled" class for styling purposes - // on a single select, we'll also make sure the single value is not an empty string to consider this being filled - if ((selected && this.isMultipleSelect) || (selected && !this.isMultipleSelect && option[this.valueName] !== '')) { - this.isFilled = true; - } - }); - } - } - - return ``; - } - /** Create a blank entry that can be added to the collection. It will also reuse the same collection structure provided by the user */ protected createBlankEntry(): any { const blankEntry = { @@ -446,11 +375,10 @@ export class SelectFilter implements Filter { } /** - * From the html template string, create a DOM element of the Multiple/Single Select Filter - * Subscribe to the onClose event and run the callback when that happens - * @param filterTemplate + * From the Select DOM Element created earlier, create a Multiple/Single Select Filter using the jQuery multiple-select.js lib + * @param {Object} selectElement */ - protected createDomElement(filterTemplate: string) { + protected createDomElement(selectElement: HTMLSelectElement) { const columnId = this.columnDef?.id ?? ''; // provide the name attribute to the DOM element which will be needed to auto-adjust drop position (dropup / dropdown) @@ -461,7 +389,7 @@ export class SelectFilter implements Filter { $($headerElm).empty(); // create the DOM element & add an ID and filter class - this.$filterElm = $(filterTemplate); + this.$filterElm = $(selectElement); if (typeof this.$filterElm.multipleSelect !== 'function') { throw new Error(`multiple-select.js was not found, make sure to read the HOWTO Wiki on how to install it.`); } diff --git a/packages/common/src/services/domUtilities.ts b/packages/common/src/services/domUtilities.ts new file mode 100644 index 000000000..38fd1b5a8 --- /dev/null +++ b/packages/common/src/services/domUtilities.ts @@ -0,0 +1,128 @@ +import { SearchTerm } from '../enums/index'; +import { Column, SelectOption, SlickGrid } from '../interfaces/index'; +import { TranslaterService } from './translater.service'; +import { htmlEncode, sanitizeTextByAvailableSanitizer } from './utilities'; + +/** + * Create the HTML DOM Element for a Select Editor or Filter, this is specific to these 2 types only and the unit tests are directly under them + * @param {String} type - type of select DOM element to build, can be either 'editor' or 'filter' + * @param {Array} collection - array of items to build the select html options + * @param {Array} columnDef - column definition object + * @param {Object} grid - Slick Grid object + * @param {Boolean} isMultiSelect - are we building a multiple select element (false means it's a single select) + * @param {Object} translaterService - optional Translater Service + * @param {Array<*>} searchTerms - optional array of search term (used by the "filter" type only) + * @returns object with 2 properties for the select element & a boolean value telling us if any of the search terms were found and selected in the dropdown + */ +export function buildSelectEditorOrFilterDomElement(type: 'editor' | 'filter', collection: any[], columnDef: Column, grid: SlickGrid, isMultiSelect = false, translaterService?: TranslaterService, searchTerms?: SearchTerm[]): { selectElement: HTMLSelectElement; hasFoundSearchTerm: boolean; } { + const columnId = columnDef?.id ?? ''; + const gridOptions = grid.getOptions(); + const columnFilterOrEditor = (type === 'editor' ? columnDef?.internalColumnEditor : columnDef?.filter) ?? {}; + const collectionOptions = columnFilterOrEditor?.collectionOptions ?? {}; + const separatorBetweenLabels = collectionOptions?.separatorBetweenTextLabels ?? ''; + const enableTranslateLabel = columnFilterOrEditor?.enableTranslateLabel ?? false; + const isTranslateEnabled = gridOptions?.enableTranslate ?? false; + const isRenderHtmlEnabled = columnFilterOrEditor?.enableRenderHtml ?? false; + const sanitizedOptions = gridOptions?.sanitizeHtmlOptions ?? {}; + const labelName = columnFilterOrEditor?.customStructure?.label ?? 'label'; + const labelPrefixName = columnFilterOrEditor?.customStructure?.labelPrefix ?? 'labelPrefix'; + const labelSuffixName = columnFilterOrEditor?.customStructure?.labelSuffix ?? 'labelSuffix'; + const optionLabel = columnFilterOrEditor?.customStructure?.optionLabel ?? 'value'; + const valueName = columnFilterOrEditor?.customStructure?.value ?? 'value'; + + const selectElement = document.createElement('select'); + selectElement.className = 'ms-filter search-filter'; + const extraCssClasses = type === 'filter' ? ['search-filter', `filter-${columnId}`] : ['select-editor', `editor-${columnId}`]; + selectElement.classList.add(...extraCssClasses); + + selectElement.multiple = isMultiSelect; + + // use an HTML Fragment for performance reason, MDN explains it well as shown below:: + // The key difference is that because the document fragment isn't part of the actual DOM's structure, changes made to the fragment don't affect the document, cause reflow, or incur any performance impact that can occur when changes are made. + const selectOptionsFragment = document.createDocumentFragment(); + + let hasFoundSearchTerm = false; + + // collection could be an Array of Strings OR Objects + if (Array.isArray(collection)) { + if (collection.every((x: any) => typeof x === 'string')) { + for (const option of collection) { + const selectOptionElm = document.createElement('option'); + if (type === 'filter' && Array.isArray(searchTerms)) { + selectOptionElm.selected = (searchTerms.findIndex(term => term === option) >= 0); // when filter search term is found then select it in dropdown + } + selectOptionElm.value = option; + selectOptionElm.label = option; + selectOptionElm.textContent = option; + selectOptionsFragment.appendChild(selectOptionElm); + + // if there's at least 1 Filter search term found, we will add the "filled" class for styling purposes + // on a single select, we'll also make sure the single value is not an empty string to consider this being filled + if ((selectOptionElm.selected && isMultiSelect) || (selectOptionElm.selected && !isMultiSelect && option !== '')) { + hasFoundSearchTerm = true; + } + } + } else { + // array of objects will require a label/value pair unless a customStructure is passed + collection.forEach((option: SelectOption) => { + if (!option || (option[labelName] === undefined && option.labelKey === undefined)) { + throw new Error(`[Slickgrid-Universal] Select Filter/Editor collection with value/label (or value/labelKey when using Locale) is required to populate the Select list, for example:: { filter: model: Filters.multipleSelect, collection: [ { value: '1', label: 'One' } ]')`); + } + const selectOptionElm = document.createElement('option'); + const labelKey = (option.labelKey || option[labelName]) as string; + const labelText = ((option.labelKey || (enableTranslateLabel && translaterService)) && labelKey && isTranslateEnabled) ? translaterService?.translate(labelKey || ' ') : labelKey; + let prefixText = option[labelPrefixName] || ''; + let suffixText = option[labelSuffixName] || ''; + let selectOptionLabel = option.hasOwnProperty(optionLabel) ? option[optionLabel] : ''; + if (selectOptionLabel?.toString) { + selectOptionLabel = selectOptionLabel.toString().replace(/\"/g, '\''); // replace double quotes by single quotes to avoid interfering with regular html + } + + // also translate prefix/suffix if enableTranslateLabel is true and text is a string + prefixText = (enableTranslateLabel && translaterService && prefixText && typeof prefixText === 'string') ? translaterService.translate(prefixText || ' ') : prefixText; + suffixText = (enableTranslateLabel && translaterService && suffixText && typeof suffixText === 'string') ? translaterService.translate(suffixText || ' ') : suffixText; + selectOptionLabel = (enableTranslateLabel && translaterService && optionLabel && typeof optionLabel === 'string') ? translaterService.translate(optionLabel || ' ') : optionLabel; + + // add to a temp array for joining purpose and filter out empty text + const tmpOptionArray = [prefixText, (typeof labelText === 'string' || typeof labelText === 'number') ? labelText.toString() : labelText, suffixText].filter((text) => text); + let optionText = tmpOptionArray.join(separatorBetweenLabels); + + // if user specifically wants to render html text, he needs to opt-in else it will stripped out by default + // also, the 3rd party lib will saninitze any html code unless it's encoded, so we'll do that + if (isRenderHtmlEnabled) { + // sanitize any unauthorized html tags like script and others + // for the remaining allowed tags we'll permit all attributes + const sanitizedText = sanitizeTextByAvailableSanitizer(gridOptions, optionText, sanitizedOptions); + optionText = htmlEncode(sanitizedText); + selectOptionElm.innerHTML = optionText; + } else { + selectOptionElm.textContent = optionText; + } + + // html text of each select option + let selectOptionValue = option[valueName]; + if (selectOptionValue === undefined || selectOptionValue === null) { + selectOptionValue = ''; + } + + if (type === 'filter' && Array.isArray(searchTerms)) { + selectOptionElm.selected = (searchTerms.findIndex(term => `${term}` === `${option[valueName]}`) >= 0); // when filter search term is found then select it in dropdown + } + selectOptionElm.value = `${selectOptionValue}`; + selectOptionElm.label = selectOptionLabel; + selectOptionsFragment.appendChild(selectOptionElm); + + // if there's a search term, we will add the "filled" class for styling purposes + // on a single select, we'll also make sure the single value is not an empty string to consider this being filled + if ((selectOptionElm.selected && isMultiSelect) || (selectOptionElm.selected && !isMultiSelect && option[valueName] !== '')) { + hasFoundSearchTerm = true; + } + }); + } + } + + // last step append the HTML fragment to the final select DOM element + selectElement.appendChild(selectOptionsFragment); + + return { selectElement, hasFoundSearchTerm }; +} \ No newline at end of file diff --git a/packages/common/src/services/index.ts b/packages/common/src/services/index.ts index 34ce9045e..37de9b79a 100644 --- a/packages/common/src/services/index.ts +++ b/packages/common/src/services/index.ts @@ -2,6 +2,7 @@ export * from './backendUtility.service'; export * from './bindingEvent.service'; export * from './collection.service'; export * from './container.service'; +export * from './domUtilities'; export * from './excelExport.service'; export * from './extension.service'; export * from './filter.service'; diff --git a/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip b/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip index 3a61a18b8..b123bd6e5 100644 Binary files a/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip and b/packages/vanilla-bundle/dist-grid-bundle-zip/slickgrid-vanilla-bundle.zip differ