diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index 32159c197..04a10e803 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -25,6 +25,7 @@ import Example21 from './examples/example21'; import Example22 from './examples/example22'; import Example23 from './examples/example23'; import Example24 from './examples/example24'; +import Example25 from './examples/example25'; export class AppRouting { constructor(private config: RouterConfig) { @@ -55,6 +56,7 @@ export class AppRouting { { route: 'example22', name: 'example22', view: './examples/example22.html', viewModel: Example22, title: 'Example22', }, { route: 'example23', name: 'example23', view: './examples/example23.html', viewModel: Example23, title: 'Example23', }, { route: 'example24', name: 'example24', view: './examples/example24.html', viewModel: Example24, title: 'Example24', }, + { route: 'example25', name: 'example25', view: './examples/example25.html', viewModel: Example25, title: 'Example25', }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' } ]; diff --git a/examples/vite-demo-vanilla-bundle/src/app.html b/examples/vite-demo-vanilla-bundle/src/app.html index ab3a34936..e77eaa25f 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -108,6 +108,9 @@

Slickgrid-Universal

Example24 - Footer Totals Row + + Example25 - Range Filters + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example21.html b/examples/vite-demo-vanilla-bundle/src/examples/example21.html index 8984dc1f3..1c4684cda 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example21.html +++ b/examples/vite-demo-vanilla-bundle/src/examples/example21.html @@ -18,7 +18,7 @@

-
+
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example25.html b/examples/vite-demo-vanilla-bundle/src/examples/example25.html new file mode 100644 index 000000000..d3fe92f7b --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example25.html @@ -0,0 +1,46 @@ +

+ Example 25 - Range Filters + + +

+ + + Metrics: + + of + items + + +
+ + + + + + + + Locale: + + + +
+ +
+
\ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example25.ts b/examples/vite-demo-vanilla-bundle/src/examples/example25.ts new file mode 100644 index 000000000..c341e633e --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example25.ts @@ -0,0 +1,283 @@ +import { addDay, format } from '@formkit/tempo'; + +import { SlickCustomTooltip } from '@slickgrid-universal/custom-tooltip-plugin'; +import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; + +import { + type Column, + type CurrentFilter, + FieldType, + Filters, + Formatters, + type GridOption, + type GridStateChange, + type MultipleSelectOption, + OperatorType, + type SliderRangeOption, +} from '@slickgrid-universal/common'; +import { BindingEventService } from '@slickgrid-universal/binding'; + +import { ExampleGridOptions } from './example-grid-options'; +import { type TranslateService } from '../translate.service'; + +const NB_ITEMS = 5000; + +function randomBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export default class Example25 { + private _bindingEventService: BindingEventService; + columnDefinitions: Column[] = []; + gridContainerElm: HTMLDivElement; + gridOptions!: GridOption; + dataset: any[] = []; + metricsEndTime = ''; + metricsStartTime = ''; + metricsItemCount = 0; + metricsTotalItemCount = 0; + filterList = [ + { value: '', label: '' }, + { value: 'currentYearTasks', label: 'Current Year Completed Tasks' }, + { value: 'nextYearTasks', label: 'Next Year Active Tasks' } + ]; + selectedLanguage = 'en'; + selectedLanguageFile = 'en.json'; + sgb: SlickVanillaGridBundle; + translateService: TranslateService; + + constructor() { + this._bindingEventService = new BindingEventService(); + + // get the Translate Service from the window object, + // it might be better with proper Dependency Injection but this project doesn't have any at this point + this.translateService = (window).TranslateService; + } + + attached() { + // define the grid options & columns and then create the grid itself + this.defineGrid(); + + // mock some data (different in each dataset) + this.dataset = this.mockData(NB_ITEMS); + this.gridContainerElm = document.querySelector('.grid25') as HTMLDivElement; + + this.sgb = new Slicker.GridBundle(this.gridContainerElm, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset); + + // bind any of the grid events + this._bindingEventService.bind(this.gridContainerElm, 'ongridstatechanged', this.gridStateChanged.bind(this)); + this._bindingEventService.bind(this.gridContainerElm, 'onrowcountchanged', this.refreshMetrics.bind(this)); + + this.metricsEndTime = format(new Date(), 'DD MMM, h:mm:ss a'); + this.metricsStartTime = format(new Date(), 'DD MMM, h:mm:ss a'); + this.metricsItemCount = this.sgb.dataView?.getFilteredItemCount() || 0; + this.metricsTotalItemCount = this.dataset.length || 0; + + document.body.classList.add('material-theme'); + } + + dispose() { + this.saveCurrentGridState(); + document.body.classList.remove('material-theme'); + } + + /* Define grid Options and Columns */ + defineGrid() { + this.columnDefinitions = [ + { + id: 'title', name: 'Title', field: 'id', nameKey: 'TITLE', minWidth: 100, + formatter: (_row, _cell, value) => { + return this.translateService.translate('TASK_X', { x: value }) ?? ''; + }, + sortable: true, + filterable: true, + params: { useFormatterOuputToFilter: true } + }, + { + id: 'description', name: 'Description', field: 'description', filterable: true, sortable: true, minWidth: 80, + type: FieldType.string, + }, + { + id: 'percentComplete', name: '% Complete', field: 'percentComplete', nameKey: 'PERCENT_COMPLETE', minWidth: 120, + sortable: true, + customTooltip: { position: 'center' }, + formatter: Formatters.progressBar, + type: FieldType.number, + filterable: true, + filter: { + model: Filters.sliderRange, + maxValue: 100, // or you can use the filterOptions as well + operator: OperatorType.rangeInclusive, // defaults to inclusive + filterOptions: { + hideSliderNumbers: false, // you can hide/show the slider numbers on both side + min: 0, step: 5 + } as SliderRangeOption + } + }, + { + id: 'start', name: 'Start', field: 'start', nameKey: 'START', formatter: Formatters.dateIso, sortable: true, minWidth: 75, width: 100, exportWithFormatter: true, + type: FieldType.date, filterable: true, filter: { model: Filters.compoundDate } + }, + { + id: 'finish', name: 'Finish', field: 'finish', nameKey: 'FINISH', formatter: Formatters.dateIso, sortable: true, minWidth: 75, width: 120, exportWithFormatter: true, + type: FieldType.date, + filterable: true, + filter: { + model: Filters.dateRange, + } + }, + { + id: 'duration', field: 'duration', nameKey: 'DURATION', maxWidth: 90, + type: FieldType.number, + sortable: true, + filterable: true, filter: { + model: Filters.input, + operator: OperatorType.rangeExclusive // defaults to exclusive + } + }, + { + id: 'completed', name: 'Completed', field: 'completed', nameKey: 'COMPLETED', minWidth: 85, maxWidth: 90, + formatter: Formatters.checkmarkMaterial, + exportWithFormatter: true, // you can set this property in the column definition OR in the grid options, column def has priority over grid options + filterable: true, + filter: { + collection: [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }], + model: Filters.singleSelect, + filterOptions: { autoAdjustDropHeight: true } as MultipleSelectOption + } + } + ]; + + const today = new Date(); + const presetLowestDay = format(addDay(new Date(), -2), 'YYYY-MM-DD'); + const presetHighestDay = format(addDay(new Date(), today.getDate() < 14 ? 30 : 25), 'YYYY-MM-DD'); + + this.gridOptions = { + autoResize: { + container: '.demo-container', + }, + enableExcelCopyBuffer: true, + enableFiltering: true, + enableTranslate: true, + translater: this.translateService, // pass the TranslateService instance to the grid + + // use columnDef searchTerms OR use presets as shown below + presets: { + filters: [ + // you can use the 2 dots separator on all Filters which support ranges + { columnId: 'duration', searchTerms: ['4..88'] }, + // { columnId: 'percentComplete', searchTerms: ['5..80'] }, // without operator will default to 'RangeExclusive' + // { columnId: 'finish', operator: 'RangeInclusive', searchTerms: [`${presetLowestDay}..${presetHighestDay}`] }, + + // or you could also use 2 searchTerms values, instead of using the 2 dots (only works with SliderRange & DateRange Filters) + // BUT make sure to provide the operator, else the filter service won't know that this is really a range + { columnId: 'percentComplete', operator: 'RangeInclusive', searchTerms: [5, 80] }, // same result with searchTerms: ['5..80'] + { columnId: 'finish', operator: 'RangeInclusive', searchTerms: [presetLowestDay, presetHighestDay] }, + ], + sorters: [ + { columnId: 'percentComplete', direction: 'DESC' }, + { columnId: 'duration', direction: 'ASC' }, + ], + }, + externalResources: [new SlickCustomTooltip(), new ExcelExportService()], + }; + } + + mockData(itemCount: number, startingIndex = 0): any[] { + // mock a dataset + const tempDataset: any[] = []; + for (let i = startingIndex; i < (startingIndex + itemCount); i++) { + const randomDuration = randomBetween(0, 365); + const randomYear = randomBetween(new Date().getFullYear(), new Date().getFullYear() + 1); + const randomMonth = randomBetween(0, 12); + const randomDay = randomBetween(10, 28); + const randomPercent = randomBetween(0, 100); + + tempDataset.push({ + id: i, + title: 'Task ' + i, + description: (i % 5) ? 'desc ' + i : null, // also add some random to test NULL field + duration: randomDuration, + percentComplete: randomPercent, + percentCompleteNumber: randomPercent, + start: (i % 4) ? null : new Date(randomYear, randomMonth, randomDay), // provide a Date format + finish: new Date(randomYear, randomMonth, randomDay), + completed: (randomPercent === 100) ? true : false, + }); + } + + return tempDataset; + } + + clearFilters() { + this.sgb?.filterService.clearFilters(); + } + + /** Dispatched event of a Grid State Changed event */ + gridStateChanged(event) { + if (event?.detail) { + console.log('Client sample, Grid State changed:: ', event.detail as GridStateChange); + } + } + + /** Save current Filters, Sorters in LocaleStorage or DB */ + saveCurrentGridState() { + console.log('Client sample, current Grid State:: ', this.sgb?.gridStateService.getCurrentGridState()); + } + + refreshMetrics(event) { + const args = event?.detail?.args; + if (args?.current >= 0) { + this.metricsStartTime = format(new Date(), 'DD MMM, h:mm:ss a'); + this.metricsItemCount = args?.current || 0; + this.metricsTotalItemCount = this.dataset.length || 0; + } + } + + setFiltersDynamically() { + const presetLowestDay = format(addDay(new Date(), -5), 'YYYY-MM-DD'); + const presetHighestDay = format(addDay(new Date(), 25), 'YYYY-MM-DD'); + + // we can Set Filters Dynamically (or different filters) afterward through the FilterService + this.sgb.filterService.updateFilters([ + { columnId: 'duration', searchTerms: ['14..78'], operator: 'RangeInclusive' }, + { columnId: 'percentComplete', operator: 'RangeExclusive', searchTerms: [15, 85] }, + { columnId: 'start', operator: '<=', searchTerms: [presetHighestDay] }, + { columnId: 'finish', operator: 'RangeInclusive', searchTerms: [presetLowestDay, presetHighestDay] }, + ]); + } + + setSortingDynamically() { + this.sgb?.sortService.updateSorting([ + // orders matter, whichever is first in array will be the first sorted column + { columnId: 'finish', direction: 'DESC' }, + { columnId: 'percentComplete', direction: 'ASC' }, + ]); + } + + predefinedFilterChanged(newPredefinedFilter: string) { + let filters: CurrentFilter[] = []; + const currentYear = new Date().getFullYear(); + + switch (newPredefinedFilter) { + case 'currentYearTasks': + filters = [ + { columnId: 'finish', operator: OperatorType.rangeInclusive, searchTerms: [`${currentYear}-01-01`, `${currentYear}-12-31`] }, + { columnId: 'completed', operator: OperatorType.equal, searchTerms: [true] }, + ]; + break; + case 'nextYearTasks': + filters = [{ columnId: 'start', operator: '>=', searchTerms: [`${currentYear + 1}-01-01`] }]; + break; + } + this.sgb?.filterService.updateFilters(filters); + } + + async switchLanguage() { + const nextLanguage = (this.selectedLanguage === 'en') ? 'fr' : 'en'; + await this.translateService.use(nextLanguage); + this.selectedLanguage = nextLanguage; + this.selectedLanguageFile = `${this.selectedLanguage}.json`; + } +} diff --git a/packages/common/src/filters/dateFilter.ts b/packages/common/src/filters/dateFilter.ts index 4f41c7872..2fac06b01 100644 --- a/packages/common/src/filters/dateFilter.ts +++ b/packages/common/src/filters/dateFilter.ts @@ -227,7 +227,6 @@ export class DateFilter implements Filter { if (this.calendarInstance && pickerValues !== undefined) { setPickerDates(this.columnFilter, this._dateInputElm, this.calendarInstance, { columnDef: this.columnDef, - oldVal: this._currentDateOrDates, newVal: pickerValues, updatePickerUI: true }); diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts index 298a6dca8..557c966b7 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -303,7 +303,7 @@ export class SlickVanillaGridBundle { ) { // make sure that the grid container doesn't already have the "slickgrid-container" css class // if it does then we won't create yet another grid, just stop there - if (gridParentContainerElm.querySelectorAll('.slickgrid-container').length !== 0) { + if (!gridParentContainerElm || gridParentContainerElm.querySelectorAll('.slickgrid-container').length !== 0) { return; } diff --git a/test/cypress/e2e/example25.cy.ts b/test/cypress/e2e/example25.cy.ts new file mode 100644 index 000000000..26d0f0755 --- /dev/null +++ b/test/cypress/e2e/example25.cy.ts @@ -0,0 +1,282 @@ +import { addDay, format, isAfter, isBefore, isEqual } from '@formkit/tempo'; + +const presetMinComplete = 5; +const presetMaxComplete = 80; +const presetMinDuration = 4; +const presetMaxDuration = 88; +const today = new Date(); +const presetLowestDay = format(addDay(new Date(), -2), 'YYYY-MM-DD'); +const presetHighestDay = format(addDay(new Date(), today.getDate() < 14 ? 30 : 25), 'YYYY-MM-DD'); + +function isBetween(inputDate: Date | string, minDate: Date | string, maxDate: Date | string, isInclusive = false) { + let valid = false; + if (isInclusive) { + valid = isEqual(inputDate, minDate) || isEqual(inputDate, maxDate); + } + if (!valid) { + valid = isAfter(inputDate, minDate) && isBefore(inputDate, maxDate); + } + return valid; +} + +function isSmallerOrEqual(inputDate: Date | string, maxDate: Date | string) { + return isBefore(inputDate, maxDate) || isEqual(inputDate, maxDate); +} + +describe('Example 25 - Range Filters', () => { + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example25`); + cy.get('h3').should('contain', 'Example 25 - Range Filters'); + }); + + it('should expect the grid to be sorted by "% Complete" descending and then by "Duration" ascending', () => { + cy.get('.grid25') + .get('.slick-header-column:nth(2)') + .find('.slick-sort-indicator-desc') + .should('have.length', 1) + .siblings('.slick-sort-indicator-numbered') + .contains('1'); + + cy.get('.grid25') + .get('.slick-header-column:nth(5)') + .find('.slick-sort-indicator-asc') + .should('have.length', 1) + .siblings('.slick-sort-indicator-numbered') + .contains('2'); + }); + + it('should have "% Complete" fields within the range (inclusive) of the filters presets', () => { + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(2)') + .each(($cell) => { + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value)) { + expect(value >= presetMinComplete).to.eq(true); + expect(value <= presetMaxComplete).to.eq(true); + } + }); + }); + }); + + it('should have Finish Dates within the range (inclusive) of the filters presets', () => { + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(4)') + .each(($cell) => { + const isDateBetween = isBetween($cell.text(), presetLowestDay, presetHighestDay, true); + expect(isDateBetween).to.eq(true); + }); + }); + }); + + it('should have "Duration" fields within the range (exclusive by default) of the filters presets', () => { + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(5)') + .each(($cell) => { + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value)) { + expect(value >= presetMinDuration).to.eq(true); + expect(value <= presetMaxDuration).to.eq(true); + } + }); + }); + }); + + it('should change "% Complete" filter range by using the slider left handle (min value) to make it a higher min value and expect all rows to be within new range', () => { + let newLowest = presetMinComplete; + let newHighest = presetMaxComplete; + const allowedBuffer = 0.8; + + // first input is the lowest range + cy.get('.slider-filter-input:nth(0)') + .as('range').invoke('val', 10).trigger('change', { force: true }); + + cy.get('.lowest-range-percentComplete') + .then(($lowest) => { + newLowest = parseInt($lowest.text(), 10); + }); + + cy.get('.highest-range-percentComplete') + .then(($highest) => { + newHighest = parseInt($highest.text(), 10); + }); + + cy.wait(5); + + cy.get('.grid25') + .find('.slick-row') + .each(($row, idx) => { + if (idx > 6) { + return; + } + cy.wrap($row) + .children('.slick-cell:nth(2)') + .each(($cell) => { + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value) && $cell.text() !== '') { + expect(value >= (newLowest - allowedBuffer)).to.eq(true); + expect(value <= (newHighest + allowedBuffer)).to.eq(true); + } + }); + }); + }); + + it('should change the "Finish" date in the picker and expect all rows to be within new dates range', () => { + cy.get('.date-picker.search-filter.filter-finish') + .click(); + + cy.get('.vanilla-calendar-day_selected-first') + .should('exist'); + + cy.get('.vanilla-calendar-day_selected-intermediate') + .should('have.length.gte', 2); + + cy.get('.vanilla-calendar-day_selected-last') + .should('exist'); + }); + + it('should change the "Duration" input filter and expect all rows to be within new range', () => { + const newMin = 10; + const newMax = 40; + + cy.get('[data-test=clear-filters]') + .click({ force: true }); + + cy.get('.search-filter.filter-duration') + .focus() + .type(`${newMin}..${newMax}`); + + cy.get('.grid25') + .find('.slick-row') + .each(($row, idx) => { + cy.wrap($row) + .children('.slick-cell:nth(5)') + .each(($cell) => { + if (idx > 8) { + return; + } + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value)) { + expect(value >= newMin).to.eq(true); + expect(value <= newMax).to.eq(true); + } + }); + }); + }); + + describe('Set Dymamic Filters', () => { + const dynamicMinComplete = 15; + const dynamicMaxComplete = 85; + const dynamicMinDuration = 14; + const dynamicMaxDuration = 78; + const dynamicLowestDay = format(addDay(new Date(), -5), 'YYYY-MM-DD'); + const dynamicHighestDay = format(addDay(new Date(), 25), 'YYYY-MM-DD'); + + it('should click on Set Dynamic Filters', () => { + cy.get('[data-test=set-dynamic-filter]') + .click(); + }); + + it('should have "% Complete" fields within the exclusive range of the filters presets', () => { + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(2)') + .each(($cell) => { + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value)) { + expect(value >= dynamicMinComplete).to.eq(true); + expect(value <= dynamicMaxComplete).to.eq(true); + } + }); + }); + }); + + it('should have "Duration" fields within the inclusive range of the dynamic filters', () => { + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(5)') + .each(($cell) => { + const value = parseInt($cell.text().trim(), 10); + if (!isNaN(value)) { + expect(value >= dynamicMinDuration).to.eq(true); + expect(value <= dynamicMaxDuration).to.eq(true); + } + }); + }); + }); + + it('should have Start Dates smaller or equal to the dynamic filter', () => { + cy.get('.search-filter.filter-start') + .find('input') + .invoke('val') + .then(text => expect(text).to.eq(dynamicHighestDay)); + + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(3)') + .each(($cell) => { + const isDateBetween = isSmallerOrEqual($cell.text(), dynamicHighestDay); + expect(isDateBetween).to.eq(true); + }); + }); + }); + + it('should have Finish Dates within the range (inclusive) of the dynamic filters', () => { + cy.get('.search-filter.filter-finish') + .find('input') + .invoke('val') + .then(text => expect(text).to.eq(`${dynamicLowestDay} — ${dynamicHighestDay}`)); + + cy.get('.grid25') + .find('.slick-row') + .each(($row) => { + cy.wrap($row) + .children('.slick-cell:nth(4)') + .each(($cell) => { + const isDateBetween = isBetween($cell.text(), dynamicLowestDay, dynamicHighestDay, true); + expect(isDateBetween).to.eq(true); + }); + }); + }); + }); + + describe('Set Dynamic Sorting', () => { + it('should click on "Clear Filters" then "Set Dynamic Sorting" buttons', () => { + cy.get('[data-test=clear-filters]') + .click(); + + cy.get('[data-test=set-dynamic-sorting]') + .click(); + }); + + it('should expect the grid to be sorted by "Duration" ascending and "Start" descending', () => { + cy.get('.grid25') + .get('.slick-header-column:nth(2)') + .find('.slick-sort-indicator-asc') + .should('have.length', 1) + .siblings('.slick-sort-indicator-numbered') + .contains('2'); + + cy.get('.grid25') + .get('.slick-header-column:nth(4)') + .find('.slick-sort-indicator-desc') + .should('have.length', 1) + .siblings('.slick-sort-indicator-numbered') + .contains('1'); + }); + }); +});