diff --git a/README.md b/README.md index 88e5a3ede..766538b35 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ npm run test:watch - [ ] Filters - [ ] Autocomplete - [ ] Compound Date - - [ ] Compound Input(s) - - [ ] Compound Slider + - [x] Compound Input(s) + - [x] Compound Slider - [ ] Date Range - [x] Input(s) - [x] Multiple Select diff --git a/packages/common/package.json b/packages/common/package.json index 5994248e5..63d96b650 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -24,8 +24,8 @@ "postbundle": "npm-run-all sass:build sass:copy", "bundle:amd": "cross-env tsc --project tsconfig.build.json --outDir dist/amd --module amd", "bundle:commonjs": "tsc --project tsconfig.build.json --outDir dist/commonjs --module commonjs", - "bundle:es2015": "cross-env tsc --project tsconfig.build.json --outDir dist/es2015 --module es2015 --target es2015", - "bundle:es2020": "cross-env tsc --project tsconfig.build.json --outDir dist/es2020 --module es2015 --target es2020", + "bundle:es2015": "cross-env tsc --project tsconfig.build.json --outDir dist/es2015 --module es2020 --target es2015", + "bundle:es2020": "cross-env tsc --project tsconfig.build.json --outDir dist/es2020 --module es2020 --target es2020", "bundle:native-modules": "cross-env tsc --project tsconfig.build.json --outDir dist/native-modules --module es2015", "bundle:system": "cross-env tsc --project tsconfig.build.json --outDir dist/system --module system", "delete:dist": "cross-env rimraf dist", diff --git a/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts b/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts index b13b8d60d..0a2d7d1f3 100644 --- a/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts +++ b/packages/common/src/extensions/__tests__/checkboxSelectorExtension.spec.ts @@ -60,7 +60,10 @@ describe('checkboxSelectorExtension', () => { let columnsMock: Column[]; beforeEach(() => { - columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }]; + columnsMock = [ + { id: 'field1', field: 'field1', width: 100, cssClass: 'red' }, + { id: 'field2', field: 'field2', width: 50 } + ]; columnSelectionMock = { id: '_checkbox_selector', field: 'sel' }; jest.spyOn(SharedService.prototype, 'grid', 'get').mockReturnValue(gridStub); jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); @@ -123,6 +126,36 @@ describe('checkboxSelectorExtension', () => { expect(mockSelectionModel).toHaveBeenCalledWith(optionMock); expect(columnsMock[0]).toEqual(selectionColumn); + expect(columnsMock).toEqual([ + { excludeFromColumnPicker: true, excludeFromExport: true, excludeFromGridMenu: true, excludeFromHeaderMenu: true, excludeFromQuery: true, field: 'sel', id: '_checkbox_selector', }, + { cssClass: 'red', field: 'field1', id: 'field1', width: 100, }, + { field: 'field2', id: 'field2', width: 50, } + ]); + }); + + it('should be able to change the position of the checkbox column to another column index position in the grid', () => { + const rowSelectionOptionMock = { selectActiveRow: true }; + gridOptionsMock.checkboxSelector = { columnIndexPosition: 2, }; + const selectionModelOptions = { ...gridOptionsMock, rowSelectionOptions: rowSelectionOptionMock }; + const selectionColumn = { ...columnSelectionMock, excludeFromExport: true, excludeFromColumnPicker: true, excludeFromGridMenu: true, excludeFromQuery: true, excludeFromHeaderMenu: true }; + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(selectionModelOptions); + + // we can only spy after 1st "create" call, we'll only get a valid selectionColumn on 2nd "create" call + const instance = extension.create(columnsMock, gridOptionsMock); + jest.spyOn(instance, 'getColumnDefinition').mockReturnValue(columnSelectionMock); + expect(columnsMock[0]).not.toEqual(selectionColumn); + + // do our expect here after 2nd "create" call, the selectionColumn flags will change only after this 2nd call + extension.create(columnsMock, gridOptionsMock); + extension.register(); + + expect(mockSelectionModel).toHaveBeenCalledWith(rowSelectionOptionMock); + expect(columnsMock[2]).toEqual(selectionColumn); + expect(columnsMock).toEqual([ + { cssClass: 'red', field: 'field1', id: 'field1', width: 100, }, + { field: 'field2', id: 'field2', width: 50, }, + { excludeFromColumnPicker: true, excludeFromExport: true, excludeFromGridMenu: true, excludeFromHeaderMenu: true, excludeFromQuery: true, field: 'sel', id: '_checkbox_selector', }, + ]); }); it('should be able to pre-select rows', (done) => { @@ -143,7 +176,7 @@ describe('checkboxSelectorExtension', () => { setTimeout(() => { expect(rowSpy).toHaveBeenCalledWith(selectionModelOptions.preselectedRows); done(); - }); + }, 0); }); }); }); diff --git a/packages/common/src/extensions/checkboxSelectorExtension.ts b/packages/common/src/extensions/checkboxSelectorExtension.ts index 5ca83b3bb..9e3e9da59 100644 --- a/packages/common/src/extensions/checkboxSelectorExtension.ts +++ b/packages/common/src/extensions/checkboxSelectorExtension.ts @@ -35,7 +35,14 @@ export class CheckboxSelectorExtension implements Extension { selectionColumn.excludeFromGridMenu = true; selectionColumn.excludeFromQuery = true; selectionColumn.excludeFromHeaderMenu = true; - columnDefinitions.unshift(selectionColumn); + + // column index position in the grid + const columnPosition = gridOptions?.checkboxSelector?.columnIndexPosition || 0; + if (columnPosition > 0) { + columnDefinitions.splice(columnPosition, 0, selectionColumn); + } else { + columnDefinitions.unshift(selectionColumn); + } } return this._addon; } diff --git a/packages/common/src/filters/__tests__/compoundInputFilter.spec.ts b/packages/common/src/filters/__tests__/compoundInputFilter.spec.ts new file mode 100644 index 000000000..6fdefe838 --- /dev/null +++ b/packages/common/src/filters/__tests__/compoundInputFilter.spec.ts @@ -0,0 +1,265 @@ +import { FieldType, OperatorType } from '../../enums/index'; +import { Column, FilterArguments, GridOption } from '../../interfaces/index'; +import { Filters } from '../index'; +import { CompoundInputFilter } from '../compoundInputFilter'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const gridOptionMock = { + enableFiltering: true, +} as GridOption; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getHeaderRowColumn: jest.fn(), + render: jest.fn(), +}; + +describe('CompoundInputFilter', () => { + let translateService: TranslateServiceStub; + let divContainer: HTMLDivElement; + let filter: CompoundInputFilter; + let filterArguments: FilterArguments; + let spyGetHeaderRow; + let mockColumn: Column; + + beforeEach(() => { + translateService = new TranslateServiceStub(); + + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + spyGetHeaderRow = jest.spyOn(gridStub, 'getHeaderRowColumn').mockReturnValue(divContainer); + + mockColumn = { id: 'duration', field: 'duration', filterable: true, filter: { model: Filters.input, operator: 'EQ' } }; + filterArguments = { + grid: gridStub, + columnDef: mockColumn, + callback: jest.fn() + }; + + filter = new CompoundInputFilter(translateService); + }); + + afterEach(() => { + filter.destroy(); + }); + + it('should throw an error when trying to call init without any arguments', () => { + expect(() => filter.init(null)).toThrowError('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + }); + + it('should initialize the filter', () => { + filter.init(filterArguments); + const filterCount = divContainer.querySelectorAll('.search-filter.filter-duration').length; + + expect(spyGetHeaderRow).toHaveBeenCalled(); + expect(filterCount).toBe(1); + expect(filter.inputType).toBe('text'); + }); + + it('should have a placeholder when defined in its column definition', () => { + const testValue = 'test placeholder'; + mockColumn.filter.placeholder = testValue; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + expect(filterInputElm.placeholder).toBe(testValue); + }); + + it('should call "setValues" and expect that value to be in the callback when triggered', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + filter.setValues(['abc']); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + filterInputElm.focus(); + filterInputElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); + const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); + + expect(filterFilledElms.length).toBe(1); + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['abc'], shouldTriggerQuery: true }); + }); + + it('should call "setValues" with "operator" set in the filter arguments and expect that value to be in the callback when triggered', () => { + mockColumn.type = FieldType.number; + const filterArgs = { ...filterArguments, operator: '>' } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArgs); + filter.setValues(['9']); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + filterInputElm.focus(); + filterInputElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['9'], shouldTriggerQuery: true }); + }); + + it('should be able to call "setValues" with a value and an extra operator and expect it to be set as new operator', () => { + mockColumn.type = FieldType.number; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + filter.setValues(['9'], OperatorType.greaterThanOrEqual); + + const filterSelectElm = divContainer.querySelector('.search-filter.filter-duration select'); + filterSelectElm.dispatchEvent(new CustomEvent('change')); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>=', searchTerms: ['9'], shouldTriggerQuery: true }); + expect(filterSelectElm.value).toBe('>='); + }); + + it('should trigger an operator change event and expect the callback to be called with the searchTerms and operator defined', () => { + mockColumn.type = FieldType.number; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + filter.setValues(['9']); + const filterSelectElm = divContainer.querySelector('.search-filter.filter-duration select'); + + filterSelectElm.value = '<='; + filterSelectElm.dispatchEvent(new CustomEvent('change')); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: ['9'], shouldTriggerQuery: true }); + }); + + it('should call "setValues" with extra spaces at the beginning of the searchTerms and trim value when "enableFilterTrimWhiteSpace" is enabled in grid options', () => { + gridOptionMock.enableFilterTrimWhiteSpace = true; + mockColumn.type = FieldType.number; + const filterArgs = { ...filterArguments, operator: '>' } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArgs); + filter.setValues([' 987 ']); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + filterInputElm.focus(); + filterInputElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['987'], shouldTriggerQuery: true }); + }); + + it('should call "setValues" with extra spaces at the beginning of the searchTerms and trim value when "enableTrimWhiteSpace" is enabled in the column filter', () => { + gridOptionMock.enableFilterTrimWhiteSpace = false; + mockColumn.filter.enableTrimWhiteSpace = true; + mockColumn.type = FieldType.number; + const filterArgs = { ...filterArguments, operator: '>' } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArgs); + filter.setValues([' 987 ']); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + filterInputElm.focus(); + filterInputElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['987'], shouldTriggerQuery: true }); + }); + + it('should trigger the callback method when user types something in the input', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + filterInputElm.focus(); + filterInputElm.value = 'a'; + filterInputElm.dispatchEvent(new (window.window as any).KeyboardEvent('keyup', { keyCode: 97, bubbles: true, cancelable: true })); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '', searchTerms: ['a'], shouldTriggerQuery: true }); + }); + + it('should create the input filter with a default search term when passed as a filter argument', () => { + filterArguments.searchTerms = ['xyz']; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + expect(filterInputElm.value).toBe('xyz'); + }); + + it('should expect the input not to have the "filled" css class when the search term provided is an empty string', () => { + filterArguments.searchTerms = ['']; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); + + expect(filterInputElm.value).toBe(''); + expect(filterFilledElms.length).toBe(0); + }); + + it('should create the input filter with operator dropdown options related to numbers when column definition type is FieldType.number', () => { + mockColumn.type = FieldType.number; + filterArguments.searchTerms = ['9']; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + const filterSelectElm = divContainer.querySelectorAll('.search-filter.filter-duration select'); + + expect(filterInputElm.value).toBe('9'); + expect(filterSelectElm[0][1].title).toBe('='); + expect(filterSelectElm[0][1].textContent).toBe('='); + expect(filterSelectElm[0][2].textContent).toBe('<'); + expect(filterSelectElm[0][3].textContent).toBe('<='); + expect(filterSelectElm[0][4].textContent).toBe('>'); + expect(filterSelectElm[0][5].textContent).toBe('>='); + expect(filterSelectElm[0][6].textContent).toBe('<>'); + }); + + it('should create the input filter with operator dropdown options related to strings when column definition type is FieldType.string', () => { + mockColumn.type = FieldType.string; + filterArguments.searchTerms = ['xyz']; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + const filterSelectElm = divContainer.querySelectorAll('.search-filter.filter-duration select'); + + expect(filterInputElm.value).toBe('xyz'); + expect(filterSelectElm[0][0].title).toBe('Contains'); + expect(filterSelectElm[0][1].title).toBe('Equals'); + expect(filterSelectElm[0][2].title).toBe('Starts With'); + expect(filterSelectElm[0][3].title).toBe('Ends With'); + expect(filterSelectElm[0][1].textContent).toBe('='); + expect(filterSelectElm[0][2].textContent).toBe('a*'); + expect(filterSelectElm[0][3].textContent).toBe('*z'); + }); + + it('should trigger a callback with the clear filter set when calling the "clear" method', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + filterArguments.searchTerms = ['xyz']; + + filter.init(filterArguments); + filter.clear(); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); + + + expect(filterInputElm.value).toBe(''); + expect(filterFilledElms.length).toBe(0); + expect(spyCallback).toHaveBeenCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: true }); + }); + + it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + filterArguments.searchTerms = ['xyz']; + + filter.init(filterArguments); + filter.clear(false); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + const filterFilledElms = divContainer.querySelectorAll('.search-filter.filter-duration.filled'); + + + expect(filterInputElm.value).toBe(''); + expect(filterFilledElms.length).toBe(0); + expect(spyCallback).toHaveBeenCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + }); +}); diff --git a/packages/common/src/filters/__tests__/compoundInputNumberFilter.spec.ts b/packages/common/src/filters/__tests__/compoundInputNumberFilter.spec.ts new file mode 100644 index 000000000..8423568a6 --- /dev/null +++ b/packages/common/src/filters/__tests__/compoundInputNumberFilter.spec.ts @@ -0,0 +1,65 @@ +import { Column, FilterArguments, GridOption } from '../../interfaces/index'; +import { Filters } from '../index'; +import { CompoundInputNumberFilter } from '../compoundInputNumberFilter'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const gridOptionMock = { + enableFiltering: true, + enableFilterTrimWhiteSpace: true, +} as GridOption; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getHeaderRowColumn: jest.fn(), + render: jest.fn(), +}; + +describe('CompoundInputNumberFilter', () => { + let translateService: TranslateServiceStub; + let divContainer: HTMLDivElement; + let filter: CompoundInputNumberFilter; + let filterArguments: FilterArguments; + let spyGetHeaderRow; + let mockColumn: Column; + + beforeEach(() => { + translateService = new TranslateServiceStub(); + + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + spyGetHeaderRow = jest.spyOn(gridStub, 'getHeaderRowColumn').mockReturnValue(divContainer); + + mockColumn = { id: 'duration', field: 'duration', filterable: true, filter: { model: Filters.input, operator: 'EQ' } }; + filterArguments = { + grid: gridStub, + columnDef: mockColumn, + callback: jest.fn() + }; + + filter = new CompoundInputNumberFilter(translateService); + }); + + afterEach(() => { + filter.destroy(); + }); + + it('should throw an error when trying to call init without any arguments', () => { + expect(() => filter.init(null)).toThrowError('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + }); + + it('should initialize the filter and expect an input of type number', () => { + filter.init(filterArguments); + const filterCount = divContainer.querySelectorAll('.search-filter.filter-duration').length; + + expect(spyGetHeaderRow).toHaveBeenCalled(); + expect(filterCount).toBe(1); + expect(filter.inputType).toBe('number'); + }); +}); diff --git a/packages/common/src/filters/__tests__/compoundInputPasswordFilter.spec.ts b/packages/common/src/filters/__tests__/compoundInputPasswordFilter.spec.ts new file mode 100644 index 000000000..35e566d86 --- /dev/null +++ b/packages/common/src/filters/__tests__/compoundInputPasswordFilter.spec.ts @@ -0,0 +1,65 @@ +import { Column, FilterArguments, GridOption } from '../../interfaces/index'; +import { Filters } from '../index'; +import { CompoundInputPasswordFilter } from '../compoundInputPasswordFilter'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const gridOptionMock = { + enableFiltering: true, + enableFilterTrimWhiteSpace: true, +} as GridOption; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getHeaderRowColumn: jest.fn(), + render: jest.fn(), +}; + +describe('CompoundInputPasswordFilter', () => { + let translateService: TranslateServiceStub; + let divContainer: HTMLDivElement; + let filter: CompoundInputPasswordFilter; + let filterArguments: FilterArguments; + let spyGetHeaderRow; + let mockColumn: Column; + + beforeEach(() => { + translateService = new TranslateServiceStub(); + + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + spyGetHeaderRow = jest.spyOn(gridStub, 'getHeaderRowColumn').mockReturnValue(divContainer); + + mockColumn = { id: 'duration', field: 'duration', filterable: true, filter: { model: Filters.input, operator: 'EQ' } }; + filterArguments = { + grid: gridStub, + columnDef: mockColumn, + callback: jest.fn() + }; + + filter = new CompoundInputPasswordFilter(translateService); + }); + + afterEach(() => { + filter.destroy(); + }); + + it('should throw an error when trying to call init without any arguments', () => { + expect(() => filter.init(null)).toThrowError('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + }); + + it('should initialize the filter and expect an input of type password', () => { + filter.init(filterArguments); + const filterCount = divContainer.querySelectorAll('.search-filter.filter-duration').length; + + expect(spyGetHeaderRow).toHaveBeenCalled(); + expect(filterCount).toBe(1); + expect(filter.inputType).toBe('password'); + }); +}); diff --git a/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts b/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts new file mode 100644 index 000000000..6bad110e5 --- /dev/null +++ b/packages/common/src/filters/__tests__/compoundSliderFilter.spec.ts @@ -0,0 +1,235 @@ +import { OperatorType } from '../../enums/index'; +import { Column, FilterArguments, GridOption } from '../../interfaces/index'; +import { Filters } from '../index'; +import { CompoundSliderFilter } from '../compoundSliderFilter'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const gridOptionMock = { + enableFiltering: true, + enableFilterTrimWhiteSpace: true, +} as GridOption; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getHeaderRowColumn: jest.fn(), + render: jest.fn(), +}; + +describe('CompoundSliderFilter', () => { + let divContainer: HTMLDivElement; + let filter: CompoundSliderFilter; + let filterArguments: FilterArguments; + let spyGetHeaderRow; + let mockColumn: Column; + + beforeEach(() => { + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + spyGetHeaderRow = jest.spyOn(gridStub, 'getHeaderRowColumn').mockReturnValue(divContainer); + + mockColumn = { id: 'duration', field: 'duration', filterable: true, filter: { model: Filters.compoundSlider } }; + filterArguments = { + grid: gridStub, + columnDef: mockColumn, + callback: jest.fn() + }; + + filter = new CompoundSliderFilter(); + }); + + afterEach(() => { + filter.destroy(); + }); + + it('should throw an error when trying to call init without any arguments', () => { + expect(() => filter.init(null)).toThrowError('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + }); + + it('should initialize the filter', () => { + filter.init(filterArguments); + const filterCount = divContainer.querySelectorAll('.search-filter.slider-container.filter-duration').length; + + expect(spyGetHeaderRow).toHaveBeenCalled(); + expect(filterCount).toBe(1); + }); + + it('should call "setValues" with "operator" set in the filter arguments and expect that value to be in the callback when triggered', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + const filterArgs = { ...filterArguments, operator: '>' } as FilterArguments; + + filter.init(filterArgs); + filter.setValues(['2']); + const filterElm = divContainer.querySelector('.input-group.search-filter.filter-duration input'); + filterElm.dispatchEvent(new CustomEvent('change')); + + expect(spyCallback).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>', searchTerms: ['2'], shouldTriggerQuery: true }); + }); + + it('should call "setValues" with "operator" set in the filter arguments and expect that value, converted as a string, to be in the callback when triggered', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + const filterArgs = { ...filterArguments, operator: '<=' } as FilterArguments; + + filter.init(filterArgs); + filter.setValues(3); + const filterElm = divContainer.querySelector('.input-group.search-filter.filter-duration input'); + filterElm.dispatchEvent(new CustomEvent('change')); + const filterFilledElms = divContainer.querySelectorAll('.slider-container.search-filter.filter-duration.filled'); + + expect(filterFilledElms.length).toBe(1); + expect(spyCallback).toHaveBeenLastCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: ['3'], shouldTriggerQuery: true }); + }); + + it('should trigger an operator change event and expect the callback to be called with the searchTerms and operator defined', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + filter.setValues(9); + const filterSelectElm = divContainer.querySelector('.search-filter.filter-duration select'); + + filterSelectElm.value = '<='; + filterSelectElm.dispatchEvent(new CustomEvent('change')); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '<=', searchTerms: ['9'], shouldTriggerQuery: true }); + }); + + it('should be able to call "setValues" with a value and an extra operator and expect it to be set as new operator', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArguments); + filter.setValues(['9'], OperatorType.greaterThanOrEqual); + + const filterSelectElm = divContainer.querySelector('.search-filter.filter-duration select'); + filterSelectElm.dispatchEvent(new CustomEvent('change')); + + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: '>=', searchTerms: ['9'], shouldTriggerQuery: true }); + }); + + it('should create the input filter with default search terms range when passed as a filter argument', () => { + const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [3] } as FilterArguments; + + filter.init(filterArgs); + const filterNumberElm = divContainer.querySelector('.input-group-text'); + const filterFilledElms = divContainer.querySelectorAll('.slider-container.search-filter.filter-duration.filled'); + + expect(filterFilledElms.length).toBe(1); + expect(filterNumberElm.textContent).toBe('3'); + expect(filter.getValues()).toEqual(3); + }); + + it('should create the input filter with default search terms and a different step size when "valueStep" is provided', () => { + const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [15] } as FilterArguments; + mockColumn.filter.valueStep = 5; + + filter.init(filterArgs); + const filterNumberElm = divContainer.querySelector('.input-group-text'); + const filterInputElm = divContainer.querySelector('.search-filter.filter-duration input'); + + expect(filterInputElm.step).toBe('5'); + expect(filterNumberElm.textContent).toBe('15'); + expect(filter.getValues()).toEqual(15); + }); + + it('should create the input filter with min slider values being set by filter "minValue"', () => { + mockColumn.filter = { + minValue: 4, + maxValue: 69, + }; + + filter.init(filterArguments); + + const filterNumberElm = divContainer.querySelector('.input-group-text'); + + expect(filterNumberElm.textContent).toBe('4'); + expect(filter.getValues()).toEqual(4); + }); + + it('should create the input filter with min/max slider values being set by filter "sliderStartValue" and "sliderEndValue" through the filter params', () => { + mockColumn.filter = { + params: { + sliderStartValue: 4, + sliderEndValue: 69, + } + }; + + filter.init(filterArguments); + + const filterNumberElm = divContainer.querySelector('.input-group-text'); + + expect(filterNumberElm.textContent).toBe('4'); + expect(filter.getValues()).toEqual(4); + }); + + it('should create the input filter with default search terms range but without showing side numbers when "hideSliderNumber" is set in params', () => { + filterArguments.searchTerms = [3]; + mockColumn.filter.params = { hideSliderNumber: true }; + + filter.init(filterArguments); + + const filterNumberElms = divContainer.querySelectorAll('.input-group-text'); + + expect(filterNumberElms.length).toBe(0); + expect(filter.getValues()).toEqual(3); + }); + + it('should trigger a callback with the clear filter set when calling the "clear" method', () => { + const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [3] } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArgs); + filter.clear(); + + expect(filter.getValues()).toBe(0); + expect(spyCallback).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: true }); + }); + + it('should trigger a callback with the clear filter but without querying when when calling the "clear" method with False as argument', () => { + const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [3] } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + + filter.init(filterArgs); + filter.clear(false); + + expect(filter.getValues()).toBe(0); + expect(spyCallback).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + }); + + it('should trigger a callback with the clear filter set when calling the "clear" method and expect min slider values being with values of "sliderStartValue" when defined through the filter params', () => { + const filterArgs = { ...filterArguments, operator: '<=', searchTerms: [3] } as FilterArguments; + const spyCallback = jest.spyOn(filterArguments, 'callback'); + mockColumn.filter = { + params: { + sliderStartValue: 4, + sliderEndValue: 69, + } + }; + + filter.init(filterArgs); + filter.clear(false); + + expect(filter.getValues()).toEqual(4); + expect(spyCallback).toHaveBeenLastCalledWith(undefined, { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); + }); + + it('should create the input filter with all available operators in a select dropdown options as a prepend element', () => { + filterArguments.searchTerms = ['9']; + + filter.init(filterArguments); + const filterInputElm = divContainer.querySelector('.input-group.search-filter.filter-duration input'); + const filterSelectElm = divContainer.querySelectorAll('.search-filter.filter-duration select'); + + expect(filterInputElm.value).toBe('9'); + expect(filterSelectElm[0][1].title).toBe('='); + expect(filterSelectElm[0][1].textContent).toBe('='); + expect(filterSelectElm[0][2].textContent).toBe('<'); + expect(filterSelectElm[0][3].textContent).toBe('<='); + expect(filterSelectElm[0][4].textContent).toBe('>'); + expect(filterSelectElm[0][5].textContent).toBe('>='); + expect(filterSelectElm[0][6].textContent).toBe('<>'); + }); +}); diff --git a/packages/common/src/filters/compoundInputFilter.ts b/packages/common/src/filters/compoundInputFilter.ts new file mode 100644 index 000000000..9315923db --- /dev/null +++ b/packages/common/src/filters/compoundInputFilter.ts @@ -0,0 +1,264 @@ +import { Constants } from '../constants'; +import { FieldType, OperatorString, OperatorType, SearchTerm, } from '../enums/index'; +import { + Column, + ColumnFilter, + Filter, + FilterArguments, + FilterCallback, + GridOption, + Locale, +} from '../interfaces/index'; +import { mapOperatorToShorthandDesignation } from '../services/utilities'; +import { TranslaterService } from '../services/translater.service'; + +// using external non-typed js libraries +declare const $: any; + +export class CompoundInputFilter implements Filter { + private _clearFilterTriggered = false; + private _shouldTriggerQuery = true; + private _inputType = 'text'; + private _locales: Locale; + private $filterElm: any; + private $filterInputElm: any; + private $selectOperatorElm: any; + private _operator: OperatorType | OperatorString; + grid: any; + searchTerms: SearchTerm[]; + columnDef: Column; + callback: FilterCallback; + + constructor(protected translaterService: TranslaterService) { } + + /** Getter for the Grid Options pulled through the Grid Object */ + private get gridOptions(): GridOption { + return (this.grid && this.grid.getOptions) ? this.grid.getOptions() : {}; + } + + /** Getter for the Filter Operator */ + get columnFilter(): ColumnFilter { + return this.columnDef && this.columnDef.filter || {}; + } + + /** Getter to know what would be the default operator when none is specified */ + get defaultOperator(): OperatorType | OperatorString { + return OperatorType.empty; + } + + /** Getter of input type (text, number, password) */ + get inputType() { + return this._inputType; + } + + /** Setter of input type (text, number, password) */ + set inputType(type: string) { + this._inputType = type; + } + + /** Getter of the Operator to use when doing the filter comparing */ + get operator(): OperatorType | OperatorString { + return this._operator || this.defaultOperator; + } + + /** Setter of the Operator to use when doing the filter comparing */ + set operator(op: OperatorType | OperatorString) { + this._operator = op; + } + + /** + * Initialize the Filter + */ + init(args: FilterArguments) { + if (!args) { + throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + } + + this.grid = args.grid; + this.callback = args.callback; + this.columnDef = args.columnDef; + this.operator = args.operator || ''; + this.searchTerms = (args.hasOwnProperty('searchTerms') ? args.searchTerms : []) || []; + + // get locales provided by user in main file or else use default English locales via the Constants + this._locales = this.gridOptions && this.gridOptions.locales || Constants.locales; + + // filter input can only have 1 search term, so we will use the 1st array index if it exist + const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; + + // step 1, create the DOM Element of the filter which contain the compound Operator+Input + // and initialize it if searchTerm is filled + this.$filterElm = this.createDomElement(searchTerm); + + // step 3, subscribe to the keyup event and run the callback when that happens + // also add/remove "filled" class for styling purposes + this.$filterInputElm.on('keyup input change', (e: any) => { + this.onTriggerEvent(e); + }); + this.$selectOperatorElm.on('change', (e: any) => { + this.onTriggerEvent(e); + }); + } + + /** + * Clear the filter value + */ + clear(shouldTriggerQuery = true) { + if (this.$filterElm && this.$selectOperatorElm) { + this._clearFilterTriggered = true; + this._shouldTriggerQuery = shouldTriggerQuery; + this.searchTerms = []; + this.$selectOperatorElm.val(0); + this.$filterInputElm.val(''); + this.onTriggerEvent(undefined); + } + } + + /** + * destroy the filter + */ + destroy() { + if (this.$filterElm && this.$selectOperatorElm) { + this.$filterElm.off('keyup input change').remove(); + this.$selectOperatorElm.off('change'); + } + } + + /** Set value(s) on the DOM element */ + setValues(values: SearchTerm[], operator?: OperatorType | OperatorString) { + if (values) { + const newValue = Array.isArray(values) ? values[0] : values; + this.$filterInputElm.val(newValue); + } + + // set the operator, in the DOM as well, when defined + this.operator = operator || this.defaultOperator; + if (operator && this.$selectOperatorElm) { + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this.$selectOperatorElm.val(operatorShorthand); + } + } + + // + // private functions + // ------------------ + + private buildInputHtmlString() { + const columnId = this.columnDef && this.columnDef.id; + let placeholder = (this.gridOptions) ? (this.gridOptions.defaultFilterPlaceholder || '') : ''; + if (this.columnFilter && this.columnFilter.placeholder) { + placeholder = this.columnFilter.placeholder; + } + return ``; + } + + private buildSelectOperatorHtmlString() { + const optionValues = this.getOptionValues(); + let optionValueString = ''; + optionValues.forEach((option) => { + optionValueString += ``; + }); + + return ``; + } + + private getOptionValues(): { operator: OperatorString, description: string }[] { + const type = (this.columnDef.type && this.columnDef.type) ? this.columnDef.type : FieldType.string; + let optionValues = []; + + switch (type) { + case FieldType.string: + optionValues = [ + { operator: '' as OperatorString, description: this.translaterService?.getCurrentLocale() && this.translaterService?.translate('CONTAINS') || this._locales?.TEXT_CONTAINS }, + { operator: '=' as OperatorString, description: this.translaterService?.getCurrentLocale() && this.translaterService?.translate('EQUALS') || this._locales?.TEXT_EQUALS }, + { operator: 'a*' as OperatorString, description: this.translaterService?.getCurrentLocale() && this.translaterService?.translate('STARTS_WITH') || this._locales?.TEXT_STARTS_WITH }, + { operator: '*z' as OperatorString, description: this.translaterService?.getCurrentLocale() && this.translaterService?.translate('ENDS_WITH') || this._locales?.TEXT_ENDS_WITH }, + ]; + break; + default: + optionValues = [ + { operator: '' as OperatorString, description: '' }, + { operator: '=' as OperatorString, description: '=' }, + { operator: '<' as OperatorString, description: '<' }, + { operator: '<=' as OperatorString, description: '<=' }, + { operator: '>' as OperatorString, description: '>' }, + { operator: '>=' as OperatorString, description: '>=' }, + { operator: '<>' as OperatorString, description: '<>' } + ]; + break; + } + + return optionValues; + } + + /** + * Create the DOM element + */ + private createDomElement(searchTerm?: SearchTerm) { + const columnId = this.columnDef && this.columnDef.id; + const $headerElm = this.grid.getHeaderRowColumn(columnId); + $($headerElm).empty(); + + // create the DOM Select dropdown for the Operator + this.$selectOperatorElm = $(this.buildSelectOperatorHtmlString()); + this.$filterInputElm = $(this.buildInputHtmlString()); + const $filterContainerElm = $(`
`); + const $containerInputGroup = $(`
`); + const $operatorInputGroupAddon = $(`
`); + + /* the DOM element final structure will be +
+
+ +
+ +
+ */ + $operatorInputGroupAddon.append(this.$selectOperatorElm); + $containerInputGroup.append($operatorInputGroupAddon); + $containerInputGroup.append(this.$filterInputElm); + + // create the DOM element & add an ID and filter class + $filterContainerElm.append($containerInputGroup); + + this.$filterInputElm.val(searchTerm); + this.$filterInputElm.data('columnId', columnId); + + if (this.operator) { + this.$selectOperatorElm.val(this.operator); + } + + // if there's a search term, we will add the "filled" class for styling purposes + if (searchTerm) { + $filterContainerElm.addClass('filled'); + } + + // append the new DOM element to the header row + if ($filterContainerElm && typeof $filterContainerElm.appendTo === 'function') { + $filterContainerElm.appendTo($headerElm); + } + + return $filterContainerElm; + } + + /** Event trigger, could be called by the Operator dropdown or the input itself */ + private onTriggerEvent(e: Event | undefined) { + if (this._clearFilterTriggered) { + this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); + this.$filterElm.removeClass('filled'); + } else { + const selectedOperator = this.$selectOperatorElm.find('option:selected').text(); + let value = this.$filterInputElm.val() as string; + const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; + if (typeof value === 'string' && enableWhiteSpaceTrim) { + value = value.trim(); + } + + (value !== null && value !== undefined && value !== '') ? this.$filterElm.addClass('filled') : this.$filterElm.removeClass('filled'); + this.callback(e, { columnDef: this.columnDef, searchTerms: (value ? [value] : null), operator: selectedOperator || '', shouldTriggerQuery: this._shouldTriggerQuery }); + } + // reset both flags for next use + this._clearFilterTriggered = false; + this._shouldTriggerQuery = true; + } +} diff --git a/packages/common/src/filters/compoundInputNumberFilter.ts b/packages/common/src/filters/compoundInputNumberFilter.ts new file mode 100644 index 000000000..a176340e6 --- /dev/null +++ b/packages/common/src/filters/compoundInputNumberFilter.ts @@ -0,0 +1,10 @@ +import { TranslaterService } from '../services/translater.service'; +import { CompoundInputFilter } from './compoundInputFilter'; + +export class CompoundInputNumberFilter extends CompoundInputFilter { + /** Initialize the Filter */ + constructor(protected translaterService: TranslaterService) { + super(translaterService); + this.inputType = 'number'; + } +} diff --git a/packages/common/src/filters/compoundInputPasswordFilter.ts b/packages/common/src/filters/compoundInputPasswordFilter.ts new file mode 100644 index 000000000..898f52fc3 --- /dev/null +++ b/packages/common/src/filters/compoundInputPasswordFilter.ts @@ -0,0 +1,10 @@ +import { TranslaterService } from '../services/translater.service'; +import { CompoundInputFilter } from './compoundInputFilter'; + +export class CompoundInputPasswordFilter extends CompoundInputFilter { + /** Initialize the Filter */ + constructor(protected translaterService: TranslaterService) { + super(translaterService); + this.inputType = 'password'; + } +} diff --git a/packages/common/src/filters/compoundSliderFilter.ts b/packages/common/src/filters/compoundSliderFilter.ts new file mode 100644 index 000000000..aa1b73a46 --- /dev/null +++ b/packages/common/src/filters/compoundSliderFilter.ts @@ -0,0 +1,288 @@ +import { OperatorString, OperatorType, SearchTerm } from '../enums/index'; +import { + Column, + ColumnFilter, + Filter, + FilterArguments, + FilterCallback, +} from '../interfaces/index'; +import { mapOperatorToShorthandDesignation } from '../services/utilities'; + +// using external non-typed js libraries +declare const $: any; + +const DEFAULT_MIN_VALUE = 0; +const DEFAULT_MAX_VALUE = 100; +const DEFAULT_STEP = 1; + +export class CompoundSliderFilter implements Filter { + private _clearFilterTriggered = false; + private _currentValue: number; + private _shouldTriggerQuery = true; + private _elementRangeInputId: string = ''; + private _elementRangeOutputId: string = ''; + private _operator: OperatorType | OperatorString; + private $containerInputGroupElm: any; + private $filterElm: any; + private $filterInputElm: any; + private $selectOperatorElm: any; + grid: any; + searchTerms: SearchTerm[]; + columnDef: Column; + callback: FilterCallback; + + /** Getter to know what would be the default operator when none is specified */ + get defaultOperator(): OperatorType | OperatorString { + return OperatorType.empty; + } + + /** Getter for the Filter Generic Params */ + private get filterParams(): any { + return this.columnDef && this.columnDef.filter && this.columnDef.filter.params || {}; + } + + /** Getter for the `filter` properties */ + private get filterProperties(): ColumnFilter { + return this.columnDef && this.columnDef.filter || {}; + } + + get operator(): OperatorType | OperatorString { + return this._operator || this.defaultOperator; + } + + set operator(op: OperatorType | OperatorString) { + this._operator = op; + } + + /** + * Initialize the Filter + */ + init(args: FilterArguments) { + if (!args) { + throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + } + this.grid = args.grid; + this.callback = args.callback; + this.columnDef = args.columnDef; + this.operator = args.operator || ''; + this.searchTerms = (args.hasOwnProperty('searchTerms') ? args.searchTerms : []) || []; + + // define the input & slider number IDs + this._elementRangeInputId = `rangeInput_${this.columnDef.field}`; + this._elementRangeOutputId = `rangeOutput_${this.columnDef.field}`; + + // filter input can only have 1 search term, so we will use the 1st array index if it exist + const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; + + // step 1, create the DOM Element of the filter which contain the compound Operator+Input + // and initialize it if searchTerm is filled + this.$filterElm = this.createDomElement(searchTerm); + + // step 3, subscribe to the keyup event and run the callback when that happens + // also add/remove "filled" class for styling purposes + this.$filterInputElm.change((e: any) => { + this.onTriggerEvent(e); + }); + this.$selectOperatorElm.change((e: any) => { + this.onTriggerEvent(e); + }); + + // if user chose to display the slider number on the right side, then update it every time it changes + // we need to use both "input" and "change" event to be all cross-browser + if (!this.filterParams.hideSliderNumber) { + this.$filterInputElm.on('input change', (e: { target: HTMLInputElement }) => { + const value = e && e.target && e.target.value; + if (value && document) { + const elements = document.getElementsByClassName(this._elementRangeOutputId || ''); + if (elements && elements.length > 0 && elements[0].innerHTML) { + elements[0].innerHTML = value; + } + } + }); + } + } + + /** + * Clear the filter value + */ + clear(shouldTriggerQuery = true) { + if (this.$filterElm && this.$selectOperatorElm) { + this._clearFilterTriggered = true; + this._shouldTriggerQuery = shouldTriggerQuery; + this.searchTerms = []; + const clearedValue = this.filterParams.hasOwnProperty('sliderStartValue') ? this.filterParams.sliderStartValue : DEFAULT_MIN_VALUE; + this._currentValue = +clearedValue; + this.$selectOperatorElm.val(0); + this.$filterInputElm.val(clearedValue); + if (!this.filterParams.hideSliderNumber) { + this.$containerInputGroupElm.children('div.input-group-addon.input-group-append').children().last().html(clearedValue); + } + this.onTriggerEvent(undefined); + this.$filterElm.removeClass('filled'); + } + } + + /** + * destroy the filter + */ + destroy() { + if (this.$filterElm) { + this.$filterElm.off('input change').remove(); + } + } + + /** + * Get selected value retrieved from the slider element + * @params selected items + */ + getValues(): number { + return this._currentValue; + } + + /** Set value(s) on the DOM element */ + setValues(values: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString) { + const newValue = Array.isArray(values) ? values[0] : values; + this._currentValue = +newValue; + this.$filterInputElm.val(newValue); + this.$containerInputGroupElm.children('div.input-group-addon.input-group-append').children().last().html(newValue); + + // set the operator, in the DOM as well, when defined + this.operator = operator || this.defaultOperator; + if (operator && this.$selectOperatorElm) { + const operatorShorthand = mapOperatorToShorthandDesignation(this.operator); + this.$selectOperatorElm.val(operatorShorthand); + } + } + + // + // private functions + // ------------------ + + /** Build HTML Template for the input range (slider) */ + private buildTemplateHtmlString() { + const minValue = this.filterProperties.hasOwnProperty('minValue') ? this.filterProperties.minValue : DEFAULT_MIN_VALUE; + const maxValue = this.filterProperties.hasOwnProperty('maxValue') ? this.filterProperties.maxValue : DEFAULT_MAX_VALUE; + const defaultValue = this.filterParams.hasOwnProperty('sliderStartValue') ? this.filterParams.sliderStartValue : minValue; + const step = this.filterProperties.hasOwnProperty('valueStep') ? this.filterProperties.valueStep : DEFAULT_STEP; + + return ``; + } + + /** Build HTML Template for the text (number) that is shown appended to the slider */ + private buildTemplateSliderTextHtmlString() { + const minValue = this.filterProperties.hasOwnProperty('minValue') ? this.filterProperties.minValue : DEFAULT_MIN_VALUE; + const defaultValue = this.filterParams.hasOwnProperty('sliderStartValue') ? this.filterParams.sliderStartValue : minValue; + + return `
${defaultValue}
`; + } + + /** Build HTML Template select dropdown (operator) */ + private buildSelectOperatorHtmlString() { + const optionValues = this.getOptionValues(); + let optionValueString = ''; + optionValues.forEach((option) => { + optionValueString += ``; + }); + + return ``; + } + + /** Get the available operator option values */ + private getOptionValues(): { operator: OperatorString, description: string }[] { + return [ + { operator: '' as OperatorString, description: '' }, + { operator: '=' as OperatorString, description: '=' }, + { operator: '<' as OperatorString, description: '<' }, + { operator: '<=' as OperatorString, description: '<=' }, + { operator: '>' as OperatorString, description: '>' }, + { operator: '>=' as OperatorString, description: '>=' }, + { operator: '<>' as OperatorString, description: '<>' } + ]; + } + + /** + * Create the DOM element + */ + private createDomElement(searchTerm?: SearchTerm) { + const columnId = this.columnDef && this.columnDef.id; + const minValue = (this.filterProperties.hasOwnProperty('minValue') && this.filterProperties.minValue) ? this.filterProperties.minValue : DEFAULT_MIN_VALUE; + const startValue = +(this.filterParams.hasOwnProperty('sliderStartValue') ? this.filterParams.sliderStartValue : minValue); + const $headerElm = this.grid.getHeaderRowColumn(this.columnDef.id); + $($headerElm).empty(); + + let searchTermInput = (searchTerm || '0') as string; + if (+searchTermInput < minValue) { + searchTermInput = `${minValue}`; + } + if (+searchTermInput < startValue) { + searchTermInput = `${startValue}`; + } + this._currentValue = +searchTermInput; + + // create the DOM Select dropdown for the Operator + this.$selectOperatorElm = $(this.buildSelectOperatorHtmlString()); + this.$filterInputElm = $(this.buildTemplateHtmlString()); + const $filterContainerElm = $(`
`); + this.$containerInputGroupElm = $(`
`); + const $operatorInputGroupAddon = $(``); + + /* the DOM element final structure will be +
+
+ +
+ +
0
+
+ */ + $operatorInputGroupAddon.append(this.$selectOperatorElm); + this.$containerInputGroupElm.append($operatorInputGroupAddon); + this.$containerInputGroupElm.append(this.$filterInputElm); + if (!this.filterParams.hideSliderNumber) { + const $sliderTextInputAppendAddon = $(this.buildTemplateSliderTextHtmlString()); + $sliderTextInputAppendAddon.children().html(searchTermInput); + this.$containerInputGroupElm.append($sliderTextInputAppendAddon); + } + + // create the DOM element & add an ID and filter class + $filterContainerElm.append(this.$containerInputGroupElm); + + this.$filterInputElm.val(searchTermInput); + this.$filterInputElm.data('columnId', columnId); + + if (this.operator) { + this.$selectOperatorElm.val(this.operator); + } + + // if there's a search term, we will add the "filled" class for styling purposes + if (searchTerm !== '') { + $filterContainerElm.addClass('filled'); + } + + // append the new DOM element to the header row + if ($filterContainerElm && typeof $filterContainerElm.appendTo === 'function') { + $filterContainerElm.appendTo($headerElm); + } + + return $filterContainerElm; + } + + private onTriggerEvent(e: Event | undefined) { + const value = this.$filterInputElm.val(); + this._currentValue = +value; + + if (this._clearFilterTriggered) { + this.$filterElm.removeClass('filled'); + this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); + } else { + this.$filterElm.addClass('filled'); + const selectedOperator = this.$selectOperatorElm.find('option:selected').text(); + this.callback(e, { columnDef: this.columnDef, searchTerms: (value ? [value || '0'] : null), operator: selectedOperator || '', shouldTriggerQuery: this._shouldTriggerQuery }); + } + // reset both flags for next use + this._clearFilterTriggered = false; + this._shouldTriggerQuery = true; + } +} diff --git a/packages/common/src/filters/index.ts b/packages/common/src/filters/index.ts index 3e674534a..de6dcb01a 100644 --- a/packages/common/src/filters/index.ts +++ b/packages/common/src/filters/index.ts @@ -1,10 +1,10 @@ import { Column, Filter } from '../interfaces/index'; // import { AutoCompleteFilter } from './autoCompleteFilter'; // import { CompoundDateFilter } from './compoundDateFilter'; -// import { CompoundInputFilter } from './compoundInputFilter'; -// import { CompoundInputNumberFilter } from './compoundInputNumberFilter'; -// import { CompoundInputPasswordFilter } from './compoundInputPasswordFilter'; -// import { CompoundSliderFilter } from './compoundSliderFilter'; +import { CompoundInputFilter } from './compoundInputFilter'; +import { CompoundInputNumberFilter } from './compoundInputNumberFilter'; +import { CompoundInputPasswordFilter } from './compoundInputPasswordFilter'; +import { CompoundSliderFilter } from './compoundSliderFilter'; import { InputFilter } from './inputFilter'; import { InputMaskFilter } from './inputMaskFilter'; import { InputNumberFilter } from './inputNumberFilter'; @@ -24,19 +24,19 @@ export const Filters = { // compoundDate: CompoundDateFilter, /** Alias to compoundInputText to Compound Input Filter (compound of Operator + Input Text) */ - // compoundInput: CompoundInputFilter, + compoundInput: CompoundInputFilter, /** Compound Input Number Filter (compound of Operator + Input of type Number) */ - // compoundInputNumber: CompoundInputNumberFilter, + compoundInputNumber: CompoundInputNumberFilter, /** Compound Input Password Filter (compound of Operator + Input of type Password, also note that only the text shown in the UI will be masked, filter query is still plain text) */ - // compoundInputPassword: CompoundInputPasswordFilter, + compoundInputPassword: CompoundInputPasswordFilter, /** Compound Input Text Filter (compound of Operator + Input Text) */ - // compoundInputText: CompoundInputFilter, + compoundInputText: CompoundInputFilter, /** Compound Slider Filter (compound of Operator + Slider) */ - // compoundSlider: CompoundSliderFilter, + compoundSlider: CompoundSliderFilter, /** Range Date Filter (uses the Flactpickr Date picker with range option) */ // dateRange: DateRangeFilter, diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 9b99fd6e3..baee8b18d 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -159,6 +159,7 @@ $column-picker-checkbox-opacity: 0.15 !default; $column-picker-checkbox-opacity-hover: 0.35 !default; $column-picker-checkbox-width: 13px !default; $column-picker-close-btn-bg-color: #ffffff !default; +$column-picker-close-btn-cursor: pointer !default; $column-picker-close-btn-font-size: 21px; $column-picker-close-btn-border: 0px solid #9c9c9c !default; $column-picker-close-btn-height: 21px !default; @@ -166,6 +167,8 @@ $column-picker-close-btn-width: 15px !default; $column-picker-close-btn-margin: 1px !default; $column-picker-close-btn-padding: 0px !default; $column-picker-close-btn-opacity: 0.9 !default; +$column-picker-close-btn-position-right: 5px !default; +$column-picker-close-btn-position-top: 0px !default; $column-picker-item-border: 1px solid transparent !default; $column-picker-item-border-radius: 0px !default; $column-picker-item-font-size: $icon-font-size !default; @@ -211,6 +214,7 @@ $grid-menu-checkbox-margin-right: 4px !default; $grid-menu-checkbox-opacity: 0.15 !default; $grid-menu-checkbox-opacity-hover: 0.35 !default; $grid-menu-checkbox-width: 13px !default; +$grid-menu-close-btn-cursor: pointer !default; $grid-menu-close-btn-bg-color: #ffffff !default; $grid-menu-close-btn-border: 0px solid #9c9c9c !default; $grid-menu-close-btn-font-size: 21px !default; @@ -219,6 +223,8 @@ $grid-menu-close-btn-width: 15px !default; $grid-menu-close-btn-margin: 1px !default; $grid-menu-close-btn-padding: 0px !default; $grid-menu-close-btn-opacity: 0.9 !default; +$grid-menu-close-btn-position-right: 5px !default; +$grid-menu-close-btn-position-top: 0px !default; $grid-menu-label-margin: 4px !default; $grid-menu-label-font-weight: normal !default; $grid-menu-link-background-color: #ffffff !default; @@ -233,6 +239,7 @@ $grid-menu-item-padding: 2px 4px !default; $grid-menu-item-font-size: $font-size-base !default; $grid-menu-item-hover-border: 1px solid #BFBDBD !default; $grid-menu-item-hover-color: #fafafa !default; +$grid-menu-min-width: 200px !default; $grid-menu-divider-height: 1px !default; $grid-menu-divider-margin: 8px 5px !default; $grid-menu-divider-color: #e7e7e7 !default; diff --git a/packages/common/src/styles/slick-controls.scss b/packages/common/src/styles/slick-controls.scss index a0ed47e80..6a739d647 100644 --- a/packages/common/src/styles/slick-controls.scss +++ b/packages/common/src/styles/slick-controls.scss @@ -20,9 +20,13 @@ z-index: 2000; overflow: auto; resize: both; + width: auto; + padding-right: 24px; /* trick to cheat the width to include extra scrollbar width in addition to auto width */ > .close { float: right; + position: absolute; + cursor: $column-picker-close-btn-cursor; width: $column-picker-close-btn-width; height: $column-picker-close-btn-height; margin: $column-picker-close-btn-margin; @@ -30,6 +34,8 @@ font-size: $column-picker-close-btn-font-size; background-color: $column-picker-close-btn-bg-color; border: $column-picker-close-btn-border; + right: $column-picker-close-btn-position-right; + top: $column-picker-close-btn-position-top; > span { opacity: $column-picker-close-btn-opacity; @@ -73,6 +79,7 @@ } li { + width: calc(100% + 24px - 6px); /* trick to cheat the width to include extra scrollbar width in addition to auto width */ border: $column-picker-item-border; border-radius: $column-picker-item-border-radius; padding: $column-picker-item-padding; @@ -138,16 +145,17 @@ border-radius: $grid-menu-border-radius; padding: 6px; box-shadow: $grid-menu-box-shadow; - min-width: 200px; + min-width: $grid-menu-min-width; cursor: default; position:absolute; z-index: 2000; overflow: auto; - resize: both; + width: max-content; > .close { - cursor: pointer; float: right; + position: absolute; + cursor: $grid-menu-close-btn-cursor; width: $grid-menu-close-btn-width; height: $grid-menu-close-btn-height; margin: $grid-menu-close-btn-margin; @@ -155,6 +163,8 @@ font-size: $grid-menu-close-btn-font-size; background-color: $grid-menu-close-btn-bg-color; border: $grid-menu-close-btn-border; + right: $grid-menu-close-btn-position-right; + top: $grid-menu-close-btn-position-top; > span { opacity: $grid-menu-close-btn-opacity; @@ -275,6 +285,7 @@ } li { + width: auto; border: $grid-menu-item-border; border-radius: $grid-menu-item-border-radius; padding: $grid-menu-item-padding; diff --git a/packages/common/src/styles/slick-material.scss b/packages/common/src/styles/slick-material.scss index 841521166..5edc6941f 100644 --- a/packages/common/src/styles/slick-material.scss +++ b/packages/common/src/styles/slick-material.scss @@ -106,6 +106,17 @@ border-top-right-radius: 0; border-bottom-right-radius: 0; } + + .input-group-addon:not(:first-child):not(:last-child), + .input-group-btn:not(:first-child):not(:last-child), + .input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + } + *, :after, :before { box-sizing: border-box; } diff --git a/packages/common/src/styles/slickgrid-theme-material.scss b/packages/common/src/styles/slickgrid-theme-material.scss index 979fb93f9..a3a8d6a12 100644 --- a/packages/common/src/styles/slickgrid-theme-material.scss +++ b/packages/common/src/styles/slickgrid-theme-material.scss @@ -75,6 +75,7 @@ $row-mouse-hover-color: #ebfaef; $row-selected-color: #d4f6d7; /*rgba(0, 149, 48, 0.2);*/ @import './roboto-font.scss'; +@import './slick-material'; @import './slick-grid'; @import './slick-controls'; @import './slick-editors'; @@ -84,5 +85,46 @@ $row-selected-color: #d4f6d7; /*rgba(0, 149, 48, 0.2);*/ @import './slick-footer'; @import './slickgrid-examples'; @import './slick-bootstrap'; -@import './slick-material'; @import './bootstrap-jquery-ui-autocomplete'; + +$link-color: #0099ff; + +.cell-effort-driven { + text-align: center; +} + +.editable-field { + background-color: #d5e8e9 !important; +} +.fake-hyperlink { + cursor: pointer; + color: $link-color; + &:hover { + text-decoration: underline; + } +} + +.toggle { + height: 20px; + width: 20px; + display: inline-block; + + &.expand { + cursor: pointer; + &:before { + font-family: "Material Design Icons"; + font-size: 20px; + content: "\F0142"; + } + } + + &.collapse{ + cursor: pointer; + &:before { + font-family: "Material Design Icons"; + font-size: 20px; + content: "\F0140"; + } + } +} + diff --git a/packages/common/tsconfig.build.json b/packages/common/tsconfig.build.json index 7f944d5ba..14cd76e9c 100644 --- a/packages/common/tsconfig.build.json +++ b/packages/common/tsconfig.build.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "module": "es2020", + "module": "esnext", "moduleResolution": "node", "target": "es2015", "lib": [ diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 59625f7ab..a040cf789 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -5,8 +5,8 @@ "rootDir": "src", "declarationDir": "dist/es2015", "outDir": "dist/es2015", - "target": "es2017", - "module": "es2015", + "target": "es2015", + "module": "esnext", "sourceMap": true, "lib": [ "es2020", diff --git a/packages/vanilla-bundle-examples/src/examples/example01.ts b/packages/vanilla-bundle-examples/src/examples/example01.ts index 2dee0af01..0f58d2827 100644 --- a/packages/vanilla-bundle-examples/src/examples/example01.ts +++ b/packages/vanilla-bundle-examples/src/examples/example01.ts @@ -19,8 +19,6 @@ const myCustomTitleValidator = (value, args) => { }; export class Example1 { - gridClass; - gridClassName; columnDefinitions: Column[]; gridOptions: GridOption; dataset; @@ -56,7 +54,11 @@ export class Example1 { filterable: true, }, { - id: 'duration', name: 'Duration', field: 'duration', sortable: true, filterable: true, + id: 'duration', name: 'Duration', field: 'duration', sortable: true, + filterable: true, + filter: { + model: Slicker.Filters.compoundSlider, + }, editor: { model: Slicker.Editors.slider, minValue: 0, diff --git a/test/translateServiceStub.ts b/test/translateServiceStub.ts index 5e543ca20..96f7f36fb 100644 --- a/test/translateServiceStub.ts +++ b/test/translateServiceStub.ts @@ -16,7 +16,10 @@ export class TranslateServiceStub implements TranslaterService { case 'COLUMNS': output = this._locale === 'en' ? 'Columns' : 'Colonnes'; break; case 'COMMANDS': output = this._locale === 'en' ? 'Commands' : 'Commandes'; break; case 'COLLAPSE_ALL_GROUPS': output = this._locale === 'en' ? 'Collapse all Groups' : 'Réduire tous les groupes'; break; + case 'CONTAINS': output = this._locale === 'en' ? 'Contains' : 'Contient'; break; case 'COPY': output = this._locale === 'en' ? 'Copy' : 'Copier'; break; + case 'ENDS_WITH': output = this._locale === 'en' ? 'Ends With' : 'Se termine par'; break; + case 'EQUALS': output = this._locale === 'en' ? 'Equals' : 'Égale'; break; case 'EXPAND_ALL_GROUPS': output = this._locale === 'en' ? 'Expand all Groups' : 'Étendre tous les groupes'; break; case 'EXPORT_TO_CSV': output = this._locale === 'en' ? 'Export in CSV format' : 'Exporter en format CSV'; break; case 'EXPORT_TO_EXCEL': output = this._locale === 'en' ? 'Export to Excel' : 'Exporter vers Excel'; break; @@ -37,6 +40,7 @@ export class TranslateServiceStub implements TranslaterService { case 'SORT_ASCENDING': output = this._locale === 'en' ? 'Sort Ascending' : 'Trier par ordre croissant'; break; case 'SORT_DESCENDING': output = this._locale === 'en' ? 'Sort Descending' : 'Trier par ordre décroissant'; break; case 'SAVE': output = this._locale === 'en' ? 'Save' : 'Sauvegarder'; break; + case 'STARTS_WITH': output = this._locale === 'en' ? 'Starts With' : 'Commence par'; break; case 'SYNCHRONOUS_RESIZE': output = this._locale === 'en' ? 'Synchronous resize' : 'Redimension synchrone'; break; case 'TITLE': output = this._locale === 'en' ? 'Title' : 'Titre'; break; case 'TOGGLE_FILTER_ROW': output = this._locale === 'en' ? 'Toggle Filter Row' : 'Basculer la ligne des filtres'; break; diff --git a/tsconfig.json b/tsconfig.json index b983d08d9..b44afe79c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,13 @@ { - "compilerOptions": { - "experimentalDecorators": true, - "esModuleInterop": true, - "module": "commonjs", - "target": "es2018", - "lib": [ - "esnext" - ], - "resolveJsonModule": true - } -} \ No newline at end of file + "compilerOptions": { + "experimentalDecorators": true, + "esModuleInterop": true, + "module": "commonjs", + "target": "es2018", + "lib": [ + "es2020", + "dom" + ], + "resolveJsonModule": true + } +}