diff --git a/docs/TOC.md b/docs/TOC.md index 48e4ff5e0..4a532d6f7 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -54,6 +54,7 @@ * [Pinning (frozen) of Columns/Rows](grid-functionalities/frozen-columns-rows.md) * [Row Selection](grid-functionalities/Row-Selection.md) * [Tree Data Grid](grid-functionalities/Tree-Data-Grid.md) +* [Row Based Editing Plugin](grid-functionalities/Row-based-edit.md) ## Backend Services diff --git a/docs/column-functionalities/Editors.md b/docs/column-functionalities/Editors.md index 419b13121..958b757e8 100644 --- a/docs/column-functionalities/Editors.md +++ b/docs/column-functionalities/Editors.md @@ -369,4 +369,8 @@ For SalesForce it's nearly the same, the only difference is that we add our even
-
\ No newline at end of file + +``` + +## Turning individual rows into edit mode +Using the [Row Based Editing Plugin](../grid-functionalities/Row-based-edit.md) you can let the user toggle either one or multiple rows into edit mode, keep track of cell changes and either discard or save them on an individual basis using a custom `onBeforeRowUpdated` hook. \ No newline at end of file diff --git a/docs/grid-functionalities/Row-based-edit.md b/docs/grid-functionalities/Row-based-edit.md new file mode 100644 index 000000000..133d91118 --- /dev/null +++ b/docs/grid-functionalities/Row-based-edit.md @@ -0,0 +1,70 @@ +#### index +- [The action column](#the-action-column) +- [Multiple Row Selections](#multiple-row-selections) +- [Change Dynamically Single/Multiple Selections](#changing-dynamically-from-single-to-multiple-selections-and-vice-versa) +- [Mixing Single & Multiple Row Selections](#mixing-single--multiple-row-selections) +- [Disable Custom Rows Selections via `selectableOverride`](#disable-custom-rows-selections-via-selectableoverride) +- [Disable External Button when having Empty Selection](#disable-external-button-when-having-empty-selection) +- [Change Row Selections](#change-row-selections) +- Troubleshooting + - [Adding a Column dynamically is removing the Row Selection, why is that?](#adding-a-column-dynamically-is-removing-the-row-selection-why-is-that) + +### Description +The Row based editing plugin makes it possible to keep the grid readonly except for rows which the user explicitely toggles into edit mode. + +**Note:** This plugin enforces the use of the `autoEdit` option and will turn it on with a console warning if its not already. + +### Demo +[Demo Page](https://ghiscoding.github.io/slickgrid-universal/#/example22) / [Demo ViewModel](https://github.com/ghiscoding/slickgrid-universal/blob/master/examples/webpack-demo-vanilla-bundle/src/examples/example22.ts) + +## The action column +A new column is rendered that shows an edit/delete button. If the user clicks on edit, a save and cancel button are shown instead and the row toggles into edit mode. By default as the last column but you can override it with the option `columnIndexPosition`. Additionally it's default column id can be overriden using the opiton `columnId`. Furthermore, you can also override the columns label via the `actionsColumnLabel` property. + +### Single or multiple editable rows +By default you can only toggle a single row into edit mode. If you set the option `allowMultipleRows` to `true` on the other hand, you can toggle as many as you want. + +### Configuring the action buttons +You can override the styling, the hover text as well as whether a prompt — and with what text — should be shown. It is done by overriding the `actionButtons` property of the [plugins options](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/common/src/interfaces/rowBasedEditOption.interface.ts). + +## Support for the Excel Copy Buffer Plugin +If the [Excel Copy Buffer Plugin](excel-copy-buffer.md) is configured, the Row based editing pluging will override it's behavior by denying pastes on all cells not within a edit mode row. Nevertheless, any existing `BeforePasteCellHandler` will be respected. + +## How the plugin works +The idea of the plugin is to focus the users editing experience on specific individual rows and and save them individually. This is achieved by letting the user toggle one or more rows into edit mode. Now changes can be made to those rows and will be highlighted and tracked. The user may cancel the edit mode at any time and revert all cells changes. If the save button is pressed on the other hand an `onBeforeRowUpdated` hook, which you define via plugin options, is called and expects a `Promise`. In that method you'd typically write the changes to your backend and return either true or false based on the operations outcome. If a negative boolean is returned the edit mode is kept, otherwise the row applies the changes and toggles back into readonly mode. That means, no modifications can be done on the grid. + +Here's the respective code shown in Example22: + +#### ViewModel +```ts +onBeforeRowUpdated(args) { + const { effortDriven, percentComplete, finish, start, duration, title } = args.dataContext; + + if (duration > 40) { + alert('Sorry, 40 is the maximum allowed duration.'); + return Promise.resolve(false); + } + + return fakeFetch('your-backend-api/endpoint', { + method: 'POST', + body: JSON.stringify({ effortDriven, percentComplete, finish, start, duration, title }), + headers: { + 'Content-type': 'application/json; charset=UTF-8' + } + }).catch(err => { + console.error(err); + return false; + }) + .then(response => { + if (response === false) { // <---- the negative response, e.g validation failed, keep the row as is + return false; + } + if (typeof response === 'object') { + return response!.json(); + } + }) + .then(json => { + alert(json.message); + return true; // <--- all good, apply changes in grid and toggle row into readonly mode + }); +}, +``` \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/public/i18n/en.json b/examples/vite-demo-vanilla-bundle/public/i18n/en.json index c90558024..12757b0ad 100644 --- a/examples/vite-demo-vanilla-bundle/public/i18n/en.json +++ b/examples/vite-demo-vanilla-bundle/public/i18n/en.json @@ -99,5 +99,7 @@ "TASK_X": "Task {{x}}", "TITLE": "Title", "TRUE": "True", - "X_DAY_PLURAL": "{{x}} day{{plural}}" + "X_DAY_PLURAL": "{{x}} day{{plural}}", + "RBE_BTN_UPDATE": "Update the current row", + "RBE_BTN_CANCEL": "Cancel changes of the current row" } \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/public/i18n/fr.json b/examples/vite-demo-vanilla-bundle/public/i18n/fr.json index fa6361296..dd81ad3c9 100644 --- a/examples/vite-demo-vanilla-bundle/public/i18n/fr.json +++ b/examples/vite-demo-vanilla-bundle/public/i18n/fr.json @@ -100,5 +100,7 @@ "TITLE": "Titre", "TITLE.NAME": "Nom du Titre", "TRUE": "Vrai", - "X_DAY_PLURAL": "{{x}} journée{{plural}}" + "X_DAY_PLURAL": "{{x}} journée{{plural}}", + "RBE_BTN_UPDATE": "Mettre à jour la ligne actuelle", + "RBE_BTN_CANCEL": "Annuler la ligne actuelle" } \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index b4eafe9c2..24a5724ad 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -22,6 +22,7 @@ import Example18 from './examples/example18'; import Example19 from './examples/example19'; import Example20 from './examples/example20'; import Example21 from './examples/example21'; +import Example22 from './examples/example22'; export class AppRouting { constructor(private config: RouterConfig) { @@ -49,6 +50,7 @@ export class AppRouting { { route: 'example19', name: 'example19', view: './examples/example19.html', viewModel: Example19, title: 'Example19', }, { route: 'example20', name: 'example20', view: './examples/example20.html', viewModel: Example20, title: 'Example20', }, { route: 'example21', name: 'example21', view: './examples/example21.html', viewModel: Example21, title: 'Example21', }, + { route: 'example22', name: 'example22', view: './examples/example22.html', viewModel: Example22, title: 'Example22', }, { 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 1a1e6f995..35cbeec7e 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -98,6 +98,9 @@

Slickgrid-Universal

Example21 - Row Detail View + + Example22 - Row Based Editing + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example22.html b/examples/vite-demo-vanilla-bundle/src/examples/example22.html new file mode 100644 index 000000000..6af4588bd --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example22.html @@ -0,0 +1,72 @@ +

+ Example 22 - Row Based Editing + (with Salesforce Theme) + +

+ +
+ + +
+
+ + + Locale: + + +
+
+ +
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example22.scss b/examples/vite-demo-vanilla-bundle/src/examples/example22.scss new file mode 100644 index 000000000..29e7fc282 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example22.scss @@ -0,0 +1,9 @@ +:root { + // turn on/off the following variables to see the difference in styling + + // --slick-row-based-edit-editmode-bgcolor: rgb(82, 235, 158); + // --slick-row-based-edit-editmode-hover-bgcolor: cyan; + // --slick-row-based-edit-unsaved-cell-bgcolor: rgb(190, 114, 127); + // --slick-row-based-edit-editmode-active-bgcolor: rgb(82, 235, 158); + // --slick-row-based-edit-editmode-active-hover-bgcolor: cyan; +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example22.ts b/examples/vite-demo-vanilla-bundle/src/examples/example22.ts new file mode 100644 index 000000000..db15440c1 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example22.ts @@ -0,0 +1,253 @@ +import { + type Column, + FieldType, + Formatters, + type GridOption, + Editors, +} from '@slickgrid-universal/common'; +import { + Slicker, + SlickVanillaGridBundle, +} from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options'; + +import './example22.scss'; +import { TranslateService } from '../translate.service'; + +const NB_ITEMS = 20; + +export default class Example22 { + gridOptions!: GridOption; + columnDefinitions!: Column[]; + dataset!: any[]; + sgb!: SlickVanillaGridBundle; + translateService: TranslateService; + selectedLanguage: string; + selectedLanguageFile: string; + + constructor() { + this.translateService = (window).TranslateService; + this.selectedLanguage = this.translateService.getCurrentLanguage(); + this.selectedLanguageFile = `${this.selectedLanguage}.json`; + } + + attached() { + this.defineGrids(); + + // mock some data (different in each dataset) + this.dataset = this.mockData(NB_ITEMS); + + this.sgb = new Slicker.GridBundle( + document.querySelector(`.grid1`) as HTMLDivElement, + this.columnDefinitions, + { ...ExampleGridOptions, ...this.gridOptions }, + this.dataset + ); + } + + dispose() { + this.sgb?.dispose(); + } + + /* Define grid Options and Columns */ + defineGrids() { + this.columnDefinitions = [ + { + id: 'title', + name: 'Title', + field: 'title', + sortable: true, + minWidth: 100, + filterable: true, + editor: { model: Editors.text }, + }, + { + id: 'duration', + name: 'Duration (days)', + field: 'duration', + sortable: true, + minWidth: 100, + filterable: true, + type: FieldType.number, + editor: { model: Editors.text }, + }, + { + id: '%', + name: '% Complete', + field: 'percentComplete', + sortable: true, + minWidth: 100, + filterable: true, + type: FieldType.number, + editor: { model: Editors.text }, + }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + editor: { model: Editors.text }, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + editor: { model: Editors.text }, + }, + { + id: 'effort-driven', + name: 'Effort Driven', + field: 'effortDriven', + sortable: true, + minWidth: 100, + filterable: true, + editor: { model: Editors.text }, + }, + ]; + + this.gridOptions = { + enableAutoResize: false, + gridHeight: 225, + gridWidth: 800, + rowHeight: 33, + enableExcelCopyBuffer: true, + excelCopyBufferOptions: { + onBeforePasteCell: (_e, args) => { + // for the sake of the demo, do not allow to paste into the first column title + // this will be overriden by the row based edit plugin to additionally only work if the row is in editmode + return args.cell > 0; + }, + }, + // NOTE: this will be automatically turned to true by the Row Based Edit Plugin. + // A console warning will be shown if you omit this flag + autoEdit: false, + editable: true, + enableCellNavigation: true, + enableRowBasedEdit: true, + rowBasedEditOptions: { + allowMultipleRows: false, + onBeforeRowUpdated(args) { + const { effortDriven, percentComplete, finish, start, duration, title } = args.dataContext; + + if (duration > 40) { + alert('Sorry, 40 is the maximum allowed duration.'); + return Promise.resolve(false); + } + + return fakeFetch('your-backend-api/endpoint', { + method: 'POST', + body: JSON.stringify({ effortDriven, percentComplete, finish, start, duration, title }), + headers: { + 'Content-type': 'application/json; charset=UTF-8' + } + }).catch(err => { + console.error(err); + return false; + }) + .then(response => { + if (response === false) { + return false; + } + if (typeof response === 'object') { + return response!.json(); + } + }) + .then(json => { + alert(json.message); + return true; + }); + }, + actionColumnConfig: { // override the defaults of the action column + width: 100, + minWidth: 100, + maxWidth: 100, + }, + actionButtons: { + editButtonClassName: 'button-style padding-1px mr-2', + iconEditButtonClassName: 'mdi mdi-pencil', + // since no title and no titleKey is provided, it will fallback to the default text provided by the plugin + // if the title is provided but no titleKey, it will override the default text + // last but not least if a titleKey is provided, it will use the translation key to translate the text + // editButtonTitle: 'Edit row', + + cancelButtonClassName: 'button-style padding-1px', + cancelButtonTitle: 'Cancel row', + cancelButtonTitleKey: 'RBE_BTN_CANCEL', + iconCancelButtonClassName: 'mdi mdi-undo color-danger', + cancelButtonPrompt: 'Are you sure you want to cancel your changes?', + + updateButtonClassName: 'button-style padding-1px mr-2', + updateButtonTitle: 'Update row', + updateButtonTitleKey: 'RBE_BTN_UPDATE', + iconUpdateButtonClassName: 'mdi mdi-check color-success', + updateButtonPrompt: 'Save changes?', + + deleteButtonClassName: 'button-style padding-1px', + deleteButtonTitle: 'Delete row', + iconDeleteButtonClassName: 'mdi mdi-trash-can color-danger', + deleteButtonPrompt: 'Are you sure you want to delete this row?', + }, + }, + enableTranslate: true, + translater: this.translateService + }; + } + + mockData(count: number) { + // mock a dataset + const mockDataset: any[] = []; + for (let i = 0; i < count; i++) { + const randomYear = 2000 + Math.floor(Math.random() * 10); + const randomMonth = Math.floor(Math.random() * 11); + const randomDay = Math.floor(Math.random() * 29); + const randomPercent = Math.round(Math.random() * 100); + + mockDataset[i] = { + id: i, + title: 'Task ' + i, + duration: Math.round(Math.random() * 40) + '', + percentComplete: randomPercent, + start: new Date(randomYear, randomMonth + 1, randomDay), + finish: new Date(randomYear + 1, randomMonth + 1, randomDay), + effortDriven: i % 5 === 0, + }; + } + + return mockDataset; + } + + toggleSingleMultiRowEdit() { + this.sgb.gridOptions = { + ...this.sgb.gridOptions, + ...{ + rowBasedEditOptions: { + ...this.sgb.gridOptions.rowBasedEditOptions, + ...{ allowMultipleRows: !this.sgb.gridOptions.rowBasedEditOptions?.allowMultipleRows }, + }, + }, + }; + + this.gridOptions = this.sgb.gridOptions; + } + + async switchLanguage() { + const nextLanguage = (this.selectedLanguage === 'en') ? 'fr' : 'en'; + await this.translateService.use(nextLanguage); + this.selectedLanguage = nextLanguage; + this.selectedLanguageFile = `${this.selectedLanguage}.json`; + } +} + +function fakeFetch(_input: string | URL | Request, _init?: RequestInit | undefined): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(new Response(JSON.stringify({ status: 200, message: 'success' }))); + // reduces the delay for automated Cypress tests + }, (window as any).Cypress ? 10 : 500); + }); +} diff --git a/package.json b/package.json index a0d83b7a0..8d79fd255 100644 --- a/package.json +++ b/package.json @@ -95,5 +95,9 @@ "funding": { "type": "ko_fi", "url": "https://ko-fi.com/ghiscoding" + }, + "prettier": { + "singleQuote": true, + "printWidth": 120 } } \ No newline at end of file diff --git a/packages/common/src/enums/extensionName.enum.ts b/packages/common/src/enums/extensionName.enum.ts index 10a0c0387..6e9419b47 100644 --- a/packages/common/src/enums/extensionName.enum.ts +++ b/packages/common/src/enums/extensionName.enum.ts @@ -12,6 +12,7 @@ export enum ExtensionName { gridMenu = 'gridMenu', headerButton = 'headerButton', headerMenu = 'headerMenu', + rowBasedEdit = 'rowBasedEdit', rowDetailView = 'rowDetailView', rowMoveManager = 'rowMoveManager', rowSelection = 'rowSelection', diff --git a/packages/common/src/enums/slickPluginList.enum.ts b/packages/common/src/enums/slickPluginList.enum.ts index 7b1dcd3a1..de93d87e7 100644 --- a/packages/common/src/enums/slickPluginList.enum.ts +++ b/packages/common/src/enums/slickPluginList.enum.ts @@ -17,6 +17,7 @@ import type { SlickGroupItemMetadataProvider, SlickHeaderButtons, SlickHeaderMenu, + SlickRowBasedEdit, SlickRowMoveManager, SlickRowSelectionModel, } from '../extensions/index'; @@ -36,6 +37,7 @@ export type SlickPluginList = SlickGroupItemMetadataProvider | SlickHeaderButtons | SlickHeaderMenu | + SlickRowBasedEdit | SlickRowDetailView | SlickRowMoveManager | SlickRowSelectionModel; @@ -53,6 +55,7 @@ export type InferExtensionByName = T extends ExtensionName.groupItemMetaProvider ? SlickGroupItemMetadataProvider : T extends ExtensionName.headerButton ? SlickHeaderButtons : T extends ExtensionName.headerMenu ? SlickHeaderMenu : + T extends ExtensionName.rowBasedEdit ? SlickRowBasedEdit : T extends ExtensionName.rowDetailView ? SlickRowDetailView : T extends ExtensionName.rowMoveManager ? SlickRowMoveManager : T extends ExtensionName.rowSelection ? SlickRowSelectionModel : any; diff --git a/packages/common/src/extensions/__tests__/slickRowBasedEdit.spec.ts b/packages/common/src/extensions/__tests__/slickRowBasedEdit.spec.ts new file mode 100644 index 000000000..9ddf603a0 --- /dev/null +++ b/packages/common/src/extensions/__tests__/slickRowBasedEdit.spec.ts @@ -0,0 +1,769 @@ +import { BasePubSubService } from '@slickgrid-universal/event-pub-sub'; + +import type { + Column, + EditCommand, + GridOption, + OnBeforeEditCellEventArgs, + OnSetOptionsEventArgs, + RowBasedEditOptions, +} from '../../interfaces/index'; +import { SlickEvent, SlickGrid } from '../../core/index'; +import { + BTN_ACTION_CANCEL, + BTN_ACTION_DELETE, + BTN_ACTION_EDIT, + BTN_ACTION_UPDATE, + ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS, + SlickRowBasedEdit, +} from '../slickRowBasedEdit'; +import { GridService } from '../../services'; +import { Editors } from '../../editors'; +import { ExtensionUtility } from '../extensionUtility'; + +let addonOptions: RowBasedEditOptions = { + actionsColumnLabel: 'MyActions', + columnId: '_my_fancy_column_id', + allowMultipleRows: false, +}; + +const mockColumns = [ + // The column definitions + { name: 'Short', field: 'short', width: 100 }, + { + name: 'Medium', + field: 'medium', + width: 100, + editor: { model: Editors.text }, + }, + { name: 'Long', field: 'long', width: 100 }, + { name: 'Mixed', field: 'mixed', width: 100 }, + { name: 'Long header creates tooltip', field: 'header', width: 50 }, + { + name: 'Long header with predefined tooltip', + field: 'tooltipHeader', + width: 50, + toolTip: 'Already have a tooltip!', + }, +] as Column[]; + +const gridStubBlueprint = { + getData: jest.fn().mockReturnValue({ + getItemMetadata: jest.fn(), + getRowByItem: jest.fn(), + getRowById: jest.fn(), + }), + setCellCssStyles: jest.fn(), + removeCellCssStyles: jest.fn(), + getCellNode: jest.fn(), + getCellFromEvent: jest.fn(), + getOptions: jest.fn(), + getViewport: jest.fn().mockReturnValue({ top: 0, bottom: 0 }), + invalidateRows: jest.fn(), + setOptions: jest.fn(), + registerPlugin: jest.fn(), + onSetOptions: new SlickEvent(), + onBeforeEditCell: new SlickEvent(), + setColumns: jest.fn().mockImplementation((columns) => { + (gridStubBlueprint as any).columns = columns; + }), + invalidate: jest.fn(), + render: jest.fn(), + getColumns: jest.fn().mockImplementation(() => (gridStubBlueprint as any).columns || []), +} as unknown as SlickGrid; + +const extensionUtilityStub = { + +} as ExtensionUtility; + +const pubSubServiceStub = { + publish: jest.fn(), + subscribe: jest.fn(), + subscribeEvent: jest.fn(), + unsubscribe: jest.fn(), + unsubscribeAll: jest.fn(), +} as BasePubSubService; + +type MockedSlickGrid = SlickGrid & { + [K in keyof SlickGrid]: jest.Mock; +}; + +describe('Row Based Edit Plugin', () => { + let plugin: SlickRowBasedEdit; + let gridStub: MockedSlickGrid; + let gridService: GridService; + + beforeEach(() => { + const _any = {} as any; + gridStub = { + ...(gridStubBlueprint as unknown as SlickGrid), + columns: [...mockColumns], + } as unknown as MockedSlickGrid; + gridService = new GridService(_any, _any, _any, _any, _any, _any, _any); + jest.spyOn(gridService, 'getAllColumnDefinitions').mockReturnValue(mockColumns); + plugin = new SlickRowBasedEdit(extensionUtilityStub, pubSubServiceStub, addonOptions); + (plugin as any)._eventHandler = { + subscribe: jest.fn(), + unsubscribeAll: jest.fn(), + }; + + // add a getter eventHandler to the gridStub + Object.defineProperty(gridStub, 'eventHandler', { + get: jest.fn(() => (plugin as any)._eventHandler), + set: jest.fn(), + enumerable: true, + configurable: true, + }); + }); + + afterEach(() => { + plugin.destroy(); + plugin.dispose(); + }); + + it('should create the plugin', () => { + expect(plugin).toBeTruthy(); + expect(plugin.eventHandler).toBeTruthy(); + }); + + const optionsMock = { + autoEdit: true, + editable: true, + enableCellNavigation: true, + } as GridOption; + + it('should only allow cell editing when the currently edited row is in edit mode', () => { + const fakeItem = { id: 'test' }; + + gridStub.getData.mockReturnValue({ getItem: () => fakeItem }); + gridStub.getOptions.mockReturnValue(optionsMock); + + plugin.init(gridStub, gridService); + + const onBeforeEditCellHandler = (plugin.eventHandler.subscribe as jest.Mock).mock.calls[0][1] as ( + e: Event, + args: OnBeforeEditCellEventArgs + ) => boolean; + expect(onBeforeEditCellHandler({} as Event, { item: fakeItem } as OnBeforeEditCellEventArgs)).toBe(false); + + plugin.rowBasedEditCommandHandler(fakeItem, {} as Column, {} as EditCommand); + + expect(onBeforeEditCellHandler({} as Event, { item: fakeItem } as OnBeforeEditCellEventArgs)).toBe(true); + }); + + it('should throw an error when "enableRowEdit" is set without "enableCellNavigation"', () => { + gridStub.getOptions.mockReturnValue({}); + + expect(() => plugin.init(gridStub, gridService)).toThrow(/(enableCellNavigation = true)/); + }); + + it('should throw an error when "enableRowEdit" is set without "editable"', () => { + gridStub.getOptions.mockReturnValue({ enableCellNavigation: true }); + + expect(() => plugin.init(gridStub, gridService)).toThrow(/(editable = true)/); + }); + + it('should warn the user when autoEdit is not set or false and turn it on', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockReturnValue(); + gridStub.getOptions.mockReturnValue({ + enableCellNavigation: true, + editable: true, + autoEdit: false, + }); + + plugin.init(gridStub, gridService); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"autoEdit"')); + }); + + it('should override any existing edit command handler to keep track of changes performed on individual rows', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + + plugin.init(gridStub, gridService); + + expect(gridStub.setOptions).toHaveBeenCalledWith({ + editCommandHandler: expect.anything(), + }); + }); + + describe('when running the overriden edit command handler', () => { + it('should run original command handler first when running override edit command handler', () => { + const editCommandHandlerSpy = jest.fn(); + gridStub.getOptions.mockReturnValue({ + ...optionsMock, + editCommandHandler: editCommandHandlerSpy, + } as GridOption); + + plugin.init(gridStub, gridService); + plugin.rowBasedEditCommandHandler({} as any, {} as Column, {} as EditCommand); + expect(editCommandHandlerSpy).toHaveBeenCalled(); + }); + + it('should early exit if no matching column found', () => { + const editCommandHandlerSpy = jest.fn(); + gridStub.getOptions.mockReturnValue({ + ...optionsMock, + editCommandHandler: editCommandHandlerSpy, + } as GridOption); + + plugin.init(gridStub, gridService); + gridStub.invalidate.mockReset(); + + plugin.rowBasedEditCommandHandler( + {} as any, + undefined as unknown as Column, + { + prevSerializedValue: 'foo', + serializedValue: 'bar', + execute: () => {}, + } as EditCommand + ); + + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + + it('should handle prev and current serialized values as arrays', () => { + const editCommandHandlerSpy = jest.fn(); + gridStub.getOptions.mockReturnValue({ + ...optionsMock, + editCommandHandler: editCommandHandlerSpy, + } as GridOption); + + gridStub.getData().getRowById = () => 0; + + plugin.init(gridStub, gridService); + gridStub.invalidate.mockReset(); + + plugin.rowBasedEditCommandHandler( + {} as any, + undefined as unknown as Column, + { + prevSerializedValue: [], + serializedValue: ['bar'], + execute: () => {}, + } as EditCommand + ); + + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + }); + + describe('when excel copy buffer is enabled', () => { + const excelCopyBufferOptions = { + ...optionsMock, + enableExcelCopyBuffer: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should override the enableExcelCopyBuffer's before paste cell handler if the plugin is turned on", () => { + gridStub.getOptions.mockReturnValue(excelCopyBufferOptions); + + plugin.init(gridStub, gridService); + + expect(gridStub.setOptions).toHaveBeenCalledWith({ + excelCopyBufferOptions: { + onBeforePasteCell: expect.anything(), + }, + }); + }); + + it('should early exit if any user defined rule returned with false', () => { + gridStub.getOptions.mockReturnValue({ + ...excelCopyBufferOptions, + excelCopyBufferOptions: { onBeforePasteCell: () => false }, + }); + + plugin.init(gridStub, gridService); + + const call = gridStub.setOptions.mock.calls.find((c) => c[0].excelCopyBufferOptions)[0] as GridOption; + + const result = call.excelCopyBufferOptions!.onBeforePasteCell!.bind({ + existingBeforePasteCellHandler: () => false, + })({} as any, {} as any); + + expect(result).toBe(false); + }); + + it('should allow paste if the row is currently in edit mode', () => { + const fakeItem = { id: 'test' }; + gridStub.getOptions.mockReturnValue({ + ...excelCopyBufferOptions, + excelCopyBufferOptions: { onBeforePasteCell: () => true }, + }); + + plugin.init(gridStub, gridService); + + const call = gridStub.setOptions.mock.calls.find((c) => c[0].excelCopyBufferOptions)[0] as GridOption; + + gridStub.getData.mockReturnValue({ getItem: () => fakeItem }); + plugin.rowBasedEditCommandHandler(fakeItem, { id: 'test-column' } as Column, {} as EditCommand); + + const result = call.excelCopyBufferOptions!.onBeforePasteCell!.bind({ + existingBeforePasteCellHandler: () => false, + })({} as any, {} as any); + + expect(result).toBe(true); + }); + + it('should deny paste if both user rules and edit mode is false', () => { + gridStub.getOptions.mockReturnValue({ + ...excelCopyBufferOptions, + excelCopyBufferOptions: { onBeforePasteCell: () => true }, + }); + + plugin.init(gridStub, gridService); + + const call = gridStub.setOptions.mock.calls.find((c) => c[0].excelCopyBufferOptions)[0] as GridOption; + + const result = call.excelCopyBufferOptions!.onBeforePasteCell!.bind({ + existingBeforePasteCellHandler: () => false, + })({} as any, {} as any); + + expect(gridStub.getData).toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); + + it('should override the getItemMetaData', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + + gridStub.getData().getItemMetadata = 'TEST' as any; + plugin.init(gridStub, gridService); + + expect(gridStub.getData().getItemMetadata).not.toBe('TEST'); + }); + + it('should apply previous itemMetadata if available', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + const fakeItem = { id: 'test' }; + gridStub.getData.mockReturnValue({ getItem: () => fakeItem }); + const previousGetItemMetadataSpy = jest.fn(); + gridStub.getData().getItemMetadata = previousGetItemMetadataSpy; + plugin.init(gridStub, gridService); + + gridStub.getData().getItemMetadata(1); + + expect(previousGetItemMetadataSpy).toHaveBeenCalledWith(1); + }); + + it('should add the row highlight class if row is in editmode', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + const fakeItem = { id: 'test' }; + gridStub.getData.mockReturnValue({ getItem: () => fakeItem }); + plugin.init(gridStub, gridService); + + plugin.rowBasedEditCommandHandler(fakeItem, {} as Column, {} as EditCommand); + const meta = gridStub.getData().getItemMetadata(1); + + expect(meta?.cssClasses).toContain(ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS); + }); + + it('should remove the highlight class if row no longer in editmode', () => { + gridStub.getOptions.mockReturnValue({ + ...optionsMock, + datasetIdPropertyName: 'id', + }); + const fakeItem = { id: 'test' }; + gridStub.getData.mockReturnValue({ getItem: () => fakeItem }); + gridStub.getData().getItemMetadata = () => ({ + cssClasses: ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS, + }); + plugin.init(gridStub, gridService); + + const meta = gridStub.getData().getItemMetadata(1); + + expect(meta?.cssClasses).not.toContain(ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS); + }); + + it('should set up a otions updated listener', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + gridStub.onSetOptions = 'onSetOptions' as any; + plugin.init(gridStub, gridService); + + const call = (plugin.eventHandler.subscribe as jest.Mock).mock.calls.find( + (c) => c[0] === 'onSetOptions' + )[1] as (_e: Event, args: OnSetOptionsEventArgs) => void; + + call( + {} as Event, + { + optionsAfter: { rowBasedEditOptions: { foobar: true } }, + } as unknown as OnSetOptionsEventArgs + ); + + expect(plugin.addonOptions).toEqual(expect.objectContaining({ foobar: true })); + }); + + it('should remove all styles of rows on re-render and re-apply them', () => { + gridStub.getOptions.mockReturnValue(optionsMock); + gridStub.getData().onRowsOrCountChanged = 'onRowsOrCountChanged' as any; + plugin.init(gridStub, gridService); + + gridStub.getData.mockReturnValue({ + getItem: () => mockColumns[1], + getRowById: () => 0, + }); + + plugin.rowBasedEditCommandHandler(mockColumns[1], { id: 'test-column' } as Column, {} as EditCommand); + + plugin.rowBasedEditCommandHandler( + mockColumns[1], + { id: 'test-column' } as Column, + { + prevSerializedValue: 'foo', + serializedValue: 'bar', + execute: () => {}, + } as EditCommand + ); + + const call = (plugin.eventHandler.subscribe as jest.Mock).mock.calls.find( + (c) => c[0] === 'onRowsOrCountChanged' + )[1] as () => void; + + call(); + + expect(gridStub.removeCellCssStyles).toHaveBeenCalledTimes(1); + expect(gridStub.setCellCssStyles).toHaveBeenCalledTimes(2); + }); + + it('should cleanup all handlers and pubsub when destroyed', () => { + plugin.init(gridStub, gridService); + plugin.destroy(); + + expect(plugin.eventHandler.unsubscribeAll).toHaveBeenCalled(); + expect(pubSubServiceStub.unsubscribeAll).toHaveBeenCalled(); + }); + + describe('when creating the plugin', () => { + it('should merge default options with user provided options', () => { + const options = { ...addonOptions, editable: false }; + const cols = [...mockColumns]; + plugin.create(cols, options); + + expect(plugin.addonOptions).toEqual(expect.objectContaining({ columnIndexPosition: -1 })); + }); + + it('should add a custom column formatter to the action column', () => { + plugin.init(gridStub, gridService); + const actionColumn = plugin.getColumnDefinition(); + + const fragment = actionColumn.formatter?.(0, 0, undefined, {} as Column, 'test', gridStub); + + expect(fragment).toBeDefined(); + expect((fragment as DocumentFragment).hasChildNodes()).toBe(true); + + const actionBtns = (fragment as DocumentFragment).querySelectorAll('span.action-btns'); + expect(actionBtns.length).toBe(4); + }); + + it('should add the actions column at the end if columnIndexPosition not provided', () => { + const options = { ...addonOptions, editable: false }; + + const cols = [...mockColumns]; + plugin.create(cols, options); + + const actionColumn = plugin.getColumnDefinition(); + expect(cols.at(-1)?.name).toEqual(actionColumn.name); + }); + + [-1, 0, 2].forEach((position) => { + it('should position the actions column at the ' + position + ' position provided via columnIndexPosition', () => { + const options = { + ...addonOptions, + rowBasedEditOptions: { columnIndexPosition: position }, + editable: false, + } as GridOption; + + const cols = [...mockColumns]; + plugin.create(cols, options); + + const actionColumn = plugin.getColumnDefinition(); + expect(cols.at(position)?.name).toEqual(actionColumn.name); + }); + }); + + [-10, 100, mockColumns.length].forEach((position) => { + it('should position the columns at the start if position out of bounds: ' + position, () => { + const options = { + ...addonOptions, + rowBasedEditOptions: { columnIndexPosition: position }, + editable: false, + } as GridOption; + + const cols = [...mockColumns]; + plugin.create(cols, options); + + const actionColumn = plugin.getColumnDefinition(); + expect(cols.at(0)?.name).toEqual(actionColumn.name); + }); + }); + + it('should publish an onPluginColumnsChanged event when creating the plugin', () => { + const spy = jest.spyOn(pubSubServiceStub, 'publish'); + const options = { ...addonOptions, editable: false }; + const cols = [...mockColumns]; + plugin.create(cols, options); + + expect(spy).toHaveBeenCalledWith('onPluginColumnsChanged', expect.anything()); + }); + }); + + describe('the actions column', () => { + function arrange(addonOptions?: RowBasedEditOptions) { + const gridService = { + deleteItem: jest.fn(), + getAllColumnDefinitions: jest.fn().mockReturnValue(mockColumns), + } as unknown as GridService; + plugin = new SlickRowBasedEdit(extensionUtilityStub, pubSubServiceStub, addonOptions); + (plugin as any)._eventHandler = { + subscribe: jest.fn(), + unsubscribeAll: jest.fn(), + }; + plugin.init(gridStub, gridService); + const actionColumn = plugin.getColumnDefinition(); + gridStub.getData.mockReturnValue({ + getRowByItem: () => 0, + getRowById: () => 0, + }); + + const confirmSpy = jest.fn().mockReturnValue(false); + window.confirm = confirmSpy; + + return { + onCellClick: actionColumn.onCellClick!, + gridService, + confirmSpy, + }; + } + + function createFakeEvent(classToAdd: string, simulateChildClick = false) { + const fakeElement = document.createElement('span'); + + if (simulateChildClick) { + const fakeParent = document.createElement('div'); + fakeParent.classList.add(classToAdd); + fakeParent.appendChild(fakeElement); + } else { + fakeElement.classList.add(classToAdd); + } + + const event = { target: fakeElement } as unknown as Event; + + return event; + } + + it('should have overrideable action column options', () => { + const { onCellClick, gridService, confirmSpy } = arrange({ + actionColumnConfig: { + width: 100, + minWidth: 100, + maxWidth: 100, + }, + }); + + expect(plugin.getColumnDefinition()).toEqual( + expect.objectContaining({ + width: 100, + minWidth: 100, + maxWidth: 100, + }) + ); + }); + + it('should prompt before deletion if deleteButtonPrompt is defined and keep row if canceled', () => { + const { onCellClick, gridService, confirmSpy } = arrange({ + actionButtons: { deleteButtonPrompt: 'TEST' }, + }); + const fakeItem = { id: 'test' }; + + onCellClick(createFakeEvent(BTN_ACTION_DELETE), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(confirmSpy).toHaveBeenCalled(); + expect(gridService.deleteItem).not.toHaveBeenCalled(); + }); + + it('should exit editmode before deleting the item', () => { + const { onCellClick, gridService } = arrange(); + const fakeItem = { id: 'test' }; + + onCellClick(createFakeEvent(BTN_ACTION_DELETE), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridService.deleteItem).toHaveBeenCalled(); + }); + + it('should enter editmode when clicking the edit button', () => { + const { onCellClick } = arrange(); + const fakeItem = { id: 'test' }; + + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_EDIT), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridStub.invalidate).toHaveBeenCalledTimes(1); + }); + + it('should not enter editmode when not in allowMultipleRows mode and a previous row is already in editmode', () => { + const { onCellClick } = arrange(); + const fakeItem = { id: 'test' }; + + plugin.rowBasedEditCommandHandler(fakeItem, {} as Column, {} as EditCommand); + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_EDIT), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + + it('should prompt before updating if updateButtonPrompt is defined and edits happened and keep row in editmode', () => { + const { onCellClick, confirmSpy } = arrange({ + actionButtons: { updateButtonPrompt: 'TEST' }, + }); + const fakeItem = { id: 'test' }; + + plugin.rowBasedEditCommandHandler( + fakeItem, + {} as Column, + { + prevSerializedValue: 'foo', + serializedValue: 'bar', + execute: () => {}, + } as EditCommand + ); + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_UPDATE), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(confirmSpy).toHaveBeenCalled(); + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + + it('should call onBeforeRowUpdated and cancel if a non-true result is returned', () => { + const { onCellClick } = arrange({ + onBeforeRowUpdated: () => Promise.resolve(false), + }); + const fakeItem = { id: 'test' }; + + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_UPDATE), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + + it('should remove all cell css styles after updating', () => { + const { onCellClick } = arrange(); + const fakeItem = { id: 'test' }; + + gridStub.getColumns.mockReturnValue(mockColumns); + gridStub.removeCellCssStyles.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_UPDATE), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridStub.removeCellCssStyles).toHaveBeenCalled(); + }); + + it('should prompt before canceling if cancelButtonPrompt is defined and previous edits exist and keep row in editmode if canceled prompt', () => { + const { onCellClick, confirmSpy } = arrange({ + actionButtons: { cancelButtonPrompt: 'TEST' }, + }); + const fakeItem = { id: 'test' }; + + plugin.rowBasedEditCommandHandler( + fakeItem, + {} as Column, + { + prevSerializedValue: 'foo', + serializedValue: 'bar', + execute: () => {}, + } as EditCommand + ); + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_CANCEL), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(confirmSpy).toHaveBeenCalled(); + expect(gridStub.invalidate).not.toHaveBeenCalled(); + }); + + it('should undo row edits', () => { + const { onCellClick } = arrange(); + const fakeItem = { id: 'test' }; + const undoSpy = jest.fn(); + + plugin.rowBasedEditCommandHandler( + fakeItem, + {} as Column, + { + prevSerializedValue: 'foo', + serializedValue: 'bar', + execute: () => {}, + undo: undoSpy, + } as unknown as EditCommand + ); + gridStub.invalidate.mockClear(); + onCellClick(createFakeEvent(BTN_ACTION_CANCEL, true), { + row: 0, + cell: 0, + grid: gridStub, + columnDef: {} as Column, + dataContext: fakeItem, + dataView: gridStub.getData(), + }); + + expect(gridStub.invalidate).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/common/src/extensions/extensionUtility.ts b/packages/common/src/extensions/extensionUtility.ts index 912764429..c14af3bca 100644 --- a/packages/common/src/extensions/extensionUtility.ts +++ b/packages/common/src/extensions/extensionUtility.ts @@ -9,7 +9,7 @@ export class ExtensionUtility { constructor( private readonly sharedService: SharedService, private readonly backendUtilities?: BackendUtilityService, - private readonly translaterService?: TranslaterService + public readonly translaterService?: TranslaterService ) { } /** diff --git a/packages/common/src/extensions/index.ts b/packages/common/src/extensions/index.ts index 1fbee7c86..29e9d711d 100644 --- a/packages/common/src/extensions/index.ts +++ b/packages/common/src/extensions/index.ts @@ -14,5 +14,6 @@ export * from './slickGridMenu'; export * from './slickGroupItemMetadataProvider'; export * from './slickHeaderButtons'; export * from './slickHeaderMenu'; +export * from './slickRowBasedEdit'; export * from './slickRowMoveManager'; export * from './slickRowSelectionModel'; \ No newline at end of file diff --git a/packages/common/src/extensions/slickRowBasedEdit.ts b/packages/common/src/extensions/slickRowBasedEdit.ts new file mode 100644 index 000000000..dafe918ee --- /dev/null +++ b/packages/common/src/extensions/slickRowBasedEdit.ts @@ -0,0 +1,543 @@ +import { BasePubSubService } from '@slickgrid-universal/event-pub-sub'; +import { createDomElement } from '@slickgrid-universal/utils'; + +import type { + Column, + EditCommand, + GridOption, + OnBeforeEditCellEventArgs, + OnEventArgs, + OnRowsOrCountChangedEventArgs, + OnSetOptionsEventArgs, + RowBasedEditOptions, +} from '../interfaces/index'; +import { SlickEventData, SlickEventHandler, SlickGlobalEditorLock, type SlickGrid } from '../core/index'; +import { GridService } from '../services'; +import { ExtensionUtility } from './extensionUtility'; + +export const ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS = 'slick-rbe-editmode'; +export const ROW_BASED_EDIT_UNSAVED_CELL = 'slick-rbe-unsaved-cell'; +export const ROW_BASED_EDIT_UNSAVED_HIGHLIGHT_PREFIX = 'slick-rbe-unsaved-highlight'; +export const BTN_ACTION_DELETE = 'action-btns--delete'; +export const BTN_ACTION_EDIT = 'action-btns--edit'; +export const BTN_ACTION_UPDATE = 'action-btns--update'; +export const BTN_ACTION_CANCEL = 'action-btns--cancel'; + +export interface EditedRowDetails { + // the affected columns by the edits of the row + columns: Column[]; + // the edit commands of the row. This is used to undo the edits + editCommands: EditCommand[]; + // stores style keys for unsaved cells + cssStyleKeys: string[]; +} + +/** + * Row based edit plugin to add edit/delete buttons to each row and only allow editing rows currently in editmode + */ +export class SlickRowBasedEdit { + pluginName = 'RowBasedEdit' as const; + + protected _addonOptions?: RowBasedEditOptions; + protected _eventHandler: SlickEventHandler; + protected _grid!: SlickGrid; + protected _gridService?: GridService; + protected _defaults = { + actionsColumnLabel: 'Actions', + allowMultipleRows: false, + columnId: '_slick_rowbasededit_action', + columnIndexPosition: -1, + } as RowBasedEditOptions; + protected _editedRows: Map = new Map(); + + private _existingEditCommandHandler: ((item: any, column: Column, command: EditCommand) => void) | undefined; + private btnUpdateTitle: string = ''; + private btnEditTitle: string = ''; + private btnDeleteTitle: string = ''; + private btnCancelTitle: string = ''; + + /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ + constructor( + protected readonly extensionUtility: ExtensionUtility, + protected readonly pubSubService: BasePubSubService, + options?: RowBasedEditOptions + ) { + this._eventHandler = new SlickEventHandler(); + this._addonOptions = options; + } + + get addonOptions(): RowBasedEditOptions { + return this._addonOptions as RowBasedEditOptions; + } + + get gridOptions(): GridOption { + return this._grid.getOptions() || ({} as GridOption); + } + + get eventHandler(): SlickEventHandler { + return this._eventHandler; + } + + /** Initialize plugin. */ + init(grid: SlickGrid, gridService: GridService) { + this._grid = grid; + this._gridService = gridService; + this._addonOptions = { ...this._defaults, ...this.addonOptions }; + const dataView = this._grid.getData(); + this._eventHandler.subscribe(this._grid.onBeforeEditCell, this.onBeforeEditCellHandler); + this.checkOptionsRequirements(this.gridOptions); + + if (!this.gridOptions.autoEdit) { + this._grid.setOptions({ autoEdit: true }); + console.warn( + '[Slickgrid-Universal] The Row Based Edit Plugin works best with the gridOption "autoEdit" enabled, the option has now been set automatically for you.' + ); + } + + this._existingEditCommandHandler = this.gridOptions.editCommandHandler; + this._grid.setOptions({ + editCommandHandler: this.rowBasedEditCommandHandler.bind(this), + }); + + if (this.gridOptions.enableExcelCopyBuffer === true) { + const existingBeforePasteCellHandler = this.gridOptions.excelCopyBufferOptions?.onBeforePasteCell; + + this._grid.setOptions({ + excelCopyBufferOptions: { + ...this.gridOptions.excelCopyBufferOptions, + onBeforePasteCell: (e: SlickEventData, args: OnEventArgs) => { + let userResult = true; + if (existingBeforePasteCellHandler) { + userResult = existingBeforePasteCellHandler(e, args); + + if (userResult === false) { + return false; + } + } + + const item = dataView.getItem(args.row); + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + + if (this._editedRows.has(item[idProperty]) && userResult === true) { + return true; + } + + return false; + }, + }, + }); + } + + const originalGetItemMetadata = dataView.getItemMetadata; + dataView.getItemMetadata = this.updateItemMetadata(originalGetItemMetadata?.bind?.(dataView)); + this._eventHandler.subscribe(this._grid.onSetOptions, this.optionsUpdatedHandler.bind(this)); + this._eventHandler.subscribe(dataView.onRowsOrCountChanged, this.handleAllRowRerender.bind(this)); + + this.translate(); + this._grid.invalidate(); + } + + destroy() { + this.dispose(); + } + + /** Dispose (destroy) the SlickGrid 3rd party plugin */ + dispose() { + this._eventHandler?.unsubscribeAll(); + this.pubSubService?.unsubscribeAll(); + } + + create(columnDefinitions: Column[], gridOptions: GridOption): SlickRowBasedEdit | null { + this._addonOptions = { + ...this._defaults, + ...gridOptions.rowBasedEditOptions, + } as RowBasedEditOptions; + if (Array.isArray(columnDefinitions) && gridOptions) { + const selectionColumn: Column = this.getColumnDefinition(); + // add new action column unless it was already added + if (!columnDefinitions.some((col) => col.id === selectionColumn.id)) { + // column index position in the grid + const columnPosition = gridOptions?.rowBasedEditOptions?.columnIndexPosition ?? -1; + if (columnPosition === -1) { + columnDefinitions.push(selectionColumn); + } else if (columnPosition > 0 && columnPosition < columnDefinitions.length) { + columnDefinitions.splice(columnPosition, 0, selectionColumn); + } else { + columnDefinitions.unshift(selectionColumn); + } + this.pubSubService.publish(`onPluginColumnsChanged`, { + columns: columnDefinitions, + pluginName: this.pluginName, + }); + } + } + return this; + } + + getColumnDefinition(): Column { + const columnId = String(this._addonOptions?.columnId ?? this._defaults.columnId); + + return { + id: columnId, + name: this._addonOptions?.actionsColumnLabel, + field: 'action', + minWidth: 70, + width: 75, + maxWidth: 75, + excludeFromExport: true, + formatter: this.actionColumnFormatter.bind(this), + onCellClick: this.onCellClickHandler.bind(this), + ...(this._addonOptions?.actionColumnConfig ?? {}), + } as Column; + } + + rowBasedEditCommandHandler(item: any, column: Column, editCommand: EditCommand) { + if (this._existingEditCommandHandler) { + this._existingEditCommandHandler(item, column, editCommand); + } + + const prevSerializedValues = Array.isArray(editCommand.prevSerializedValue) + ? editCommand.prevSerializedValue + : [editCommand.prevSerializedValue]; + const serializedValues = Array.isArray(editCommand.serializedValue) + ? editCommand.serializedValue + : [editCommand.serializedValue]; + const editorColumns = this._gridService?.getAllColumnDefinitions().filter((col) => col.editor !== undefined); + + const modifiedColumns: Column[] = []; + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + prevSerializedValues.forEach((_val, index) => { + const prevSerializedValue = prevSerializedValues[index]; + const serializedValue = serializedValues[index]; + + if (prevSerializedValue !== serializedValue || serializedValue === '') { + /* istanbul ignore next */ + const finalColumn = Array.isArray(editCommand.prevSerializedValue) ? editorColumns?.[index] : column; + + if (!finalColumn) { + return; + } + + this._grid.invalidate(); + editCommand.execute(); + + this.renderUnsavedCellStyling(item[idProperty], finalColumn); + modifiedColumns.push(finalColumn); + } + }); + + const editedRow = this._editedRows.get(item[idProperty]); + const newCommands = [...(editedRow?.editCommands || [])]; + if (modifiedColumns.length > 0) { + newCommands.push(editCommand); + } + + this._editedRows.set(item[idProperty], { + columns: [...(editedRow?.columns || []), ...modifiedColumns], + editCommands: newCommands, + cssStyleKeys: editedRow?.cssStyleKeys || [], + }); + } + + translate() { + this.btnUpdateTitle = this.getTitleOrDefault('updateButtonTitle', 'Update the row'); + this.btnEditTitle = this.getTitleOrDefault('editButtonTitle', 'Edit the Row'); + this.btnDeleteTitle = this.getTitleOrDefault('deleteButtonTitle', 'Delete the Row'); + this.btnCancelTitle = this.getTitleOrDefault('cancelButtonTitle', 'Cancel changes of the Row'); + + const viewport = this._grid.getViewport(); + + this._grid.invalidateRows([...Array(viewport.bottom - viewport.top + 1).keys()].map((i) => i + viewport.top)); + this._grid.render(); + } + + protected checkOptionsRequirements(options: GridOption) { + if (!options?.enableCellNavigation) { + throw new Error( + `[Slickgrid-Universal] Row Based Edit Plugin requires the gridOption cell navigation (enableCellNavigation = true)` + ); + } + + if (!options?.editable) { + throw new Error(`[Slickgrid-Universal] Row Based Edit Plugin requires the gridOption editable (editable = true)`); + } + } + + protected undoRowEdit(item: any) { + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + const targetRow = this._editedRows.get(item[idProperty]); + const row = this._grid.getData().getRowByItem(item); + if ( + (row !== undefined && targetRow?.editCommands && targetRow.editCommands.length) || + /* istanbul ignore next */ + SlickGlobalEditorLock.cancelCurrentEdit() + ) { + while (targetRow!.editCommands.length > 0) { + const lastEdit = targetRow!.editCommands.pop(); + if (lastEdit) { + lastEdit.undo(); + } + } + + targetRow!.columns.forEach((column) => { + this.removeUnsavedStylingFromCell(column, row!); + }); + targetRow!.columns = []; + + this._grid.invalidate(); + } + } + + protected renderUnsavedCellStyling(id: any, column: Column) { + if (column) { + const row = this._grid.getData()?.getRowById(id); + if (row !== undefined && row >= 0) { + const hash = { [row]: { [column.id]: ROW_BASED_EDIT_UNSAVED_CELL } }; + const cssStyleKey = `${ROW_BASED_EDIT_UNSAVED_HIGHLIGHT_PREFIX}_${[column.id]}${row}`; + this._grid.setCellCssStyles(cssStyleKey, hash); + this._editedRows.get(id)?.cssStyleKeys.push(cssStyleKey); + } + } + } + + protected handleAllRowRerender(_e: SlickEventData, _args: OnRowsOrCountChangedEventArgs) { + this._editedRows.forEach((editedRow, key) => { + editedRow.cssStyleKeys.forEach((cssStyleKey) => { + this._grid.removeCellCssStyles(cssStyleKey); + }); + editedRow.cssStyleKeys = []; + editedRow.columns.forEach((column) => { + this.renderUnsavedCellStyling(key, column); + }); + }); + } + + protected removeUnsavedStylingFromCell(column: Column, row: number) { + const cssStyleKey = `${ROW_BASED_EDIT_UNSAVED_HIGHLIGHT_PREFIX}_${[column.id]}${row}`; + this._grid.removeCellCssStyles(cssStyleKey); + } + + protected removeUnsavedStylingFromRow(row: number) { + this._grid.getColumns().forEach((column) => { + this.removeUnsavedStylingFromCell(column, row); + }); + } + + protected optionsUpdatedHandler(_e: SlickEventData, args: OnSetOptionsEventArgs) { + this._addonOptions = { + ...this._defaults, + ...args.optionsAfter.rowBasedEditOptions, + } as RowBasedEditOptions; + } + + protected async onCellClickHandler(event: Event, args: any) { + const dataContext = args.dataContext; + const target = event.target as HTMLElement; + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + const targetRow = this._editedRows.get(dataContext[idProperty]); + if ( + (target.classList.contains(BTN_ACTION_DELETE) || target.parentElement?.classList.contains(BTN_ACTION_DELETE)) && + this._gridService + ) { + if ( + this._addonOptions?.actionButtons?.deleteButtonPrompt && + !window.confirm(this._addonOptions.actionButtons.deleteButtonPrompt) + ) { + return; + } + + this.toggleEditmode(dataContext, false); + this._gridService.deleteItem(dataContext); + } else if ( + target.classList.contains(BTN_ACTION_EDIT) || + target.parentElement?.classList.contains(BTN_ACTION_EDIT) + ) { + if (!this._addonOptions?.allowMultipleRows && this._editedRows.size > 0) { + return; + } + + this.toggleEditmode(dataContext, true); + } else if ( + target.classList.contains(BTN_ACTION_UPDATE) || + target.parentElement?.classList.contains(BTN_ACTION_UPDATE) + ) { + if ( + this._addonOptions?.actionButtons?.updateButtonPrompt && + (targetRow?.editCommands.length || 0) > 0 && + !window.confirm(this._addonOptions.actionButtons.updateButtonPrompt) + ) { + return; + } + + if (this._addonOptions?.onBeforeRowUpdated) { + const result = await this._addonOptions.onBeforeRowUpdated(args); + + if (result !== true) { + return; + } + } + + this.removeUnsavedStylingFromRow(args.row); + this.toggleEditmode(dataContext, false); + } else if ( + target.classList.contains(BTN_ACTION_CANCEL) || + target.parentElement?.classList.contains(BTN_ACTION_CANCEL) + ) { + if ( + this._addonOptions?.actionButtons?.cancelButtonPrompt && + (targetRow?.editCommands.length || 0) > 0 && + !window.confirm(this._addonOptions.actionButtons.cancelButtonPrompt) + ) { + return; + } + + this.undoRowEdit(dataContext); + this.toggleEditmode(dataContext, false); + } + } + + protected actionColumnFormatter(_row: number, _cell: number, _value: any, _columnDef: Column, dataContext: any) { + const options = this.gridOptions; + const isInEditMode = this._editedRows.has(dataContext?.[options.datasetIdPropertyName ?? 'id']); + + const actionFragment = document.createDocumentFragment(); + actionFragment + .appendChild( + createDomElement('span', { + className: + `${ + options.rowBasedEditOptions?.actionButtons?.editButtonClassName || 'button-style padding-1px mr-2' + } action-btns ` + BTN_ACTION_EDIT, + title: this.btnEditTitle, + style: { display: isInEditMode ? 'none' : '' }, + }) + ) + .appendChild( + createDomElement('span', { + className: + options.rowBasedEditOptions?.actionButtons?.iconEditButtonClassName || 'mdi mdi-table-edit color-primary', + }) + ); + actionFragment + .appendChild( + createDomElement('span', { + className: + `${ + options.rowBasedEditOptions?.actionButtons?.deleteButtonClassName || 'button-style padding-1px' + } action-btns ` + BTN_ACTION_DELETE, + title: this.btnDeleteTitle, + style: { display: isInEditMode ? 'none' : '' }, + }) + ) + .appendChild( + createDomElement('span', { + className: + options.rowBasedEditOptions?.actionButtons?.iconDeleteButtonClassName || 'mdi mdi-close color-danger', + }) + ); + actionFragment + .appendChild( + createDomElement('span', { + className: + `${ + options.rowBasedEditOptions?.actionButtons?.updateButtonClassName || 'button-style padding-1px mr-2' + } action-btns ` + BTN_ACTION_UPDATE, + title: this.btnUpdateTitle, + style: { display: !isInEditMode ? 'none' : '' }, + }) + ) + .appendChild( + createDomElement('span', { + className: + options.rowBasedEditOptions?.actionButtons?.iconUpdateButtonClassName || 'mdi mdi-check-bold color-success', + }) + ); + actionFragment + .appendChild( + createDomElement('span', { + className: + `${ + options.rowBasedEditOptions?.actionButtons?.cancelButtonClassName || 'button-style padding-1px' + } action-btns ` + BTN_ACTION_CANCEL, + title: this.btnCancelTitle, + style: { display: !isInEditMode ? 'none' : '' }, + }) + ) + .appendChild( + createDomElement('span', { + className: + options.rowBasedEditOptions?.actionButtons?.iconCancelButtonClassName || 'mdi mdi-cancel color-danger', + }) + ); + + return actionFragment; + } + + protected onBeforeEditCellHandler = (_e: SlickEventData, args: OnBeforeEditCellEventArgs) => { + return this._editedRows.has(args.item?.[this.gridOptions.datasetIdPropertyName ?? 'id']); + }; + + protected toggleEditmode(dataContext: any, editMode: boolean) { + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + if (editMode) { + this._editedRows.set(dataContext[idProperty], { + columns: [], + editCommands: [], + cssStyleKeys: [], + }); + } else { + this._editedRows.delete(dataContext[idProperty]); + } + + this._grid.invalidate(); + } + + protected updateItemMetadata(previousItemMetadata: any) { + return (rowNumber: number) => { + const item = this._grid.getData().getItem(rowNumber); + let meta = { + cssClasses: '', + }; + if (typeof previousItemMetadata === 'function') { + const previousMeta = previousItemMetadata(rowNumber); + if (previousMeta) { + meta = previousMeta; + } + } + + if (meta && item) { + const idProperty = this.gridOptions.datasetIdPropertyName ?? 'id'; + if (this._editedRows.has(item[idProperty]) && !meta.cssClasses.includes(ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS)) { + meta.cssClasses = (meta.cssClasses || '') + ' ' + ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS; + } else if ( + !this._editedRows.has(item[idProperty]) && + meta.cssClasses.includes(ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS) + ) { + meta.cssClasses = meta.cssClasses.replace(ROW_BASED_EDIT_ROW_HIGHLIGHT_CLASS, ''); + } + } + + return meta; + }; + } + + protected getTitleOrDefault(key: ActionButtonTitles, defaultTitle: string) { + const actionBtnOptions = this.gridOptions.rowBasedEditOptions?.actionButtons; + return ( + (actionBtnOptions?.[(key + 'Key') as ActionButtonTitleKeys] && + this.extensionUtility.translaterService?.translate?.( + actionBtnOptions?.[(key + 'Key') as ActionButtonTitleKeys] || '' + )) || + actionBtnOptions?.[key] || + defaultTitle + ); + } +} + +type IsDefined = T extends undefined ? never : T; +type ActionButtonTitles = keyof { + [K in keyof IsDefined as K extends `${string}Title` ? K : never]: IsDefined< + RowBasedEditOptions['actionButtons'] + >[K]; +}; +type ActionButtonTitleKeys = `${ActionButtonTitles}Key`; diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 032f4e6b8..d4aa3f2a4 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -30,6 +30,7 @@ import type { OperatorDetailAlt, Pagination, ResizeByContentOption, + RowBasedEditOptions, RowDetailView, RowMoveManager, RowSelectionModelOption, @@ -448,6 +449,12 @@ export interface GridOption { /** Do we want to enable Tree Data grid? */ enableTreeData?: boolean; + /** Enable the row based editing plugin */ + enableRowBasedEdit?: boolean; + + /** Options for the row based editing plugin */ + rowBasedEditOptions?: RowBasedEditOptions; + /** * Event naming style for the exposed SlickGrid & Component Events * Style could be (camelCase, lowerCase, kebabCase) diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 0ef32a0b2..fdfa69a2b 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -129,6 +129,7 @@ export * from './pagingInfo.interface'; export * from './resizeByContentOption.interface'; export * from './resizer.interface'; export * from './resizerOption.interface'; +export * from './rowBasedEditOption.interface'; export * from './rowDetailView.interface'; export * from './rowDetailViewOption.interface'; export * from './rowMoveManager.interface'; diff --git a/packages/common/src/interfaces/rowBasedEditOption.interface.ts b/packages/common/src/interfaces/rowBasedEditOption.interface.ts new file mode 100644 index 000000000..4ea1099ca --- /dev/null +++ b/packages/common/src/interfaces/rowBasedEditOption.interface.ts @@ -0,0 +1,67 @@ +import { SlickRowBasedEdit } from '../extensions'; +import { Column } from './column.interface'; +import { OnEventArgs } from './onEventArgs.interface'; + +export interface RowBasedEditOptions { + /** Fired after extension (plugin) is registered by SlickGrid */ + onExtensionRegistered?: (plugin: SlickRowBasedEdit) => void; + + /** the display name of the added actions column */ + actionsColumnLabel?: string; + /** method called before row gets updated. Needs to return a promised boolean. True will continue; False will halt the update */ + onBeforeRowUpdated?: (args: OnEventArgs) => Promise; + /** whether multiple rows can be toggled into edit mode at the same itme (default: false) */ + allowMultipleRows?: boolean; + + /** Defaults to "_slick_rowbasededit_action", Row Detail column Id */ + columnId?: string; + + /** + * Defaults to -1, the column index position in the grid by default it will show as the last column. + * Also note that the index position might vary if you use other extensions, after each extension is created, + * it will add an offset to take into consideration (1.CheckboxSelector, 2.RowDetail, 3.RowMove) + */ + columnIndexPosition?: number; + + /** + * additional column configurations for the action column. You can override the defaults by passing your own Column definition. + */ + actionColumnConfig?: Partial>; + + /** Allows to override the styling and titles of the actions buttons */ + actionButtons?: { + editButtonClassName?: string; + iconEditButtonClassName?: string; + /** The tooltip to show on the edit button */ + editButtonTitle?: string; + /** Same as "editButtonTitle", except that it's a translation key which can be used on page load and/or when switching locale */ + editButtonTitleKey?: string; + + deleteButtonClassName?: string; + iconDeleteButtonClassName?: string; + /** The tooltip to show on the delete button */ + deleteButtonTitle?: string; + /** Same as "deleteButtonTitle", except that it's a translation key which can be used on page load and/or when switching locale */ + deleteButtonTitleKey?: string; + /** if defined, a confirm prompt will be shown before deleting a row */ + deleteButtonPrompt?: string; + + cancelButtonClassName?: string; + iconCancelButtonClassName?: string; + /** The tooltip to show on the cancel button */ + cancelButtonTitle?: string; + /** Same as "cancelButtonTitle", except that it's a translation key which can be used on page load and/or when switching locale */ + cancelButtonTitleKey?: string; + /** if defined, a confirm prompt will be shown before canceling the changes of a row */ + cancelButtonPrompt?: string; + + updateButtonClassName?: string; + iconUpdateButtonClassName?: string; + /** The tooltip to show on the update button */ + updateButtonTitle?: string; + /** Same as "updateButtonTitle", except that it's a translation key which can be used on page load and/or when switching locale */ + updateButtonTitleKey?: string; + /** if defined, a confirm prompt will be shown before saving the changes of a row */ + updateButtonPrompt?: string; + } +} \ No newline at end of file diff --git a/packages/common/src/services/__tests__/extension.service.spec.ts b/packages/common/src/services/__tests__/extension.service.spec.ts index 2c1cc05a5..16a422a1b 100644 --- a/packages/common/src/services/__tests__/extension.service.spec.ts +++ b/packages/common/src/services/__tests__/extension.service.spec.ts @@ -5,8 +5,8 @@ import { BasePubSubService } from '@slickgrid-universal/event-pub-sub'; import { ExtensionName } from '../../enums/index'; import { Column, ExtensionModel, GridOption } from '../../interfaces/index'; -import { ExtensionUtility } from '../../extensions'; -import { ExtensionService, FilterService, SharedService, SortService, TreeDataService } from '../index'; +import { ExtensionUtility, SlickRowBasedEdit } from '../../extensions'; +import { ExtensionService, FilterService, GridService, SharedService, SortService, TreeDataService } from '../index'; import { SlickEvent, SlickGrid } from '../../core/index'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; import { @@ -190,6 +190,7 @@ describe('ExtensionService', () => { sortServiceStub, treeDataServiceStub, translateService, + () => ({}) as GridService ); jest.spyOn(gridStub, 'getContainerNode').mockReturnValue(document.body as HTMLDivElement); }); @@ -309,6 +310,60 @@ describe('ExtensionService', () => { expect(output!.instance instanceof SlickAutoTooltip).toBeTrue(); }); + it('should register the row based edit plugin when "enableRowBasedEdit" and "editable" is set in the grid options', () => { + const onRegisteredMock = jest.fn(); + const gridOptionsMock = { + enableRowBasedEdit: true, + editable: true, + rowBasedEditOptions: { + onExtensionRegistered: onRegisteredMock + } + } as GridOption; + const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; + const gridSpy = jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + const extSpy = jest.spyOn(SlickRowBasedEdit.prototype, 'init').mockImplementation(); + + service.createExtensionsBeforeGridCreation(columnsMock, gridOptionsMock); + service.bindDifferentExtensions(); + const output = service.getExtensionByName(ExtensionName.rowBasedEdit); + const pluginInstance = service.getExtensionInstanceByName(ExtensionName.rowBasedEdit); + + expect(onRegisteredMock).toHaveBeenCalledWith(expect.toBeObject()); + expect(gridSpy).toHaveBeenCalled(); + expect(extSpy).toHaveBeenCalled(); + expect(pluginInstance).toBeTruthy(); + expect(output!.instance).toEqual(pluginInstance); + expect(output).toEqual({ name: ExtensionName.rowBasedEdit, instance: pluginInstance } as ExtensionModel); + }); + + it('should throw a custom exception if gridService not ready during row based plugin instantiation', () => { + const onRegisteredMock = jest.fn(); + const gridOptionsMock = { + enableRowBasedEdit: true, + editable: true, + rowBasedEditOptions: { + onExtensionRegistered: onRegisteredMock + } + } as GridOption; + const gridSpy = jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + const extSpy = jest.spyOn(SlickRowBasedEdit.prototype, 'init').mockImplementation(); + + service = new ExtensionService( + extensionUtilityStub, + filterServiceStub, + pubSubServiceStub, + sharedService, + sortServiceStub, + treeDataServiceStub, + translateService, + () => undefined as unknown as GridService + ); + + expect(() => service.bindDifferentExtensions()).toThrow(); + }); + it('should register the ColumnPicker addon when "enableColumnPicker" is set in the grid options', () => { const gridOptionsMock = { enableColumnPicker: true } as GridOption; jest.spyOn(extensionColumnPickerStub, 'register').mockReturnValueOnce(instanceMock); @@ -558,6 +613,16 @@ describe('ExtensionService', () => { expect(extSpy).toHaveBeenCalledWith(columnsMock, gridOptionsMock); }); + it('should call rowBasedEditplugin create when "enableRowBasedEdit" is set in the grid options provided', () => { + const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; + const gridOptionsMock = { enableRowBasedEdit: true } as GridOption; + const extSpy = jest.spyOn(SlickRowBasedEdit.prototype, 'create'); + + service.createExtensionsBeforeGridCreation(columnsMock, gridOptionsMock); + + expect(extSpy).toHaveBeenCalledWith(columnsMock, gridOptionsMock); + }); + it('should call draggableGroupingExtension create when "enableDraggableGrouping" is set in the grid options provided', () => { const columnsMock = [{ id: 'field1', field: 'field1', width: 100, cssClass: 'red' }] as Column[]; const gridOptionsMock = { enableDraggableGrouping: true } as GridOption; diff --git a/packages/common/src/services/extension.service.ts b/packages/common/src/services/extension.service.ts index b0798bc16..a0a8f8eb2 100644 --- a/packages/common/src/services/extension.service.ts +++ b/packages/common/src/services/extension.service.ts @@ -17,17 +17,19 @@ import { SlickGroupItemMetadataProvider, SlickHeaderButtons, SlickHeaderMenu, + SlickRowBasedEdit, SlickRowMoveManager, SlickRowSelectionModel } from '../extensions/index'; import type { FilterService } from './filter.service'; import type { SortService } from './sort.service'; import type { TreeDataService } from './treeData.service'; +import { GridService } from './grid.service'; interface ExtensionWithColumnIndexPosition { name: ExtensionName; columnIndexPosition: number; - extension: SlickCheckboxSelectColumn | SlickRowDetailView | SlickRowMoveManager; + extension: SlickCheckboxSelectColumn | SlickRowDetailView | SlickRowMoveManager | SlickRowBasedEdit; } export class ExtensionService { @@ -45,6 +47,7 @@ export class ExtensionService { protected _headerMenuPlugin?: SlickHeaderMenu; protected _rowMoveManagerPlugin?: SlickRowMoveManager; protected _rowSelectionModel?: SlickRowSelectionModel; + protected _rowBasedEdit?: SlickRowBasedEdit; get extensionList() { return this._extensionList; @@ -62,6 +65,7 @@ export class ExtensionService { protected readonly sortService: SortService, protected readonly treeDataService: TreeDataService, protected readonly translaterService?: TranslaterService, + protected readonly lazyGridService?: () => GridService ) { } /** Dispose of all the controls & plugins */ @@ -162,6 +166,21 @@ export class ExtensionService { this.translateItems(this.sharedService.allColumns, 'nameKey', 'name'); } + // Row Based Edit Plugin + if (this.gridOptions.enableRowBasedEdit) { + this._rowBasedEdit = this._rowBasedEdit || new SlickRowBasedEdit(this.extensionUtility, this.pubSubService, this.gridOptions.rowBasedEditOptions); + const gridService = this.lazyGridService?.(); + if (!gridService) { + throw new Error('[Slickgrid-Universal] the RowBasedEdit Plugin requires a GridService to be configured and available'); + } + + this._rowBasedEdit.init(this.sharedService.slickGrid, gridService); + if (this.gridOptions.rowBasedEditOptions?.onExtensionRegistered) { + this.gridOptions.rowBasedEditOptions.onExtensionRegistered(this._rowBasedEdit); + } + this._extensionList[ExtensionName.rowBasedEdit] = { name: ExtensionName.rowBasedEdit, instance: this._rowBasedEdit }; + } + // Auto Tooltip Plugin if (this.gridOptions.enableAutoTooltip) { const instance = new SlickAutoTooltip(this.gridOptions?.autoTooltipOptions); @@ -309,6 +328,12 @@ export class ExtensionService { featureWithColumnIndexPositions.push({ name: ExtensionName.rowMoveManager, extension: this._rowMoveManagerPlugin, columnIndexPosition: gridOptions?.rowMoveManager?.columnIndexPosition ?? featureWithColumnIndexPositions.length }); } } + if (gridOptions.enableRowBasedEdit) { + if (!this.getCreatedExtensionByName(ExtensionName.rowBasedEdit)) { + this._rowBasedEdit = new SlickRowBasedEdit(this.extensionUtility, this.pubSubService); + featureWithColumnIndexPositions.push({ name: ExtensionName.rowBasedEdit, extension: this._rowBasedEdit, columnIndexPosition: gridOptions?.rowMoveManager?.columnIndexPosition ?? featureWithColumnIndexPositions.length }); + } + } // since some features could have a `columnIndexPosition`, we need to make sure these indexes are respected in the column definitions this.createExtensionByTheirColumnIndex(featureWithColumnIndexPositions, columnDefinitions, gridOptions); @@ -358,6 +383,7 @@ export class ExtensionService { this.translateContextMenu(); this.translateGridMenu(); this.translateHeaderMenu(); + this.translateRowEditPlugin(); } /** Translate the Cell Menu titles, we need to loop through all column definition to re-translate them */ @@ -391,6 +417,13 @@ export class ExtensionService { this._headerMenuPlugin?.translateHeaderMenu?.(); } + /** + * Translate the action column buttons of the Row Based Edit Plugin + */ + translateRowEditPlugin() { + this._rowBasedEdit?.translate?.(); + } + /** * Translate manually the header titles. * We could optionally pass a locale (that will change currently loaded locale), else it will use current locale diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index 093f9fd2e..d14675046 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -261,6 +261,13 @@ $slick-detail-view-container-left: 0 !default; $slick-detail-view-container-padding: 10px !default; $slick-detail-view-container-z-index: 10 !default; +/** Row based edit plugin */ +$slick-row-based-edit-unsaved-cell-bgcolor: #f3ed91 !default; +$slick-row-based-edit-editmode-active-bgcolor: darken($slick-grid-cell-color, 15%) !default; +$slick-row-based-edit-editmode-active-hover-bgcolor: darken($slick-grid-cell-color, 10%) !default; +$slick-row-based-edit-editmode-bgcolor: darken($slick-grid-cell-color, 15%) !default; +$slick-row-based-edit-editmode-hover-bgcolor: darken($slick-grid-cell-color, 10%) !default; + /* Excel copy plugin */ $slick-copied-cell-bg-color-transition: rgba(0, 0, 255, 0.2) !default; $slick-copied-cell-transition: 0.5s background !default; diff --git a/packages/common/src/styles/slick-bootstrap.scss b/packages/common/src/styles/slick-bootstrap.scss index 060ea0d53..e74c5840a 100644 --- a/packages/common/src/styles/slick-bootstrap.scss +++ b/packages/common/src/styles/slick-bootstrap.scss @@ -107,6 +107,27 @@ font-size: var(--slick-group-totals-formatter-font-size, $slick-group-totals-formatter-font-size); } } + + &.slick-rbe-editmode.active .slick-cell, + &.slick-rbe-editmode .slick-cell { + background-color: var(--slick-row-based-edit-editmode-bgcolor, $slick-row-based-edit-editmode-bgcolor); + + &:hover { + background-color: var(--slick-row-based-edit-editmode-hover-bgcolor, $slick-row-based-edit-editmode-hover-bgcolor); + + .active { + background-color: var(--slick-row-based-edit-editmode-active-hover-bgcolor, $slick-row-based-edit-editmode-active-hover-bgcolor) !important; + } + } + + .active { + background-color: var(--slick-row-based-edit-editmode-active-bgcolor, $slick-row-based-edit-editmode-active-bgcolor); + + &:hover { + background-color: var(--slick-row-based-edit-editmode-active-hover-bgcolor, $slick-row-based-edit-editmode-active-hover-bgcolor); + } + } + } } .slick-cell, .slick-headerrow-column { border-top: var(--slick-cell-border-top, $slick-cell-border-top); @@ -124,6 +145,10 @@ .slick-cell { @include resetSlickCell(); + &.slick-rbe-unsaved-cell { + background-color: var(--slick-row-based-edit-unsaved-cell-bgcolor, $slick-row-based-edit-unsaved-cell-bgcolor) !important; + } + a, a:visited, .ui-widget-content a, .ui-widget-content a:visited { color: var(--slick-link-color, $slick-link-color); } diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index 20ff8839d..67dfc6ba5 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -74,8 +74,11 @@ transform: translate(0, -2px); } } - } + &.slick-rbe-unsaved-cell { + background-color: var(--slick-row-based-edit-unsaved-cell-bgcolor, $slick-row-based-edit-unsaved-cell-bgcolor) !important; + } + } &.active-row .slick-cell { background-color: rgb(226, 255, 253); @@ -84,6 +87,26 @@ &.active-row.odd .slick-cell { background-color: $slick-cell-odd-active-background-color; } + + &.slick-rbe-editmode .slick-cell { + background-color: var(--slick-row-based-edit-editmode-bgcolor, $slick-row-based-edit-editmode-bgcolor); + + &:hover { + background-color: var(--slick-row-based-edit-editmode-hover-bgcolor, $slick-row-based-edit-editmode-hover-bgcolor); + + .active { + background-color: var(--slick-row-based-edit-editmode-active-hover-bgcolor, $slick-row-based-edit-editmode-active-hover-bgcolor) !important; + } + } + + .active { + background-color: var(--slick-row-based-edit-editmode-active-bgcolor, $slick-row-based-edit-editmode-active-bgcolor); + + &:hover { + background-color: var(--slick-row-based-edit-editmode-active-hover-bgcolor, $slick-row-based-edit-editmode-active-hover-bgcolor); + } + } + } } .slick-group { diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index d95674f62..b187c447b 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -336,6 +336,41 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () expect(component.isGridInitialized).toBeTruthy(); }); + it('should provide the gridService lazily', () => { + cellDiv = document.createElement('div'); + divContainer.innerHTML = template; + divContainer.appendChild(cellDiv); + + const instance = new SlickVanillaGridBundle( + divContainer, + columnDefinitions, + gridOptions, + dataset, + undefined, + { + backendUtilityService: backendUtilityServiceStub, + collectionService: collectionServiceStub, + eventPubSubService, + extensionService: undefined, + extensionUtility: mockExtensionUtility, + filterService: filterServiceStub, + gridEventService: gridEventServiceStub, + gridService: gridServiceStub, + gridStateService: gridStateServiceStub, + groupingAndColspanService: groupingAndColspanServiceStub, + paginationService: paginationServiceStub, + resizerService: resizerServiceStub, + sharedService, + sortService: sortServiceStub, + treeDataService: treeDataServiceStub, + translaterService: translateService as unknown as TranslaterService, + universalContainerService: container, + } + ); + + expect((instance.extensionService as any).lazyGridService()).toBeDefined(); + }); + it('should load enabled mousewheel scrolling when using a frozen grid', () => { component.gridOptions.enableMouseWheelScrollHandler = undefined; component.gridOptions.frozenRow = 3; 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 a658ccb8a..a1c51e6fd 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -352,6 +352,7 @@ export class SlickVanillaGridBundle { this.sortService, this.treeDataService, this.translaterService, + () => this.gridService ); this.gridStateService = services?.gridStateService ?? new GridStateService(this.extensionService, this.filterService, this._eventPubSubService, this.sharedService, this.sortService, this.treeDataService); diff --git a/test/cypress/e2e/example22.cy.ts b/test/cypress/e2e/example22.cy.ts new file mode 100644 index 000000000..6bca66256 --- /dev/null +++ b/test/cypress/e2e/example22.cy.ts @@ -0,0 +1,153 @@ +describe('Example 22 - Row Based Editing', () => { + const fullTitles = ['Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Actions']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example22`); + cy.get('h3').should('contain', 'Example 22 - Row Based Editing'); + }); + + it('should have exact column titles on grid', () => { + cy.get('.grid1') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should render edit and delete buttons in the actions column', () => { + cy.get('.slick-cell.l6.r6').each(($child) => { + cy.wrap($child).find('.action-btns--edit, .action-btns--delete').should('have.length', 2); + }); + }); + + it('should only allow to toggle a single row into editmode on single mode', () => { + cy.get('.action-btns--edit').first().click(); + cy.get('.action-btns--edit').eq(1).click(); + + cy.get('.slick-row.slick-rbe-editmode').should('have.length', 1); + }); + + it('should allow to toggle a multiple rows into editmode on multiple mode', () => { + cy.reload(); + cy.get('[data-test="single-multi-toggle"]').click(); + cy.get('.action-btns--edit').first().click(); + cy.get('.action-btns--edit').eq(1).click(); + cy.get('.action-btns--edit').eq(2).click(); + + cy.get('.slick-row.slick-rbe-editmode').should('have.length', 3); + }); + + it('should not display editor in rows not being in editmode', () => { + cy.reload(); + cy.get('.slick-cell.l2.r2').first().click(); + + cy.get('input').should('have.length', 0); + + cy.get('.action-btns--edit').first().click(); + + cy.get('.slick-cell.l2.r2').first().click(); + + cy.get('input').should('have.length', 1); + }); + + it('should highlight modified cells and maintain proper index on sorting', () => { + cy.reload(); + + cy.get('.action-btns--edit').first().click(); + + cy.get('.slick-cell.l0.r0').first().click().type('abc{enter}'); + cy.get('.slick-cell').first().should('have.class', 'slick-rbe-unsaved-cell'); + cy.get('[data-id="title"]').click(); + cy.get('.slick-cell').first().should('not.have.class', 'slick-rbe-unsaved-cell'); + cy.get('[data-id="title"]').click(); + cy.get('.slick-cell').first().should('have.class', 'slick-rbe-unsaved-cell'); + }); + + it('should stay in editmode if saving failed', () => { + cy.reload(); + + cy.get('.action-btns--edit').first().click(); + + cy.get('.slick-cell.l1.r1').first().click().type('50{enter}'); + cy.get('.slick-cell.l2.r2').first().click().type('50'); + + cy.get('.action-btns--update').first().click(); + cy.on('window:confirm', () => true); + cy.on('window:alert', (str) => { + expect(str).to.equal('Sorry, 40 is the maximum allowed duration.'); + }); + + cy.get('.slick-row.slick-rbe-editmode').should('have.length', 1); + }); + + it('should save changes on update button click', () => { + cy.reload(); + + cy.get('.action-btns--edit').first().click(); + + cy.get('.slick-cell.l1.r1').first().click().type('30{enter}'); + cy.get('.slick-cell.l2.r2').first().click().type('30'); + + cy.get('.action-btns--update').first().click(); + cy.on('window:alert', (str) => { + expect(str).to.equal('success'); + }); + + cy.get('.slick-cell.l1.r1').first().should('contain', '30'); + cy.get('.slick-cell.l2.r2').first().should('contain', '30'); + }); + + it('should revert changes on cancel click', () => { + cy.get('.action-btns--edit').first().click(); + + cy.get('.slick-cell.l1.r1').first().click().type('50{enter}'); + cy.get('.slick-cell.l2.r2').first().click().type('50{enter}'); + + cy.get('.action-btns--cancel').first().click(); + + cy.get('.slick-cell.l1.r1').first().should('contain', '30'); + cy.get('.slick-cell.l2.r2').first().should('contain', '30'); + }); + + it('should delete a row when clicking it', () => { + cy.get('.action-btns--delete').first().click(); + + cy.on('window:confirm', () => true); + + cy.get('.slick-row').first().find('.slick-cell.l0.r0').should('contain', 'Task 1'); + }); + + it('should support translation keys on buttons', () => { + cy.get('.action-btns--update') + .first() + .invoke('attr', 'title') + .then((title) => { + expect(title).to.equal('Update the current row'); + }); + + cy.get('.action-btns--cancel') + .first() + .invoke('attr', 'title') + .then((title) => { + expect(title).to.equal('Cancel changes of the current row'); + }); + + cy.get('[data-test="toggle-language"]').click(); + cy.get('[data-test="selected-locale"]').should('contain', 'fr.json'); + + // this seems to be a bug in Cypress, it doesn't seem to be able to click on the button + // but at least it triggers a rerender, which makes it refetch the actual button instead of a cached one + cy.get('.action-btns--update').first().click({ force: true }); + + cy.get('.action-btns--update') + .first() + .should(($btn) => { + expect($btn.attr('title')).to.equal('Mettre à jour la ligne actuelle'); + }); + + cy.get('.action-btns--cancel') + .first() + .should(($btn) => { + expect($btn.attr('title')).to.equal('Annuler la ligne actuelle'); + }); + }); +});