diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts index b9c66a092..44173418f 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts @@ -123,10 +123,7 @@ export class Example5 { columnId: 'title', parentPropName: 'parentId', // this is optional, you can define the tree level property name that will be used for the sorting/indentation, internally it will use "__treeLevel" - // levelPropName: 'indent', - - // you can add an optional prefix to all the child values - indentedChildValuePrefix: '', + levelPropName: 'treeLevel', indentMarginLeft: 15, // you can optionally sort by a different column and/or sort direction @@ -134,7 +131,15 @@ export class Example5 { initialSort: { columnId: 'title', direction: 'ASC' - } + }, + // we can also add a custom Formatter just for the title text portion + titleFormatter: (_row, _cell, value, _def, dataContext) => { + let prefix = ''; + if (dataContext.treeLevel > 0) { + prefix = ``; + } + return `${prefix}${value}(parentId: ${dataContext.parentId})`; + }, }, multiColumnSort: false, // multi-column sorting is not supported with Tree Data, so you need to disable it presets: { @@ -152,7 +157,7 @@ export class Example5 { addNewRow() { const newId = this.sgb.dataset.length; const parentPropName = 'parentId'; - const treeLevelPropName = '__treeLevel'; // if undefined in your options, the default prop name is "__treeLevel" + const treeLevelPropName = 'treeLevel'; // if undefined in your options, the default prop name is "__treeLevel" const newTreeLevel = 1; // find first parent object and add the new item as a child const childItemFound = this.sgb.dataset.find((item) => item[treeLevelPropName] === newTreeLevel); @@ -162,7 +167,7 @@ export class Example5 { const newItem = { id: newId, parentId: parentItemFound.id, - title: this.formatTitle(newId, parentItemFound.id), + title: `Task ${newId}`, duration: '1 day', percentComplete: 99, start: new Date(), @@ -227,7 +232,7 @@ export class Example5 { item['id'] = i; item['parentId'] = parentId; - item['title'] = this.formatTitle(i, parentId); + item['title'] = `Task ${i}`; item['duration'] = '5 days'; item['percentComplete'] = Math.round(Math.random() * 100); item['start'] = new Date(randomYear, randomMonth, randomDay); @@ -239,8 +244,4 @@ export class Example5 { } return data; } - - formatTitle(taskId: number, parentId: number) { - return `Task ${taskId} (parentId: ${parentId})`; - } } diff --git a/packages/common/src/formatters/__tests__/treeFormatter.spec.ts b/packages/common/src/formatters/__tests__/treeFormatter.spec.ts index 5fabb09d0..608ed53ee 100644 --- a/packages/common/src/formatters/__tests__/treeFormatter.spec.ts +++ b/packages/common/src/formatters/__tests__/treeFormatter.spec.ts @@ -26,7 +26,7 @@ describe('Tree Formatter', () => { { id: 5, firstName: 'Sponge', lastName: 'Bob', fullName: 'Sponge Bob', email: 'sponge.bob@cartoon.com', address: { zip: 888888 }, parentId: 2, indent: 3, __collapsed: true }, ]; mockGridOptions = { - treeDataOptions: { levelPropName: 'indent', indentedChildValuePrefix: '' } + treeDataOptions: { levelPropName: 'indent' } } as GridOption; jest.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptions); }); @@ -62,7 +62,10 @@ describe('Tree Formatter', () => { jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[0]); const output = treeFormatter(1, 1, dataset[0]['firstName'], {} as Column, dataset[0], gridStub); - expect(output).toBe(`John`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-0', + text: `John` + }); }); it('should return a span without any icon and 15px indentation of a tree level 1', () => { @@ -71,7 +74,10 @@ describe('Tree Formatter', () => { jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); const output = treeFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); - expect(output).toBe(`Jane`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-1', + text: `Jane` + }); }); it('should return a span without any icon and 30px indentation of a tree level 2', () => { @@ -80,7 +86,10 @@ describe('Tree Formatter', () => { jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); const output = treeFormatter(1, 1, dataset[2]['firstName'], {} as Column, dataset[2], gridStub); - expect(output).toBe(`Bob`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-2', + text: `Bob` + }); }); it('should return a span with expanded icon and 15px indentation of a tree level 1 when current item is greater than next item', () => { @@ -89,7 +98,10 @@ describe('Tree Formatter', () => { jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); const output = treeFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); - expect(output).toBe(`Jane`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-1', + text: `Jane` + }); }); it('should return a span with collapsed icon and 0px indentation of a tree level 0 when current item is lower than next item', () => { @@ -98,27 +110,29 @@ describe('Tree Formatter', () => { jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); const output = treeFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); - expect(output).toBe(`Barbara`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-0', + text: `Barbara` + }); }); it('should return a span with expanded icon and 15px indentation of a tree level 1 with a value prefix when provided', () => { - mockGridOptions.treeDataOptions.indentedChildValuePrefix = ''; + mockGridOptions.treeDataOptions.levelPropName = 'indent'; + mockGridOptions.treeDataOptions.titleFormatter = (_row, _cell, value, _def, dataContext) => { + if (dataContext.indent > 0) { + return `${value}`; + } + return value || ''; + }; jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); - const output = treeFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); - expect(output).toBe(`Jane`); - }); - - it('should return a span with collapsed icon and 30px indentation of a tree level 2 when current item is lower than next item', () => { - mockGridOptions.treeDataOptions.indentedChildValuePrefix = ''; - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[5]); - - const output = treeFormatter(1, 1, dataset[2]['firstName'], {} as Column, dataset[2], gridStub); - expect(output).toBe(`Bob`); + const output = treeFormatter(1, 1, { ...dataset[1]['firstName'], indent: 1 }, { field: 'firstName' } as Column, dataset[1], gridStub); + expect(output).toEqual({ + addClasses: 'slick-tree-level-1', + text: `Jane` + }); }); it('should execute "queryFieldNameGetterFn" callback to get field name to use when it is defined', () => { @@ -128,7 +142,10 @@ describe('Tree Formatter', () => { const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); - expect(output).toBe(`Barbara Cane`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-0', + text: `Barbara Cane` + }); }); it('should execute "queryFieldNameGetterFn" callback to get field name and also apply html encoding when output value includes a character that should be encoded', () => { @@ -138,7 +155,10 @@ describe('Tree Formatter', () => { const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[4], gridStub); - expect(output).toBe(`Anonymous < Doe`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-0', + text: `Anonymous < Doe` + }); }); it('should execute "queryFieldNameGetterFn" callback to get field name, which has (.) dot notation reprensenting complex object', () => { @@ -148,6 +168,9 @@ describe('Tree Formatter', () => { const mockColumn = { id: 'zip', field: 'zip', queryFieldNameGetterFn: () => 'address.zip' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); - expect(output).toBe(`444444`); + expect(output).toEqual({ + addClasses: 'slick-tree-level-0', + text: `444444` + }); }); }); diff --git a/packages/common/src/formatters/treeFormatter.ts b/packages/common/src/formatters/treeFormatter.ts index ee70645a9..512ef06b8 100644 --- a/packages/common/src/formatters/treeFormatter.ts +++ b/packages/common/src/formatters/treeFormatter.ts @@ -1,13 +1,15 @@ import { SlickDataView, Formatter } from './../interfaces/index'; import { getDescendantProperty, sanitizeTextByAvailableSanitizer } from '../services/utilities'; +import { parseFormatterWhenExist } from '../services'; /** Formatter that must be use with a Tree Data column */ -export const treeFormatter: Formatter = (_row, _cell, value, columnDef, dataContext, grid) => { +export const treeFormatter: Formatter = (row, cell, value, columnDef, dataContext, grid) => { const dataView = grid.getData(); const gridOptions = grid.getOptions(); const treeDataOptions = gridOptions?.treeDataOptions; - const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; + const collapsedPropName = treeDataOptions?.collapsedPropName ?? '__collapsed'; const indentMarginLeft = treeDataOptions?.indentMarginLeft ?? 15; + const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; let outputValue = value; if (typeof columnDef.queryFieldNameGetterFn === 'function') { @@ -27,22 +29,25 @@ export const treeFormatter: Formatter = (_row, _cell, value, columnDef, dataCont } if (dataView?.getItemByIdx) { - const sanitizedOutputValue = sanitizeTextByAvailableSanitizer(gridOptions, outputValue); const identifierPropName = dataView.getIdPropertyName() ?? 'id'; const treeLevel = dataContext[treeLevelPropName] || 0; - const spacer = ``; + const indentSpacer = ``; const idx = dataView.getIdxById(dataContext[identifierPropName]); const nextItemRow = dataView.getItemByIdx((idx || 0) + 1); - const valuePrefix = treeLevel > 0 ? treeDataOptions?.indentedChildValuePrefix ?? '' : ''; + const slickTreeLevelClass = `slick-tree-level-${treeLevel}`; + let toggleClass = ''; if (nextItemRow?.[treeLevelPropName] > treeLevel) { - if (dataContext.__collapsed) { - return `${spacer}${valuePrefix}${sanitizedOutputValue}`; - } else { - return `${spacer}${valuePrefix}${sanitizedOutputValue}`; - } + toggleClass = dataContext?.[collapsedPropName] ? 'collapsed' : 'expanded'; // parent with child will have a toggle icon + } + + if (treeDataOptions?.titleFormatter) { + outputValue = parseFormatterWhenExist(treeDataOptions.titleFormatter, row, cell, dataContext, columnDef, grid); } - return `${spacer}${valuePrefix}${sanitizedOutputValue}`; + const sanitizedOutputValue = sanitizeTextByAvailableSanitizer(gridOptions, outputValue, { ADD_ATTR: ['target'] }); + const spanToggleClass = `slick-group-toggle ${toggleClass}`.trim(); + const outputHtml = `${indentSpacer}${sanitizedOutputValue}`; + return { addClasses: slickTreeLevelClass, text: outputHtml }; } return ''; }; diff --git a/packages/common/src/interfaces/treeDataOption.interface.ts b/packages/common/src/interfaces/treeDataOption.interface.ts index 12fcebd59..b91d32428 100644 --- a/packages/common/src/interfaces/treeDataOption.interface.ts +++ b/packages/common/src/interfaces/treeDataOption.interface.ts @@ -1,12 +1,14 @@ -import { Aggregator } from './aggregator.interface'; +// import { Aggregator } from './aggregator.interface'; import { SortDirection, SortDirectionString } from '../enums/index'; +import { Formatter } from './formatter.interface'; export interface TreeDataOption { /** Column Id of which column in the column definitions has the Tree Data, there can only be one with a Tree Data. */ columnId: string; /** Grouping Aggregators array */ - aggregators?: Aggregator[]; + // NOT YET IMPLEMENTED + // aggregators?: Aggregator[]; /** Optionally define the initial sort column and direction */ initialSort?: { @@ -29,9 +31,6 @@ export interface TreeDataOption { /** Defaults to "__parentId", object property name used to designate the Parent Id */ parentPropName?: string; - /** Defaults to empty string, add an optional prefix to each of the child values (in other words, add a prefix to all values which have at tree level indentation greater than 0) */ - indentedChildValuePrefix?: string; - /** Defaults to "__treeLevel", object property name used to designate the Tree Level depth number */ levelPropName?: string; @@ -52,4 +51,7 @@ export interface TreeDataOption { * and if we add a regular character like a dot then it keeps all tree level indentation spaces */ exportIndentationLeadingChar?: string; + + /** Optional Title Formatter (allows you to format/style the title text differently) */ + titleFormatter?: Formatter; } diff --git a/packages/common/src/services/export-utilities.ts b/packages/common/src/services/export-utilities.ts index ed1b2818f..d2d282bfe 100644 --- a/packages/common/src/services/export-utilities.ts +++ b/packages/common/src/services/export-utilities.ts @@ -1,7 +1,18 @@ import { Column, ExcelExportOption, Formatter, SlickGrid, TextExportOption } from '../interfaces/index'; +/** + * Goes through every possible ways to find and apply a Formatter when found, + * it will first check if a `exportCustomFormatter` is defined else it will check if there's a regular `formatter` and `exportWithFormatter` is enabled. + * This function is similar to `applyFormatterWhenDefined()` except that it execute any `exportCustomFormatter` while `applyFormatterWhenDefined` does not. + * @param {Number} row - grid row index + * @param {Number} col - grid column index + * @param {Object} dataContext - item data context object + * @param {Object} columnDef - column definition + * @param {Object} grid - Slick Grid object + * @param {Object} exportOptions - Excel or Text Export Options + * @returns formatted string output or empty string + */ export function exportWithFormatterWhenDefined(row: number, col: number, dataContext: any, columnDef: Column, grid: SlickGrid, exportOptions?: TextExportOption | ExcelExportOption) { - let output = ''; let isEvaluatingFormatter = false; // first check if there are any export options provided (as Grid Options) @@ -14,6 +25,31 @@ export function exportWithFormatterWhenDefined(row: number, col: number, dataCon isEvaluatingFormatter = !!columnDef.exportWithFormatter; } + let formatter: Formatter | undefined; + if (dataContext && columnDef.exportCustomFormatter) { + // did the user provide a Custom Formatter for the export + formatter = columnDef.exportCustomFormatter; + } else if (isEvaluatingFormatter && columnDef.formatter) { + // or else do we have a column Formatter AND are we evaluating it? + formatter = columnDef.formatter; + } + + return parseFormatterWhenExist(formatter, row, col, dataContext, columnDef, grid); +} + +/** + * Takes a Formatter function, execute and return the formatted output + * @param {Function} formatter - formatter function + * @param {Number} row - grid row index + * @param {Number} col - grid column index + * @param {Object} dataContext - item data context object + * @param {Object} columnDef - column definition + * @param {Object} grid - Slick Grid object + * @returns formatted string output or empty string + */ +export function parseFormatterWhenExist(formatter: Formatter | undefined, row: number, col: number, dataContext: any, columnDef: Column, grid: SlickGrid): string { + let output = ''; + // does the field have the dot (.) notation and is a complex object? if so pull the first property name const fieldId = columnDef.field || columnDef.id || ''; let fieldProperty = fieldId; @@ -24,15 +60,6 @@ export function exportWithFormatterWhenDefined(row: number, col: number, dataCon const cellValue = dataContext.hasOwnProperty(fieldProperty) ? dataContext[fieldProperty] : null; - let formatter: Formatter | undefined; - if (dataContext && columnDef.exportCustomFormatter) { - // did the user provide a Custom Formatter for the export - formatter = columnDef.exportCustomFormatter; - } else if (isEvaluatingFormatter && columnDef.formatter) { - // or else do we have a column Formatter AND are we evaluating it? - formatter = columnDef.formatter; - } - if (typeof formatter === 'function') { const formattedData = formatter(row, col, cellValue, columnDef, dataContext, grid); output = formattedData as string; diff --git a/packages/common/src/services/filter.service.ts b/packages/common/src/services/filter.service.ts index 78569c82c..bf869346e 100644 --- a/packages/common/src/services/filter.service.ts +++ b/packages/common/src/services/filter.service.ts @@ -221,7 +221,7 @@ export class FilterService { // emit an onFilterChanged event except when it's called by a clear filter if (!isClearFilterEvent) { - this.emitFilterChanged(EmitterType.local); + await this.emitFilterChanged(EmitterType.local); } }); } @@ -260,7 +260,7 @@ export class FilterService { } // emit an event when filter is cleared - this.emitFilterChanged(emitter); + await this.emitFilterChanged(emitter); return true; } @@ -645,7 +645,6 @@ export class FilterService { } else if (caller === EmitterType.local) { return this.pubSubService.publish(eventName, this.getCurrentLocalFilters()); } - return Promise.resolve(true); } async onBackendFilterChange(event: KeyboardEvent, args: any) { @@ -841,7 +840,7 @@ export class FilterService { } if (emitChangedEvent) { - this.emitFilterChanged(emitterType); + await this.emitFilterChanged(emitterType); } } return true; @@ -905,7 +904,7 @@ export class FilterService { } if (emitChangedEvent) { - this.emitFilterChanged(emitterType); + await this.emitFilterChanged(emitterType); } } return true; diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index 140884d28..48c86446f 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -887,7 +887,7 @@ export function sanitizeHtmlToText(htmlString: string): string { * @param dirtyHtml: dirty html string * @param domPurifyOptions: optional DOMPurify options when using that sanitizer */ -export function sanitizeTextByAvailableSanitizer(gridOptions: GridOption, dirtyHtml: string, domPurifyOptions?: DOMPurify.Config & { RETURN_TRUSTED_TYPE: true; }): string { +export function sanitizeTextByAvailableSanitizer(gridOptions: GridOption, dirtyHtml: string, domPurifyOptions?: DOMPurify.Config): string { let sanitizedText = dirtyHtml; if (gridOptions && typeof gridOptions.sanitizer === 'function') { sanitizedText = gridOptions.sanitizer(dirtyHtml || ''); diff --git a/packages/common/src/styles/slick-bootstrap.scss b/packages/common/src/styles/slick-bootstrap.scss index 5dcd49af2..9c86eed85 100644 --- a/packages/common/src/styles/slick-bootstrap.scss +++ b/packages/common/src/styles/slick-bootstrap.scss @@ -202,12 +202,12 @@ cursor: pointer; &.expanded:before { - vertical-align: $icon-group-vertical-align; display: inline-block; content: $icon-group-expanded; font-family: $icon-font-family; font-size: $icon-group-font-size; width: $icon-group-width; + vertical-align: $icon-group-vertical-align; } &.collapsed:before { @@ -216,6 +216,7 @@ font-family: $icon-font-family; font-size: $icon-group-font-size; width: $icon-group-width; + vertical-align: $icon-group-vertical-align; } } } diff --git a/packages/vanilla-bundle/src/services/resizer.service.ts b/packages/vanilla-bundle/src/services/resizer.service.ts index b5d6909c8..273aeaffc 100644 --- a/packages/vanilla-bundle/src/services/resizer.service.ts +++ b/packages/vanilla-bundle/src/services/resizer.service.ts @@ -1,10 +1,10 @@ import { - exportWithFormatterWhenDefined, FieldType, getHtmlElementOffset, GetSlickEventType, GridOption, GridSize, + parseFormatterWhenExist, PubSubService, sanitizeHtmlToText, SlickDataView, @@ -186,8 +186,7 @@ export class ResizerService { columnDefinitions.forEach((columnDef, colIdx) => { if (!columnDef.originalWidth) { const charWidthPx = columnDef?.resizeCharWidthInPx ?? resizeCellCharWidthInPx; - const exportOptions = this.gridOptions.enableTextExport ? this.gridOptions.exportOptions || this.gridOptions.textExportOptions : this.gridOptions.excelExportOptions; - const formattedData = exportWithFormatterWhenDefined(rowIdx, colIdx, item, columnDef, this._grid, exportOptions); + const formattedData = parseFormatterWhenExist(columnDef?.formatter, rowIdx, colIdx, item, columnDef, this._grid); const formattedDataSanitized = sanitizeHtmlToText(formattedData); const formattedTextWidthInPx = Math.ceil(formattedDataSanitized.length * charWidthPx); const resizeMaxWidthThreshold = columnDef.resizeMaxWidthThreshold;