diff --git a/cypress/e2e/extensions/filter-control/options/multiple-select.cy.js b/cypress/e2e/extensions/filter-control/options/multiple-select.cy.js new file mode 100644 index 0000000000..a3603a77db --- /dev/null +++ b/cypress/e2e/extensions/filter-control/options/multiple-select.cy.js @@ -0,0 +1,63 @@ +describe('Filter Control Multiple Select', () => { + const baseUrl = '/for-tests/extensions/filter-control/' + + it('Test multiple select filter control - basic functionality', () => { + cy.visit(`${baseUrl}filter-control-multiple-select.html`) + .get('.table > thead > tr > th > .fht-cell > .filter-control') + .find('select[multiple]') + .should('exist') + .and('have.length.gte', 1) + }) + + it('Test multiple select - selecting multiple options filters correctly', () => { + cy.visit(`${baseUrl}filter-control-multiple-select.html`) + .wait(1000) + .get('.table > thead > tr > th > .fht-cell > .filter-control') + .find('select[multiple]') + .first() + .select(['Option1', 'Option2']) + .wait(1000) + .get('.table > tbody > tr') + .should('have.length.gte', 1) + .should('have.length.lte', 21) // Should be filtered + }) + + it('Test multiple select - clearing selection shows all rows', () => { + cy.visit(`${baseUrl}filter-control-multiple-select.html`) + .wait(1000) + .get('.table > thead > tr > th > .fht-cell > .filter-control') + .find('select[multiple]') + .first() + .select(['Option1']) + .wait(1000) + .get('.table > tbody > tr') + .then(($filteredRows) => { + const filteredCount = $filteredRows.length + + // Clear selection + cy.get('.table > thead > tr > th > .fht-cell > .filter-control') + .find('select[multiple]') + .first() + .select([]) + .wait(1000) + .get('.table > tbody > tr') + .should('have.length.gt', filteredCount) // Should show more rows + }) + }) + + it('Test multiple select with multiple-select.js plugin integration', () => { + cy.visit(`${baseUrl}filter-control-multiple-select-plugin.html`) + .wait(1000) + .get('.ms-parent') // multiple-select plugin creates this wrapper + .should('exist') + .and('have.length.gte', 1) + }) + + it('Test multiple select default values', () => { + cy.visit(`${baseUrl}filter-control-multiple-select-defaults.html`) + .wait(1000) + .get('.table > thead > tr > th > .fht-cell > .filter-control') + .find('select[multiple] option:selected') + .should('have.length.gte', 1) // Should have pre-selected options + }) +}) \ No newline at end of file diff --git a/site/docs/extensions/filter-control.md b/site/docs/extensions/filter-control.md index a7b090c3d9..77b6dbffe7 100644 --- a/site/docs/extensions/filter-control.md +++ b/site/docs/extensions/filter-control.md @@ -217,6 +217,36 @@ toc: true - **Default:** `undefined` +### filterControlMultipleSelect + +- **Attribute:** `data-filter-control-multiple-select` + +- **type:** `Boolean` + +- **Detail:** + + Set to `true` to enable multiple selection in select filter controls. When enabled, users can select multiple filter options simultaneously. This option only works with `filterControl: 'select'` and requires the [multiple-select.js](https://github.com/wenzhixin/multiple-select) plugin to be loaded for enhanced styling and functionality. + +- **Default:** `false` + +### filterControlMultipleSelectOptions + +- **Attribute:** `data-filter-control-multiple-select-options` + +- **type:** `Object` + +- **Detail:** + + Configuration options passed to the multiple-select.js plugin when `filterControlMultipleSelect` is enabled. Common options include: + - `placeholder`: Text to show when no options are selected + - `selectAll`: Whether to show a "Select All" option + - `selectAllText`: Text for the "Select All" option + - `countSelected`: Format string for showing selected count + + Example: `data-filter-control-multiple-select-options='{"placeholder": "Choose categories...", "selectAll": true}'` + +- **Default:** `{}` + ### filterDatepickerOptions - **Attribute:** `data-filter-datepicker-options` diff --git a/src/extensions/filter-control/bootstrap-table-filter-control.js b/src/extensions/filter-control/bootstrap-table-filter-control.js index 7b4b5f68ae..b702fefa3d 100644 --- a/src/extensions/filter-control/bootstrap-table-filter-control.js +++ b/src/extensions/filter-control/bootstrap-table-filter-control.js @@ -32,12 +32,16 @@ Object.assign($.fn.bootstrapTable.defaults, { }, select (that, column) { + const isMultiple = column.filterControlMultipleSelect + const multipleAttr = isMultiple ? 'multiple' : '' + const multipleClass = isMultiple ? 'multiple-select' : '' + return Utils.sprintf( '', UtilsFilterControl.getInputClass(that, true), column.field, - '', - '', + multipleClass, + multipleAttr, UtilsFilterControl.getDirectionOfSelectOptions( that.options.alignmentSelectControlOptions ) @@ -217,20 +221,31 @@ $.BootstrapTable = class extends $.BootstrapTable { keys.forEach(key => { const thisColumn = that.columns[that.fieldsColumnsIndex[key]] const rawFilterValue = filterPartial[key] || '' - let filterValue = rawFilterValue.toLowerCase() + let filterValue = Array.isArray(rawFilterValue) ? rawFilterValue : rawFilterValue.toLowerCase() let value = Utils.unescapeHTML(Utils.getItemField(item, key, false)) let tmpItemIsExpected - if (this.options.searchAccentNeutralise) { + if (this.options.searchAccentNeutralise && !Array.isArray(filterValue)) { filterValue = Utils.normalizeAccent(filterValue) } - let filterValues = [filterValue] + let filterValues = [] - if ( + // Handle multiple select values (arrays) + if (Array.isArray(rawFilterValue)) { + filterValues = rawFilterValue.map(val => { + let processedVal = val.toLowerCase() + if (this.options.searchAccentNeutralise) { + processedVal = Utils.normalizeAccent(processedVal) + } + return processedVal + }) + } else if ( this.options.filterControlMultipleSearch ) { filterValues = filterValue.split(this.options.filterControlMultipleSearchDelimiter) + } else { + filterValues = [filterValue] } filterValues.forEach(filterValue => { @@ -304,23 +319,47 @@ $.BootstrapTable = class extends $.BootstrapTable { value = Utils.normalizeAccent(value) } - if ( - column.filterStrictSearch || - column.filterControl === 'select' && column.passed.filterStrictSearch !== false - ) { - tmpItemIsExpected = value.toString().toLowerCase() === searchValue.toString().toLowerCase() - } else if (column.filterStartsWithSearch) { - tmpItemIsExpected = `${value}`.toLowerCase().indexOf(searchValue) === 0 - } else if (column.filterControl === 'datepicker') { - tmpItemIsExpected = new Date(value).getTime() === new Date(searchValue).getTime() - } else if (this.options.regexSearch) { - tmpItemIsExpected = Utils.regexCompare(value, searchValue) + // Handle multiple select values (when searchValue is an array) + if (Array.isArray(searchValue)) { + // For multiple select, use OR logic - match if value equals ANY selected option + tmpItemIsExpected = searchValue.some(singleSearchValue => { + if ( + column.filterStrictSearch || + column.filterControl === 'select' && column.passed.filterStrictSearch !== false + ) { + return value.toString().toLowerCase() === singleSearchValue.toString().toLowerCase() + } else if (column.filterStartsWithSearch) { + return `${value}`.toLowerCase().indexOf(singleSearchValue) === 0 + } else if (column.filterControl === 'datepicker') { + return new Date(value).getTime() === new Date(singleSearchValue).getTime() + } else if (this.options.regexSearch) { + return Utils.regexCompare(value, singleSearchValue) + } else { + return `${value}`.toLowerCase().includes(singleSearchValue) + } + }) } else { - tmpItemIsExpected = `${value}`.toLowerCase().includes(searchValue) + // Single value logic (existing behavior) + if ( + column.filterStrictSearch || + column.filterControl === 'select' && column.passed.filterStrictSearch !== false + ) { + tmpItemIsExpected = value.toString().toLowerCase() === searchValue.toString().toLowerCase() + } else if (column.filterStartsWithSearch) { + tmpItemIsExpected = `${value}`.toLowerCase().indexOf(searchValue) === 0 + } else if (column.filterControl === 'datepicker') { + tmpItemIsExpected = new Date(value).getTime() === new Date(searchValue).getTime() + } else if (this.options.regexSearch) { + tmpItemIsExpected = Utils.regexCompare(value, searchValue) + } else { + tmpItemIsExpected = `${value}`.toLowerCase().includes(searchValue) + } } - const largerSmallerEqualsRegex = /(?:(<=|=>|=<|>=|>|<)(?:\s+)?(\d+)?|(\d+)?(\s+)?(<=|=>|=<|>=|>|<))/gm - const matches = largerSmallerEqualsRegex.exec(searchValue) + // Handle numeric comparison operators (only for single values, not arrays) + if (!Array.isArray(searchValue)) { + const largerSmallerEqualsRegex = /(?:(<=|=>|=<|>=|>|<)(?:\s+)?(\d+)?|(\d+)?(\s+)?(<=|=>|=<|>=|>|<))/gm + const matches = largerSmallerEqualsRegex.exec(searchValue) if (matches) { const operator = matches[1] || `${matches[5]}l` @@ -353,6 +392,7 @@ $.BootstrapTable = class extends $.BootstrapTable { break } } + } if (column.filterCustomSearch) { const customSearchResult = Utils.calculateObjectValue(column, column.filterCustomSearch, [searchValue, value, key, this.options.data], true) @@ -504,12 +544,23 @@ $.BootstrapTable = class extends $.BootstrapTable { controls.forEach(element => { const $element = $(element) const elementValue = $element.val() - const text = elementValue ? elementValue.trim() : '' + let text = '' + + // Handle multiple select (array) vs single select (string) + if (Array.isArray(elementValue)) { + text = elementValue // Keep as array for multiple select + } else { + text = elementValue ? elementValue.trim() : '' + } + const $field = $element.closest('[data-field]').data('field') this.trigger('column-search', $field, text) - if (text) { + // Check if we have a valid filter value (string with content or non-empty array) + const hasValue = Array.isArray(text) ? text.length > 0 : (text && text.length > 0) + + if (hasValue) { this.filterColumnsPartial[$field] = text } else { delete this.filterColumnsPartial[$field] diff --git a/src/extensions/filter-control/utils.js b/src/extensions/filter-control/utils.js index a08184d129..b078a71d67 100644 --- a/src/extensions/filter-control/utils.js +++ b/src/extensions/filter-control/utils.js @@ -567,9 +567,58 @@ export function createControls (that, header) { header.find('.filter-control, .no-filter-control').hide() } + // Initialize multiple select controls if needed + initMultipleSelectControls(that, header) + that.trigger('created-controls') } +export function initMultipleSelectControls (that, header) { + $.each(that.columns, (_, column) => { + if (column.filterControlMultipleSelect && column.filterControl === 'select') { + const selectControl = header.find(`select.bootstrap-table-filter-control-${escapeID(column.field)}`) + + if (selectControl.length > 0 && typeof $.fn.multipleSelect === 'function') { + // Set flag that we're using multiple select + that._usingMultipleSelect = true + + // Initialize multiple-select plugin with options + const multipleSelectOptions = Object.assign({ + placeholder: column.filterControlPlaceholder || 'Choose...', + selectAll: true, + selectAllText: 'Select All', + allSelected: 'All selected', + countSelected: '# of % selected', + noMatchesFound: 'No matches found' + }, column.filterControlMultipleSelectOptions || {}) + + selectControl.multipleSelect(multipleSelectOptions) + + // Handle multiple select change events + selectControl.on('change', function () { + const $this = $(this) + const selectedValues = $this.val() || [] + + // Store the values for later use + that._valuesFilterControl = that._valuesFilterControl || [] + const existingIndex = that._valuesFilterControl.findIndex(item => item.field === column.field) + + if (existingIndex >= 0) { + that._valuesFilterControl[existingIndex].value = selectedValues + } else { + that._valuesFilterControl.push({ + field: column.field, + value: selectedValues, + position: 0, + hasFocus: false + }) + } + }) + } + } + }) +} + export function getDirectionOfSelectOptions (_alignment) { const alignment = _alignment === undefined ? 'left' : _alignment.toLowerCase()