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
+
+ */
+ $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
+ }
+}