diff --git a/client/src/css/stigman.css b/client/src/css/stigman.css index 9a1682d1b..5851884b8 100644 --- a/client/src/css/stigman.css +++ b/client/src/css/stigman.css @@ -2263,4 +2263,14 @@ td.x-grid3-hd-over .x-grid3-hd-inner { } .x-item-disabled { filter: saturate(0) +} + +.sm-match-case-icon { + background-image: url(../img/match-case.svg); + background-size: contain; +} + +.sm-match-word-icon { + background-image: url(../img/match-word.svg); + background-size: contain; } \ No newline at end of file diff --git a/client/src/img/match-case.svg b/client/src/img/match-case.svg new file mode 100644 index 000000000..ba5a84bcc --- /dev/null +++ b/client/src/img/match-case.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/img/match-word.svg b/client/src/img/match-word.svg new file mode 100644 index 000000000..b07a20eb0 --- /dev/null +++ b/client/src/img/match-word.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/client/src/js/SM/ColumnFilters.js b/client/src/js/SM/ColumnFilters.js index 27fca43ca..48bbc57e1 100644 --- a/client/src/js/SM/ColumnFilters.js +++ b/client/src/js/SM/ColumnFilters.js @@ -78,10 +78,9 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { // // iterate the menu items and set the condition(s) for each dataIndex for (const stringItem of stringItems) { - const dataIndex = stringItem.filter.dataIndex const value = stringItem.getValue() - if (value) { - conditions[dataIndex] = value + if (value.value) { + conditions[stringItem.filter.dataIndex] = value } } for (const selectItem of selectItems) { @@ -99,24 +98,32 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { for (const dataIndex of Object.keys(conditions)) { filterFns.push({ fn: function (record) { - const value = record.data[dataIndex] - if (Array.isArray(value)) { + const cellValue = record.data[dataIndex] + const condition = conditions[dataIndex] + if (Array.isArray(cellValue)) { // the record data is an Array of values - if (Array.isArray(conditions[dataIndex])) { - if (conditions[dataIndex].includes('') && value.length === 0) return true - return value.some( v => conditions[dataIndex].includes(v)) + if (Array.isArray(condition)) { + if (condition.includes('') && cellValue.length === 0) return true + return cellValue.some( v => condition.includes(v)) } } // the record data is a scalar value (we're missing object handling?) - if (Array.isArray(conditions[dataIndex])) { - return conditions[dataIndex].includes(value) + if (Array.isArray(condition)) { + return condition.includes(cellValue) } else { - // match case-insensitive condition anywhere in value - const a = value.toLowerCase() - const b = conditions[dataIndex].toLowerCase() - return a.indexOf(b) > -1 + // string matches + const a = condition.matchCase ? cellValue : cellValue.toLowerCase() + const b = condition.matchCase ? condition.value : condition.value.toLowerCase() + let found + if (condition.matchWord) { + found = a.search(new RegExp(`\\b${b}\\b`)) + } + else { + found = a.indexOf(b) + } + return condition.condition ? found > -1 : found === -1 } } }) @@ -126,14 +133,16 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { onFilterChange: function (item, value) { switch (item.filter.type) { case 'string': - item.column.filtered = !!(item.getValue()) + item.column.filtered = !!(item.getValue()?.value) break case 'values': - const hmenuItems = this.hmenu.items.items - const hmenuPeers = hmenuItems.filter( i => i.filter?.type === 'values' && i.filter?.dataIndex === item.filter.dataIndex) - const hmenuPeersChecked = hmenuPeers.map( i => i.checked) - item.column.filtered = hmenuPeersChecked.includes(false) - break + { + const hmenuItems = this.hmenu.items.items + const hmenuPeers = hmenuItems.filter( i => i.filter?.type === 'values' && i.filter?.dataIndex === item.filter.dataIndex) + const hmenuPeersChecked = hmenuPeers.map( i => i.checked) + item.column.filtered = hmenuPeersChecked.includes(false) + break + } case 'selectall': item.column.filtered = !(!!value) break @@ -273,33 +282,27 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { for (const col of this.cm.config) { switch (col.filter?.type) { - case 'string': + case 'string': { if (col.renderer) { col.configRenderer = col.renderer col.renderer = SM.ColumnFilters.Renderers.highlighterShim } - const stringItem = hmenu.add(new SM.ColumnFilters.SearchTextField({ - emptyText: "Contains...", - height: 24, + const stringItem = hmenu.add(new SM.ColumnFilters.StringPanel({ + hideOnClick: false, column: col, filter: { dataIndex: col.dataIndex, type: 'string'}, - enableKeyEvents: true, - hideParent: true, listeners: { - input: function (item, e) { - _this.onFilterChange(item, item.value) + filterchanged: function (panel) { + _this.onFilterChange(panel, panel.getValue()) }, - keyup: function (item, e) { - const k = e.getKey() - if (k == e.RETURN) { - e.stopEvent(); - hmenu.hide(true) - } + enterkey: function () { + hmenu.hide(true) } } })) hmenu.filterItems.stringItems.push(stringItem) break + } case 'values': dynamicColumns.push(col) break @@ -314,28 +317,143 @@ SM.ColumnFilters.extend = function extend (extended = Ext.grid.GridView) { SM.ColumnFilters.GridView = SM.ColumnFilters.extend(Ext.grid.GridView) SM.ColumnFilters.GridViewBuffered = SM.ColumnFilters.extend(Ext.ux.grid.BufferView) -SM.ColumnFilters.SearchTextField = Ext.extend(Ext.form.TextField, { +SM.ColumnFilters.StringMatchTextField = Ext.extend(Ext.form.TextField, { initComponent: function () { const config = { - autoCreate: {tag: 'input', type: 'search', size: '20', autocomplete: 'off'} + autoCreate: {tag: 'input', type: 'search', size: '20', autocomplete: 'off'}, + enableKeyEvents: true } Ext.apply(this, Ext.apply(this.initialConfig, config)) - SM.ColumnFilters.SearchTextField.superclass.initComponent.call(this) + this.superclass().initComponent.call(this) this.addEvents( 'input' ) }, initEvents: function () { - SM.ColumnFilters.SearchTextField.superclass.initEvents.call(this) + this.superclass().initEvents.call(this) this.mon(this.el, { scope: this, input: this.onInput }) }, onInput: function (e) { - this.column.filter.value = this.getValue() this.fireEvent('input', this, e); } }) +SM.ColumnFilters.StringMatchConditionComboBox = Ext.extend(Ext.form.ComboBox, { + initComponent: function () { + const store = new Ext.data.ArrayStore({ + fields: ['display', 'value'], + data: [['Includes', true], ['Excludes', false]] + }) + const config = { + listClass: 'x-menu', + store, + triggerAction: 'all', + mode: 'local', + editable: false, + valueField: 'value', + displayField: 'display' + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + this.setValue(true) + } +}) + +SM.ColumnFilters.StringMatchCaseButton = Ext.extend(Ext.Button, { + initComponent: function () { + const config = { + enableToggle: true, + border: false, + iconCls: 'sm-match-case-icon', + tooltip: 'Match case' + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.ColumnFilters.StringMatchWordButton = Ext.extend(Ext.Button, { + initComponent: function () { + const config = { + enableToggle: true, + border: false, + iconCls: 'sm-match-word-icon', + tooltip: 'Match word' + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + +SM.ColumnFilters.StringPanel = Ext.extend(Ext.Panel, { + initComponent: function () { + const _this = this + + const onFilterChange = function () { + _this.column.filter.value = getValue() + _this.fireEvent('filterchanged', _this) + } + + const conditionComboBox = new SM.ColumnFilters.StringMatchConditionComboBox({ + flex: 1, + listeners: { + select: onFilterChange + } + }) + const matchCaseButton = new SM.ColumnFilters.StringMatchCaseButton({ + width: 24, + listeners: { + toggle: onFilterChange + } + }) + const matchWordButton = new SM.ColumnFilters.StringMatchWordButton({ + width: 24, + listeners: { + toggle: onFilterChange + } + }) + const textfield = new SM.ColumnFilters.StringMatchTextField({ + height: 24, + listeners: { + input: onFilterChange, + keyup: function (item, e) { + const k = e.getKey() + if (k == e.RETURN) { + e.stopEvent() + _this.fireEvent('enterkey') + } + } + } + }) + + function getValue () { + return { + value: textfield.getValue() ?? '', + condition: conditionComboBox.getValue(), + matchCase: matchCaseButton.pressed, + matchWord: matchWordButton.pressed, + } + } + const config = { + getValue, + items: [ + { + layout: 'hbox', + items: [ + conditionComboBox, + matchCaseButton, + matchWordButton + ] + }, + textfield + ] + } + Ext.apply(this, Ext.apply(this.initialConfig, config)) + this.superclass().initComponent.call(this) + } +}) + SM.ColumnFilters.Scorers = { severity: { low: 2, @@ -347,15 +465,12 @@ SM.ColumnFilters.Scorers = { SM.ColumnFilters.CompareFns = { severity: (a, b) => { return SM.ColumnFilters.Scorers.severity[a] - SM.ColumnFilters.Scorers.severity[b] - }, - labels: (a, b) => { - }, labelIds: (a, b, collectionId) => { if (a === "") return -1; if (b === "") return 1; return SM.Cache.getCollectionLabel(collectionId, a).name.localeCompare(SM.Cache.getCollectionLabel(collectionId, b).name) - }, + } } SM.ColumnFilters.Renderers = { @@ -404,11 +519,15 @@ SM.ColumnFilters.Renderers = { } }, highlighterShim: function (v, m, r, ri, ci, s) { - if (this.filter?.type === 'string' && this.filter?.value) { - const re = new RegExp(SM.he(this.filter.value),'gi') - v = v.replace(re,'$&') + if (this.filter?.type === 'string' && this.filter.value?.value && this.filter.value.condition) { + let searchStr = SM.he(this.filter.value.value) + const flags = `g${this.filter.value.matchCase ? '' : 'i'}` + if (this.filter.value.matchWord) { + searchStr = `\\b${searchStr}\\b` + } + v = v.replace(new RegExp(searchStr, flags),'$&') } - return this.configRenderer ? this.configRenderer.call(this, v, m, r, ri, ci, s) : v + return this.configRenderer ? this.configRenderer(v, m, r, ri, ci, s) : v }, labels: function (labelId, collectionId) { if (!labelId) return '(No value)'