Skip to content

Commit

Permalink
feat(composite): add new validateMassUpdateChange callback & bug fi…
Browse files Browse the repository at this point in the history
…xes (#603)

- add a new method `validateMassUpdateChange` that user can provide which when returning false will skip changes on that item data context, it will be analyzed for each row
- add `shouldPreviewMassChangeDataset` flag that when enabled will provide a preview of the dataset changes (mass update/selection) when calling the `onSave` function
- fix column name showing plain html when `name` is provided as html instead of simple text. Basically `name` can accept html that will be rendered and we should do the same in the Composite modal window
- also remove fixed button text, we will keep using translation keys instead
  • Loading branch information
ghiscoding authored Jan 5, 2022
1 parent 9c31f0f commit 2c1559b
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 43 deletions.
32 changes: 21 additions & 11 deletions examples/webpack-demo-vanilla-bundle/src/examples/example12.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class Example12 {
initializeGrid() {
this.columnDefinitions = [
{
id: 'title', name: 'Title', field: 'title', sortable: true, type: FieldType.string, minWidth: 75,
id: 'title', name: '<span title="Task must always be followed by a number" class="color-info mdi mdi-alert-circle"></span> Title', field: 'title', sortable: true, type: FieldType.string, minWidth: 75,
filterable: true, columnGroup: 'Common Factor',
filter: { model: Filters.compoundInputText },
formatter: Formatters.multiple, params: { formatters: [Formatters.uppercase, Formatters.bold] },
Expand Down Expand Up @@ -943,29 +943,39 @@ export class Example12 {
// backdrop: null,
// viewColumnLayout: 2, // responsive layout, choose from 'auto', 1, 2, or 3 (defaults to 'auto')
showFormResetButton: true,

// you can validate each row item dataContext before apply Mass Update/Selection changes via this validation callback (returning false would skip the change)
// validateMassUpdateChange: (fieldName, dataContext, formValues) => {
// const levelComplex = this.complexityLevelList.find(level => level.label === 'Complex');
// if (fieldName === 'duration' && (dataContext.complexity === levelComplex?.value || formValues.complexity === levelComplex?.value) && formValues.duration < 5) {
// // not good, do not apply the change because when it's "Complex", we assume the user has to be choose at least 5 days of work (duration)
// return false;
// }
// return true;
// },

// showResetButtonOnEachEditor: true,
onClose: () => Promise.resolve(confirm('You have unsaved changes, are you sure you want to close this window?')),
onError: (error) => alert(error.message),
onSave: (formValues, _selection, dataContext) => {
onSave: (formValues, _selection, dataContextOrUpdatedDatasetPreview) => {
const serverResponseDelay = 50;

// simulate a backend server call which will reject if the "% Complete" is below 50%
// when processing a mass update or mass selection
if (modalType === 'mass-update' || modalType === 'mass-selection') {
console.log(`${modalType} dataset preview`, dataContextOrUpdatedDatasetPreview);

// simulate a backend server call which will reject if the "% Complete" is below 50%
return new Promise((resolve, reject) => {
setTimeout(() => {
if (formValues.percentComplete >= 50) {
resolve(true);
} else {
reject('Unfortunately we only accept a minimum of 50% Completion...');
}
}, serverResponseDelay);
setTimeout(
() => (formValues.percentComplete >= 50) ? resolve(true) : reject('Unfortunately we only accept a minimum of 50% Completion...'),
serverResponseDelay
);
});
} else {
// also simulate a server cal for any other modal type (create/clone/edit)
// we'll just apply the change without any rejection from the server and
// note that we also have access to the "dataContext" which is only available for these modal
console.log(`${modalType} item data context`, dataContext);
console.log(`${modalType} item data context`, dataContextOrUpdatedDatasetPreview);
return new Promise(resolve => setTimeout(() => resolve(true), serverResponseDelay));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ export interface CompositeEditorOpenDetailOption {
/** Optionally provide a CSS class by the form reset button */
resetFormButtonIconCssClass?: string;

/**
* Defaults to false, do we want to provide a preview of what the dataset with the applied Mass changes (works for both Mass Update and/or Mass Selection)?
* If set to true, then it would provide a 4th argument to the `onSave` callback even before sending the data to the server.
* This could be useful to actually use this dataset preview to send directly to the backend server.
*/
shouldPreviewMassChangeDataset?: boolean;

/** Defaults to true, do we want the close button outside the modal (true) or inside the header modal (false)? */
showCloseButtonOutside?: boolean;

Expand All @@ -84,6 +91,10 @@ export interface CompositeEditorOpenDetailOption {
*/
viewColumnLayout?: 1 | 2 | 3 | 'auto';

// ---------
// Methods
// ---------

/** onBeforeOpen callback allows the user to optionally execute something before opening the modal (for example cancel any batch edits, or change/reset some validations in column definitions) */
onBeforeOpen?: () => void;

Expand All @@ -107,7 +118,23 @@ export interface CompositeEditorOpenDetailOption {
/** current selection of row indexes & data context Ids */
selection: CompositeEditorSelection,

/** optional item data context that is returned, this is only provided when the modal type is (clone, create or edit) */
dataContext?: any
/**
* optional item data context when the modal type is (clone, create or edit)
* OR a preview of the updated dataset when modal type is (mass-update or mass-selection).
* NOTE: the later requires `shouldPreviewMassChangeDataset` to be enabled since it could be resource heavy with large dataset.
*/
dataContextOrUpdatedDatasetPreview?: any | any[],
) => Promise<boolean>;

/**
* Optional callback that the user can add before applying the change to all item rows,
* if this callback returns False then the change will NOT be applied to the given field,
* or if on the other end it returns True or `undefined` then it assumes that it is valid and it should apply the change to the item dataContext.
* This callback works for both Mass Selection & Mass Update.
* @param {String} fieldName - field property name being validated
* @param {*} dataContext - item object data context
* @param {*} formValues - all form input and values that were changed
* @returns {Boolean} - returning False means we can't apply the change, else we go ahead and apply the change
*/
validateMassUpdateChange?: (fieldName: string, dataContext: any, formValues: any) => boolean;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'jest-extended';
import {
Column,
CompositeEditorOpenDetailOption,
Expand Down Expand Up @@ -336,7 +337,8 @@ describe('CompositeEditorService', () => {
expect(compositeContainerElm).toBeFalsy();
});

it('should make sure Slick-Composite-Editor is being created and rendered with 1 column layout', () => {
it('should make sure Slick-Composite-Editor is being created and rendered with 1 column layout & also expect column name html to be rendered as well', () => {
columnsMock[2].name = '<span class="mdi mdi-alert-circle" title="tooltip text"></span> Field 3'; // add tooltip
const mockProduct = { id: 222, address: { zip: 123456 }, productName: 'Product ABC', price: 12.55 };
jest.spyOn(gridStub, 'getDataItem').mockReturnValue(mockProduct);

Expand All @@ -361,14 +363,17 @@ describe('CompositeEditorService', () => {
expect(compositeContainerElm).toBeTruthy();
expect(compositeHeaderElm).toBeTruthy();
expect(productNameLabelElm.textContent).toBe('Product'); // regular, without column group
expect(field3LabelElm.textContent).toBe('Group Name - Field 3'); // with column group
expect(field3LabelElm.innerHTML).toBe('Group Name - <span title="tooltip text" class="mdi mdi-alert-circle"></span> Field 3'); // with column group
expect(compositeTitleElm).toBeTruthy();
expect(compositeTitleElm.textContent).toBe('Details');
expect(compositeBodyElm).toBeTruthy();
expect(compositeFooterElm).toBeTruthy();
expect(compositeFooterCancelBtnElm).toBeTruthy();
expect(compositeFooterSaveBtnElm).toBeTruthy();
expect(productNameDetailContainerElm).toBeTruthy();

// reset Field 3 column name
columnsMock[2].name = 'Field 3';
});

it('should make sure Slick-Composite-Editor is being created and expect form inputs to be in specific order when user provides column def "compositeEditorFormOrder"', () => {
Expand Down Expand Up @@ -546,7 +551,7 @@ describe('CompositeEditorService', () => {
});

it('should execute "onClose" callback when user confirms the closing of the modal when "onClose" callback is defined', (done) => {
const mockProduct = { id: 222, address: { zip: 123456 }, productName: 'Product ABC', price: 12.55 };
const mockProduct = { id: 222, address: { zip: 123456 }, productName: 'Product ABC', price: 12.55, field2: 'Test' };
jest.spyOn(gridStub, 'getDataItem').mockReturnValue(mockProduct);
const getEditSpy = jest.spyOn(gridStub, 'getEditController');
const cancelSpy = jest.spyOn(gridStub.getEditController(), 'cancelCurrentEdit');
Expand Down Expand Up @@ -1183,6 +1188,11 @@ describe('CompositeEditorService', () => {
});

describe('Form Logics', () => {
beforeEach(() => {
// reset Field 3 column name
columnsMock[2].name = 'Field 3';
});

it('should make sure Slick-Composite-Editor is being created and then call "changeFormInputValue" to change dynamically any of the form input value', () => {
const mockEditor = { setValue: jest.fn(), disable: jest.fn(), } as unknown as Editor;
const mockProduct = { id: 222, address: { zip: 123456 }, productName: 'Product ABC', price: 12.55 };
Expand Down Expand Up @@ -1782,6 +1792,64 @@ describe('CompositeEditorService', () => {
});
});

it('should handle saving and expect a dataset preview of the change when "shouldPreviewMassChangeDataset" is enabled and grid changes when "Mass Update" save button is clicked and user provides a custom "onSave" async function', (done) => {
const mockProduct1 = { id: 222, field3: 'something', address: { zip: 123456 }, product: { name: 'Product ABC', price: 12.55 } };
const mockProduct2 = { id: 333, field3: 'else', address: { zip: 789123 }, product: { name: 'Product XYZ', price: 33.44 } };
const currentEditorMock = { validate: jest.fn() };
jest.spyOn(dataViewStub, 'getItems').mockReturnValue([mockProduct1, mockProduct2]);
jest.spyOn(gridStub, 'getCellEditor').mockReturnValue(currentEditorMock as any);
jest.spyOn(currentEditorMock, 'validate').mockReturnValue({ valid: true, msg: null });
jest.spyOn(gridStateServiceStub, 'getCurrentRowSelections').mockReturnValue({ gridRowIndexes: [0], dataContextIds: [222] });
const getEditSpy = jest.spyOn(gridStub, 'getEditController');
const cancelCommitSpy = jest.spyOn(gridStub.getEditController(), 'cancelCurrentEdit');
const setActiveCellSpy = jest.spyOn(gridStub, 'setActiveCell');
const clearSelectionSpy = jest.spyOn(gridStub, 'setSelectedRows');
const setItemsSpy = jest.spyOn(dataViewStub, 'setItems');

const mockOnSave = jest.fn();
mockOnSave.mockResolvedValue(Promise.resolve(true));
const mockModalOptions = { headerTitle: 'Details', modalType: 'mass-update', onSave: mockOnSave, shouldClearRowSelectionAfterMassAction: false, shouldPreviewMassChangeDataset: true } as CompositeEditorOpenDetailOption;
component = new SlickCompositeEditorComponent();
component.init(gridStub, container);
component.openDetails(mockModalOptions);

const compositeContainerElm = document.querySelector('div.slick-editor-modal.slickgrid_123456') as HTMLSelectElement;
const compositeFooterSaveBtnElm = compositeContainerElm.querySelector('.btn-save') as HTMLSelectElement;
const compositeHeaderElm = document.querySelector('.slick-editor-modal-header') as HTMLSelectElement;
const compositeTitleElm = compositeHeaderElm.querySelector('.slick-editor-modal-title') as HTMLSelectElement;
const compositeBodyElm = document.querySelector('.slick-editor-modal-body') as HTMLSelectElement;
const field3DetailContainerElm = compositeBodyElm.querySelector('.item-details-container.editor-field3.slick-col-medium-12') as HTMLSelectElement;
const field3LabelElm = field3DetailContainerElm.querySelector('.item-details-label.editor-field3') as HTMLSelectElement;
const validationSummaryElm = compositeContainerElm.querySelector('.validation-summary') as HTMLSelectElement;

gridStub.onCompositeEditorChange.notify({ row: 0, cell: 0, column: columnsMock[0], item: mockProduct1, formValues: { field3: 'test' }, editors: {}, grid: gridStub });

compositeFooterSaveBtnElm.click();

setTimeout(() => {
expect(component).toBeTruthy();
expect(component.constructor).toBeDefined();
expect(compositeContainerElm).toBeTruthy();
expect(compositeHeaderElm).toBeTruthy();
expect(compositeTitleElm).toBeTruthy();
expect(compositeTitleElm.textContent).toBe('Details');
expect(field3LabelElm.textContent).toBe('Group Name - Field 3');
expect(getEditSpy).toHaveBeenCalledTimes(2);
expect(mockOnSave).toHaveBeenCalledWith(
{ field3: 'test' },
{ gridRowIndexes: [0], dataContextIds: [222] },
[{ address: { zip: 123456 }, field3: 'test', id: 222, product: { name: 'Product ABC', price: 12.55 } }, { address: { zip: 789123 }, field3: 'test', id: 333, product: { name: 'Product XYZ', price: 33.44 } }]
);
expect(setItemsSpy).toHaveBeenCalled();
expect(cancelCommitSpy).toHaveBeenCalled();
expect(setActiveCellSpy).toHaveBeenCalledWith(0, 0, false);
expect(validationSummaryElm.style.display).toBe('none');
expect(validationSummaryElm.textContent).toBe('');
expect(clearSelectionSpy).not.toHaveBeenCalled(); // shouldClearRowSelectionAfterMassAction is false
done();
});
});

it('should show a validation summary when clicking "Mass Update" save button and the custom "onSave" async function throws an error', (done) => {
const mockProduct1 = { id: 222, field3: 'something', address: { zip: 123456 }, product: { name: 'Product ABC', price: 12.55 } };
const mockProduct2 = { id: 333, field3: 'else', address: { zip: 789123 }, product: { name: 'Product XYZ', price: 33.44 } };
Expand Down
Loading

0 comments on commit 2c1559b

Please sign in to comment.