Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(composite): add new validateMassUpdateChange callback & bug fixes #603

Merged
merged 1 commit into from
Jan 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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