Skip to content

Commit

Permalink
feat(filters): build multiple-select options from native dom elements
Browse files Browse the repository at this point in the history
- instead of building the select dom element via a string and jQuery. We could instead create these select options as native DOM elements and from there create the jQuery element which the multiple-select.js lib requires. This rewrite should provide a decent performance improvement (especially if we have a huge set of options and will avoid allocating memory for a string that could be come extremly long to hold all the select options for example "<select><option value="true" label="True">True</option>...</select>")
  • Loading branch information
ghiscoding committed Jun 2, 2021
1 parent fbab810 commit aa548a9
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 160 deletions.
2 changes: 1 addition & 1 deletion packages/common/src/editors/__tests__/selectEditor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
Expand Down
85 changes: 18 additions & 67 deletions packages/common/src/editors/selectEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 += `<option value="${option}" label="${option}">${option}</option>`;
});
} 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 += `<option value="${optionValue}" label="${optionLabel}">${optionText}</option>`;
});
}
return `<select id="${this.elementName}" class="ms-filter search-filter select-editor editor-${columnId}" ${this.isMultipleSelect ? 'multiple="multiple"' : ''}>${options}</select>`;
// 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 */
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/filters/__tests__/selectFilter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
Expand Down
110 changes: 19 additions & 91 deletions packages/common/src/filters/selectFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 += `<option value="${option}" label="${option}" ${selected}>${option}</option>`;

// 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 += `<option value="${optionValue}" label="${optionLabel}" ${selected}>${optionText}</option>`;

// 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 `<select class="ms-filter search-filter filter-${columnId}" multiple="multiple">${options}</select>`;
}

/** 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 = {
Expand All @@ -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)
Expand All @@ -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.`);
}
Expand Down
Loading

0 comments on commit aa548a9

Please sign in to comment.