diff --git a/docs/grid-functionalities/row-detail.md b/docs/grid-functionalities/row-detail.md new file mode 100644 index 0000000..bb4b046 --- /dev/null +++ b/docs/grid-functionalities/row-detail.md @@ -0,0 +1,432 @@ +#### index +- [Usage](#usage) +- [Changing Addon Options Dynamically](#changing-addon-options-dynamically) +- [Calling Addon Methods Dynamically](#calling-addon-methods-dynamically) +- [Row Detail - Preload Component - Loading Spinner](#row-detail---preload-component-loading-spinner) +- [Row Detail - View Component](#row-detail---view-component) +- [Access Parent Component (grid) from the Child Component (row detail)](#access-parent-component-grid-from-the-child-component-row-detail) +- 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) + +### Demo +[Demo Page](https://ghiscoding.github.io/slickgrid-react/#/slickgrid/Example19) / [Demo ViewModel](https://github.com/ghiscoding/slickgrid-react/blob/master/src/examples/slickgrid/Example19.ts) + +### Description +A Row Detail allows you to open a detail panel which can contain extra and/or more detailed information about a row. For example, we have a user list but we want to display detailed information about this user (his full address, account info, last purchasers, ...) but we don't want to display this in the user grid (for performance and real estate reasons), so a Row Detail is perfect for this. + +##### NOTE +There is currently a known problem with Row Detail when loading the Row Detail Components, it currently shows console warnings (see below), however these are just warnings and they don't show up in Production code. If anyone knows how to fix it please provide a Pull Request as a contribution (please note that the suggestion to use `root.render()` does NOT work as intended hence why we call `createRoot()` every time a row detail is rendered). + +> You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. Instead, call root.render() on the existing root instead if you want to update it. + +## Usage + +##### Component +```tsx +export class GridExample { + reactGrid: SlickgridReactInstance; + + reactGridReady(reactGrid: SlickgridReactInstance) { + this.reactGrid = reactGrid; + } + + componentDidMount() { + this.defineGrid(); + } + + defineGrid() { + const columnDefinitions = this.getColumnsDefinition(); + const gridOptions = this.getGridOptions(); + + this.setState((props: Props, state: any) => { + return { + ...state, + columnDefinitions, + gridOptions + }; + }); + } + + getColumnsDefinition(): Column[] { + return [ /*...*/ ]; + } + + getGridOptions(): GridOption { + return { + enableRowDetailView: true, + rowSelectionOptions: { + selectActiveRow: true + }, + preRegisterExternalExtensions: (pubSubService) => { + // Row Detail View is a special case because of its requirement to create extra column definition dynamically + // so it must be pre-registered before SlickGrid is instantiated, we can do so via this option + const rowDetail = new SlickRowDetailView(pubSubService as EventPubSubService); + return [{ name: ExtensionName.rowDetailView, instance: rowDetail }]; + }, + rowDetailView: { + // We can load the "process" asynchronously via Fetch, Promise, ... + process: (item) => this.http.get(`api/item/${item.id}`), + + // load only once and reuse the same item detail without calling process method + loadOnce: true, + + // limit expanded row to only 1 at a time + singleRowExpand: false, + + // false by default, clicking anywhere on the row will open the detail view + // when set to false, only the "+" icon would open the row detail + // if you use editor or cell navigation you would want this flag set to false (default) + useRowClick: true, + + // how many grid rows do we want to use for the row detail panel (this is only set once and will be used for all row detail) + // also note that the detail view adds an extra 1 row for padding purposes + // so if you choose 4 panelRows, the display will in fact use 5 rows + panelRows: this.detailViewRowCount, + + // you can override the logic for showing (or not) the expand icon + // for example, display the expand icon only on every 2nd row + // expandableOverride: (row: number, dataContext: any, grid: any) => (dataContext.id % 2 === 1), + + // Preload View Template + preloadComponent: Example19Preload, + + // ViewModel Template to load when row detail data is ready + viewComponent: Example19DetailView, + + // Optionally pass your Parent Component reference to your Child Component (row detail component) + parent: this + } + }; + } + + render() { + return this.reactGridReady($event.detail)} /> + } +} +``` + +### Changing Addon Options Dynamically +Row Detail is an addon (commonly known as a plugin and are opt-in addon), because this is not built-in SlickGrid and instead are opt-in, we need to get the instance of that addon object. Once we have the instance, we can use `getOptions` and `setOptions` to get/set any of the addon options, adding `rowDetail` with intellisense should give you this info. + +#### Examples +- Dynamically change the Detail View Row Count (how many grid rows do we want to use for the row detail panel) +```ts +changeDetailViewRowCount() { + if (this.reactGrid?.extensionService) { + const rowDetailInstance = this.reactGrid.extensionService.getExtensionInstanceByName(ExtensionName.rowDetailView); + const options = rowDetailInstance.getOptions(); + options.panelRows = this.detailViewRowCount; // change number of rows dynamically + rowDetailInstance.setOptions(options); + } +} +``` + +### Calling Addon Methods Dynamically +Same as previous paragraph, after we get the SlickGrid addon instance, we can call any of the addon methods, adding `rowDetail` with intellisense should give you this info. + +#### Examples +- Dynamically close all Row Detail Panels +```ts +closeAllRowDetail() { + if (this.reactGrid && this.reactGrid.extensionService) { + const rowDetailInstance = this.reactGrid.extensionService.getExtensionInstanceByName(ExtensionName.rowDetailView); + rowDetailInstance.collapseAll(); + } +} +``` +- Dynamically close a single Row Detail by it's grid index +This requires a bit more work, you can call the method `collapseDetailView(item)` but it requires to pass the row item object (data context) and it feasible but it's just more work as can be seen below. +```ts +closeRowDetail(gridRowIndex: number) { + if (this.reactGrid && this.reactGrid.extensionService) { + const rowDetailInstance = this.reactGrid.extensionService.getExtensionInstanceByName(ExtensionName.rowDetailView); + const item = this.reactGrid.gridService.getDataItemByRowIndex(gridRowIndex); + rowDetailInstance.collapseDetailView(item); + } +} +``` + +### Row Detail - Preload Component (loading spinner) +Most of the time we would get data asynchronously, during that time we can show a loading spinner to the user via the `preloadComponent` grid option. We could use this simple Preload Component example as shown below + +###### Preload Component +```tsx +import { forwardRef } from 'react'; + +export const Example19Preload = forwardRef((props: any, ref: any) => { + return ( +
+

+ + Loading... +

+
+ ); +}); +``` + +### Row Detail - ViewModel +Same concept as the preload, we pass a React Component to the `viewComponent` that will be used to render our Row Detail. + +###### Row Detail Component +```tsx +import React from 'react'; +import type { SlickDataView, SlickGrid, SlickRowDetailView } from 'slickgrid-react'; + +import './example19-detail-view.scss'; + +interface Props { + model: { + duration: Date; + percentComplete: number; + // ... + }; + addon: SlickRowDetailView; + grid: SlickGrid; + dataView: SlickDataView; + parent: any; +} +interface State { assignee: string; } + +export class Example19DetailView extends React.Component { + constructor(public readonly props: Props) { + super(props); + this.state = { + assignee: props.model?.assignee || '' + } + } + + // ... + // + + render() { + return ( +
+

{this.props.model.title}

+
+
this.assigneeChanged(($event.target as HTMLInputElement).value)} />
+
{this.props.model.reporter}
+
{this.props.model.duration?.toISOString?.()}
+
{this.props.model.percentComplete}
+
+ +
+
{this.props.model.start?.toISOString()}
+
{this.props.model.finish?.toISOString()}
+
+
+
+ +
+ +
+

+ Find out who is the Assignee + + + +

+
+ +
+ + +
+
+ ); + } +} +``` + +###### Grid Definition +```tsx +export class GridExample { + // ... + + getGridOptions(): GridOption { + return { + enableRowDetailView: true, + preRegisterExternalExtensions: (pubSubService) => { + // Row Detail View is a special case because of its requirement to create extra column definition dynamically + // so it must be pre-registered before SlickGrid is instantiated, we can do so via this option + const rowDetail = new SlickRowDetailView(pubSubService as EventPubSubService); + return [{ name: ExtensionName.rowDetailView, instance: rowDetail }]; + }, + rowDetailView: { + // We can load the "process" asynchronously via Fetch, Promise, ... + process: (item) => this.http.get(`api/item/${item.id}`), + + // ... + + // Preload Component + preloadComponent: Example19Preload, + + // Row Detail Component to load when row detail data is ready + viewComponent: Example19DetailView, + + // Optionally pass your Parent Component reference to your Child Component (row detail component) + parent: this + } + }; + } + + render() { + return this.reactGridReady($event.detail)} /> + } +} +``` + +### Access Parent Component (grid) from the Child Component (row detail) +The Row Detail provides you access to the following references (SlickGrid, DataView, Parent Component and the Addon (3rd party plugin)), however please note that all of these references are available from the start **except** the Parent Component instance, for that one you need to reference it inside your Row Detail Grid Options like so: + +```ts +export class GridExample { + // Parent Component (grid) + getGridOptions(): GridOption { + return { + enableRowDetailView: true, + preRegisterExternalExtensions: (pubSubService) => { + // Row Detail View is a special case because of its requirement to create extra column definition dynamically + // so it must be pre-registered before SlickGrid is instantiated, we can do so via this option + const rowDetail = new SlickRowDetailView(pubSubService as EventPubSubService); + return [{ name: ExtensionName.rowDetailView, instance: rowDetail }]; + }, + rowDetailView: { + // ... + // ViewComponent Template to load when row detail data is ready + viewComponent: CustomDetailView, + + // Optionally pass your Parent Component reference to your Child Component (row detail component) + parent: this // <-- THIS REFERENCE + } + } + } + + // a Parent Method that we want to access + showFlashMessage(message: string, alertType = 'info') { + this.setState((props, state) => { + return { ...state, message, flashAlertType: alertType } + }); + } +} +``` + +Then in our Child Component, we can do some action on the Grid, the DataView or even call a method form the Parent Component (the `showFlashMessage` in our demo), with that in mind, here is the code of the Child Component + +##### View +```tsx +
+

{this.props.model.title}

+ + <-- delete a row using the DataView & SlickGrid objects --> + + + + +
+``` + +##### Component +```tsx +import React from 'react'; +import type { SlickDataView, SlickGrid, SlickRowDetailView } from 'slickgrid-react'; + +import './example19-detail-view.scss'; + +interface Props { + model: { + duration: Date; + percentComplete: number; + // ... + }; + addon: SlickRowDetailView; + grid: SlickGrid; + dataView: SlickDataView; + parent: any; +} +interface State { assignee: string; } + +export class Example19DetailView extends React.Component { + constructor(public readonly props: Props) { + super(props); + this.state = { + assignee: props.model?.assignee || '' + } + } + + assigneeChanged(newAssignee: string) { + this.setState((props: Props, state: State) => { + return { ...state, assignee: newAssignee } + }); + } + + alertAssignee(name: string) { + if (typeof name === 'string') { + alert(`Assignee on this task is: ${name.toUpperCase()}`); + } else { + alert('No one is assigned to this task.'); + } + } + + deleteRow(model: any) { + if (confirm(`Are you sure that you want to delete ${model.title}?`)) { + // you first need to collapse all rows (via the 3rd party addon instance) + this.props.addon.collapseAll(); + + // then you can delete the item from the dataView + this.props.dataView.deleteItem(model.rowId); + + this.props.parent!.showFlashMessage(`Deleted row with ${model.title}`, 'danger'); + } + } + + callParentMethod(model: any) { + this.props.parent!.showFlashMessage(`We just called Parent Method from the Row Detail Child Component on ${model.title}`); + } + + render() { + // ... + } +} +``` + +## Troubleshooting +### Adding a Column dynamically is removing the Row Selection, why is that? +The reason is because the Row Selection (checkbox) plugin is a special column and Slickgrid-React is adding an extra column dynamically for the Row Selection checkbox and that is **not** reflected in your local copy of `columnDefinitions`. To address this issue, you need to get the Slickgrid-React internal copy of all columns (including the extra columns), you can get it via `getAllColumnDefinitions()` from the Grid Service and then you can use to that array and that will work. + +```ts +reactGridReady(reactGrid: SlickgridReactInstance) { + this.reactGrid = reactGrid; +} + +addNewColumn() { + const newColumn = { /*...*/ }; + + const allColumns = this.reactGrid.gridService.getAllColumnDefinitions(); + allColumns.push(newColumn); + this.setState((props, state) => { + return { + ...state, + columnDefinitions: allColumns.slice(); // or use spread operator [...cols] + }; + } +} +``` diff --git a/docs/grid-functionalities/tree-data-grid.md b/docs/grid-functionalities/tree-data-grid.md index 69d9dc3..e568e9c 100644 --- a/docs/grid-functionalities/tree-data-grid.md +++ b/docs/grid-functionalities/tree-data-grid.md @@ -305,17 +305,17 @@ There are a few methods available from the `TreeDataService` (only listing the i - `applyToggledItemStateChanges(x)`: apply different tree toggle state changes (to ALL rows, the entire dataset) by providing an array of parentIds For example -```ts +```tsx export default class Example extends React.Component { - aureliaGrid?: AureliaGridInstance; + reactGrid?: SlickgridReactInstance; - reactGridReady(aureliaGrid: AureliaGridInstance) { - this.reactGrid = aureliaGrid; + reactGridReady(reactGrid: SlickgridReactInstance) { + this.reactGrid = reactGrid; } getTreeDataState() { // for example get current Tree Data toggled state - console.log(this.aureliaGrid.getCurrentToggleState()); + console.log(this.reactGrid.getCurrentToggleState()); } } ``` diff --git a/package.json b/package.json index 281d94a..79a95e2 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@slickgrid-universal/empty-warning-component": "~5.9.0", "@slickgrid-universal/event-pub-sub": "~5.9.0", "@slickgrid-universal/pagination-component": "~5.9.0", + "@slickgrid-universal/row-detail-view-plugin": "~5.9.0", "dequal": "^2.0.3", "i18next": "^23.16.1", "sortablejs": "^1.15.3" diff --git a/src/examples/slickgrid/App.tsx b/src/examples/slickgrid/App.tsx index ff79bf3..c25abec 100644 --- a/src/examples/slickgrid/App.tsx +++ b/src/examples/slickgrid/App.tsx @@ -20,6 +20,7 @@ import Example15 from './Example15'; import Example16 from './Example16'; import Example17 from './Example17'; import Example18 from './Example18'; +import Example19 from './Example19'; import Example20 from './Example20'; import Example21 from './Example21'; import Example22 from './Example22'; @@ -62,6 +63,7 @@ const routes: Array<{ path: string; route: string; component: any; title: string { path: 'example16', route: '/example16', component: , title: '16- Row Move Plugin' }, { path: 'example17', route: '/example17', component: , title: '17- Remote Model' }, { path: 'example18', route: '/example18', component: , title: '18- Draggable Grouping' }, + { path: 'example19', route: '/example19', component: , title: '19- Row Detail View' }, { path: 'example20', route: '/example20', component: , title: '20- Pinned Columns/Rows' }, { path: 'example21', route: '/example21', component: , title: '21- Grid AutoHeight (full height)' }, { path: 'example22', route: '/example22', component: , title: '22- with Bootstrap Tabs' }, diff --git a/src/examples/slickgrid/Example19-detail-view.tsx b/src/examples/slickgrid/Example19-detail-view.tsx new file mode 100644 index 0000000..ae97a5b --- /dev/null +++ b/src/examples/slickgrid/Example19-detail-view.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type { SlickDataView, SlickGrid, SlickRowDetailView } from 'slickgrid-react'; + +import './example19-detail-view.scss'; + +interface Props { + model: { + duration: Date; + percentComplete: number; + reporter: string; + start: Date; + finish: Date; + effortDriven: boolean; + assignee: string; + title: string; + }; + addon: SlickRowDetailView; + grid: SlickGrid; + dataView: SlickDataView; + parent: any; +} +interface State { + assignee: string; +} + +export class Example19DetailView extends React.Component { + constructor(public readonly props: Props) { + super(props); + this.state = { + assignee: props.model?.assignee || '' + } + } + + assigneeChanged(newAssignee: string) { + this.setState((props: Props, state: State) => { + return { ...state, assignee: newAssignee } + }); + } + + alertAssignee(name: string) { + if (typeof name === 'string') { + alert(`Assignee on this task is: ${name.toUpperCase()}`); + } else { + alert('No one is assigned to this task.'); + } + } + + deleteRow(model: any) { + if (confirm(`Are you sure that you want to delete ${model.title}?`)) { + // you first need to collapse all rows (via the 3rd party addon instance) + this.props.addon.collapseAll(); + + // then you can delete the item from the dataView + this.props.dataView.deleteItem(model.rowId); + + this.props.parent!.showFlashMessage(`Deleted row with ${model.title}`, 'danger'); + } + } + + callParentMethod(model: any) { + this.props.parent!.showFlashMessage(`We just called Parent Method from the Row Detail Child Component on ${model.title}`); + } + + render() { + return ( +
+

{this.props.model.title}

+
+
this.assigneeChanged(($event.target as HTMLInputElement).value)} />
+
{this.props.model.reporter}
+
{this.props.model.duration?.toISOString?.()}
+
{this.props.model.percentComplete}
+
+ +
+
{this.props.model.start?.toISOString()}
+
{this.props.model.finish?.toISOString()}
+
+
+
+ +
+ +
+

+ Find out who is the Assignee + + + +

+
+ +
+ + +
+
+ ); + } +} diff --git a/src/examples/slickgrid/Example19-preload.tsx b/src/examples/slickgrid/Example19-preload.tsx new file mode 100644 index 0000000..00c011b --- /dev/null +++ b/src/examples/slickgrid/Example19-preload.tsx @@ -0,0 +1,12 @@ +import { forwardRef } from 'react'; + +export const Example19Preload = forwardRef((props: any, ref: any) => { + return ( +
+

+ + Loading... +

+
+ ); +}); diff --git a/src/examples/slickgrid/Example19.tsx b/src/examples/slickgrid/Example19.tsx new file mode 100644 index 0000000..febad41 --- /dev/null +++ b/src/examples/slickgrid/Example19.tsx @@ -0,0 +1,344 @@ +import { type EventPubSubService } from '@slickgrid-universal/event-pub-sub'; +import React from 'react'; +import { + type Column, + Editors, + ExtensionName, + FieldType, + Filters, + Formatters, + type GridOption, + SlickgridReact, + type SlickgridReactInstance, + SlickRowDetailView, +} from '../../slickgrid-react'; + +import type BaseSlickGridState from './state-slick-grid-base'; +import { Example19Preload } from './Example19-preload'; +import { Example19DetailView } from './Example19-detail-view'; + +const NB_ITEMS = 1000; + +interface Props { } + +interface State extends BaseSlickGridState { + detailViewRowCount: number, + flashAlertType: string, + message: string, +} + +function randomNumber(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export default class Example19 extends React.Component { + private _darkMode = false; + reactGrid!: SlickgridReactInstance; + shouldResetOnSort = false; + + constructor(public readonly props: Props) { + super(props); + + this.state = { + gridOptions: undefined, + columnDefinitions: [], + dataset: this.loadData(), + detailViewRowCount: 9, + message: '', + flashAlertType: 'info', + }; + } + + get rowDetailInstance() { + // you can get the SlickGrid RowDetail plugin (addon) instance via 2 ways + + // option 1 + // return this.extensions.rowDetailView.instance || {}; + + // OR option 2 + return this.reactGrid?.extensionService.getExtensionInstanceByName(ExtensionName.rowDetailView); + } + + componentDidMount() { + this.defineGrid(); + } + + reactGridReady(reactGrid: SlickgridReactInstance) { + this.reactGrid = reactGrid; + } + + getColumnsDefinition(): Column[] { + return [ + { id: 'title', name: 'Title', field: 'title', sortable: true, type: FieldType.string, width: 70, filterable: true, editor: { model: Editors.text } }, + { id: 'duration', name: 'Duration (days)', field: 'duration', formatter: Formatters.decimal, params: { minDecimal: 1, maxDecimal: 2 }, sortable: true, type: FieldType.number, minWidth: 90, filterable: true }, + { + id: 'percent2', name: '% Complete', field: 'percentComplete2', editor: { model: Editors.slider }, + formatter: Formatters.progressBar, type: FieldType.number, sortable: true, minWidth: 100, filterable: true, filter: { model: Filters.slider, operator: '>' } + }, + { id: 'start', name: 'Start', field: 'start', formatter: Formatters.dateIso, sortable: true, type: FieldType.date, minWidth: 90, exportWithFormatter: true, filterable: true, filter: { model: Filters.compoundDate } }, + { id: 'finish', name: 'Finish', field: 'finish', formatter: Formatters.dateIso, sortable: true, type: FieldType.date, minWidth: 90, exportWithFormatter: true, filterable: true, filter: { model: Filters.compoundDate } }, + { + id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', + minWidth: 100, + formatter: Formatters.checkmarkMaterial, type: FieldType.boolean, + filterable: true, sortable: true, + filter: { + collection: [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }], + model: Filters.singleSelect + } + } + ]; + } + + defineGrid() { + const columnDefinitions = this.getColumnsDefinition(); + const gridOptions = this.getGridOptions(); + + this.setState((props: Props, state: any) => { + return { + ...state, + columnDefinitions, + gridOptions + }; + }); + } + + showFlashMessage(message: string, alertType = 'info') { + this.setState((props, state) => { + return { ...state, message, flashAlertType: alertType } + }); + } + + /** Just for demo purposes, we will simulate an async server call and return more details on the selected row item */ + simulateServerAsyncCall(item: any) { + // random set of names to use for more item detail + const randomNames = ['John Doe', 'Jane Doe', 'Chuck Norris', 'Bumblebee', 'Jackie Chan', 'Elvis Presley', 'Bob Marley', 'Mohammed Ali', 'Bruce Lee', 'Rocky Balboa']; + + // fill the template on async delay + return new Promise((resolve) => { + window.setTimeout(() => { + const itemDetail = item; + + // let's add some extra properties to our item for a better async simulation + itemDetail.assignee = randomNames[randomNumber(0, 10)]; + itemDetail.reporter = randomNames[randomNumber(0, 10)]; + + // resolve the data after delay specified + resolve(itemDetail); + }, 1000); + }); + } + + getGridOptions(): GridOption { + return { + autoResize: { + container: '#demo-container', + rightPadding: 10 + }, + enableFiltering: true, + enableRowDetailView: true, + darkMode: this._darkMode, + datasetIdPropertyName: 'rowId', // optionally use a different "id" + preRegisterExternalExtensions: (pubSubService) => { + // Row Detail View is a special case because of its requirement to create extra column definition dynamically + // so it must be pre-registered before SlickGrid is instantiated, we can do so via this option + const rowDetail = new SlickRowDetailView(pubSubService as EventPubSubService); + return [{ name: ExtensionName.rowDetailView, instance: rowDetail }]; + }, + rowDetailView: { + // optionally change the column index position of the icon (defaults to 0) + // columnIndexPosition: 1, + + // We can load the "process" asynchronously via Fetch, Promise, ... + process: (item) => this.simulateServerAsyncCall(item), + // process: (item) => this.http.get(`api/item/${item.id}`), + + // load only once and reuse the same item detail without calling process method + loadOnce: true, + + // limit expanded row to only 1 at a time + singleRowExpand: false, + + // false by default, clicking anywhere on the row will open the detail view + // when set to false, only the "+" icon would open the row detail + // if you use editor or cell navigation you would want this flag set to false (default) + useRowClick: true, + + // how many grid rows do we want to use for the row detail panel (this is only set once and will be used for all row detail) + // also note that the detail view adds an extra 1 row for padding purposes + // so if you choose 4 panelRows, the display will in fact use 5 rows + panelRows: this.state.detailViewRowCount, + + // you can override the logic for showing (or not) the expand icon + // for example, display the expand icon only on every 2nd row + // expandableOverride: (row: number, dataContext: any) => (dataContext.rowId % 2 === 1), + + // Preload View Template + preloadComponent: Example19Preload, + + // ViewModel Template to load when row detail data is ready + viewComponent: Example19DetailView, + + // Optionally pass your Parent Component reference to your Child Component (row detail component) + parent: this, + + onBeforeRowDetailToggle: (e, args) => { + // you coud cancel opening certain rows + // if (args.item.rowId === 1) { + // e.preventDefault(); + // return false; + // } + console.log('before toggling row detail', args.item); + return true; + }, + }, + rowSelectionOptions: { + // True (Single Selection), False (Multiple Selections) + selectActiveRow: true + }, + + // You could also enable Row Selection as well, but just make sure to disable `useRowClick: false` + // enableCheckboxSelector: true, + // enableRowSelection: true, + // checkboxSelector: { + // hideInFilterHeaderRow: false, + // hideSelectAllCheckbox: true, + // }, + }; + } + + loadData() { + const tmpData: any[] = []; + // mock a dataset + for (let i = 0; i < NB_ITEMS; 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); + + tmpData[i] = { + rowId: i, + title: 'Task ' + i, + duration: (i % 33 === 0) ? null : Math.random() * 100 + '', + percentComplete: randomPercent, + percentComplete2: randomPercent, + percentCompleteNumber: randomPercent, + start: new Date(randomYear, randomMonth, randomDay), + finish: new Date(randomYear, (randomMonth + 1), randomDay), + effortDriven: (i % 5 === 0) + }; + } + + return tmpData; + } + + changeDetailViewRowCount() { + const options = this.rowDetailInstance.getOptions(); + if (options && options.panelRows) { + options.panelRows = this.state.detailViewRowCount; // change number of rows dynamically + this.rowDetailInstance.setOptions(options); + } + } + + changeEditableGrid() { + // this.rowDetailInstance.setOptions({ useRowClick: false }); + this.rowDetailInstance.collapseAll(); + (this.rowDetailInstance as any).addonOptions.useRowClick = false; + this.state.gridOptions!.autoCommitEdit = !this.state.gridOptions!.autoCommitEdit; + this.reactGrid?.slickGrid.setOptions({ + editable: true, + autoEdit: true, + enableCellNavigation: true, + }); + return true; + } + + closeAllRowDetail() { + this.rowDetailInstance.collapseAll(); + } + + detailViewRowCountChanged(val: number | string) { + this.setState((state: State) => ({ ...state, detailViewRowCount: +val })); + } + + toggleDarkMode() { + this._darkMode = !this._darkMode; + this.toggleBodyBackground(); + this.reactGrid.slickGrid?.setOptions({ darkMode: this._darkMode }); + } + + toggleBodyBackground() { + if (this._darkMode) { + document.querySelector('.panel-wm-content')!.classList.add('dark-mode'); + document.querySelector('#demo-container')!.dataset.bsTheme = 'dark'; + } else { + document.querySelector('.panel-wm-content')!.classList.remove('dark-mode'); + document.querySelector('#demo-container')!.dataset.bsTheme = 'light'; + } + } + + render() { + return !this.state.gridOptions ? '' : ( +
+
+

+ Example 19: Row Detail View + + + see  + + code + + +

+ +
+
+ Add functionality to show extra information with a Row Detail View, (Wiki docs) +
    +
  • Click on the row "+" icon or anywhere on the row to open it (the latter can be changed via property "useRowClick: false")
  • +
  • Pass a View/Model as a Template to the Row Detail
  • +
  • You can use "expandableOverride()" callback to override logic to display expand icon on every row (for example only show it every 2nd row)
  • +
+
+
+ +
+
+ + +    + + + + this.detailViewRowCountChanged(($event.target as HTMLInputElement).value)} /> + + +
+ {this.state.message ?
{this.state.message}
: ''} +
+ +
+ + this.reactGridReady($event.detail)} + /> +
+
+ ); + } +} diff --git a/src/examples/slickgrid/example19-detail-view.scss b/src/examples/slickgrid/example19-detail-view.scss new file mode 100644 index 0000000..0a9ee5b --- /dev/null +++ b/src/examples/slickgrid/example19-detail-view.scss @@ -0,0 +1,10 @@ +.detail-label { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; +} + +label { + font-weight: 600; +} diff --git a/src/slickgrid-react/components/slickgrid-react.tsx b/src/slickgrid-react/components/slickgrid-react.tsx index 5b59d6c..a48e710 100644 --- a/src/slickgrid-react/components/slickgrid-react.tsx +++ b/src/slickgrid-react/components/slickgrid-react.tsx @@ -1573,7 +1573,7 @@ export class SlickgridReact extends React.Component WARN_NO_PREPARSE_DATE_SIZE && !this.gridOptions.preParseDateColumns && this.grid.getColumns().some(c => isColumnDateType(c.type))) { + if (this.dataView?.getItemCount() > WARN_NO_PREPARSE_DATE_SIZE && !this.gridOptions.silenceWarnings && !this.gridOptions.preParseDateColumns && this.grid.getColumns().some(c => isColumnDateType(c.type))) { console.warn( '[Slickgrid-Universal] For getting better perf, we suggest you enable the `preParseDateColumns` grid option, ' + 'for more info visit => https://ghiscoding.gitbook.io/slickgrid-react/column-functionalities/sorting#pre-parse-date-columns-for-better-perf' diff --git a/src/slickgrid-react/extensions/slickRowDetailView.ts b/src/slickgrid-react/extensions/slickRowDetailView.ts new file mode 100644 index 0000000..a8b782f --- /dev/null +++ b/src/slickgrid-react/extensions/slickRowDetailView.ts @@ -0,0 +1,383 @@ +import { + addToArrayWhenNotExists, + type EventSubscription, + type OnBeforeRowDetailToggleArgs, + type OnRowBackToViewportRangeArgs, + SlickEventData, + type SlickEventHandler, + type SlickGrid, + SlickRowSelectionModel, + unsubscribeAll, +} from '@slickgrid-universal/common'; +import { type EventPubSubService } from '@slickgrid-universal/event-pub-sub'; +import { SlickRowDetailView as UniversalSlickRowDetailView } from '@slickgrid-universal/row-detail-view-plugin'; +import type { Root } from 'react-dom/client'; + +import type { GridOption, RowDetailView, ViewModelBindableInputData } from '../models/index'; +import { loadReactComponentDynamically } from '../services/reactUtils'; + +const ROW_DETAIL_CONTAINER_PREFIX = 'container_'; +const PRELOAD_CONTAINER_PREFIX = 'container_loading'; + +export interface CreatedView { + id: string | number; + dataContext: any; + root: Root | null; +} +// interface SRDV extends React.Component, UniversalSlickRowDetailView {}s + +export class SlickRowDetailView extends UniversalSlickRowDetailView { + protected _component?: any; + protected _preloadComponent?: any; + protected _views: CreatedView[] = []; + protected _subscriptions: EventSubscription[] = []; + protected _userProcessFn?: (item: any) => Promise; + protected gridContainerElement!: HTMLElement; + _root?: Root; + + constructor( + private readonly eventPubSubService: EventPubSubService, + ) { + super(eventPubSubService); + } + + get addonOptions() { + return this.getOptions(); + } + + protected get datasetIdPropName(): string { + return this.gridOptions.datasetIdPropertyName || 'id'; + } + + get eventHandler(): SlickEventHandler { + return this._eventHandler; + } + set eventHandler(eventHandler: SlickEventHandler) { + this._eventHandler = eventHandler; + } + + get gridOptions(): GridOption { + return (this._grid?.getOptions() || {}) as GridOption; + } + + get rowDetailViewOptions(): RowDetailView | undefined { + return this.gridOptions.rowDetailView; + } + + /** Dispose of the RowDetailView Extension */ + dispose() { + this.disposeAllViewComponents(); + unsubscribeAll(this._subscriptions); + super.dispose(); + } + + /** Dispose of all the opened Row Detail Panels Components */ + disposeAllViewComponents() { + if (Array.isArray(this._views)) { + this._views.forEach((view) => this.disposeViewComponent(view)); + } + this._views = []; + } + + /** Get the instance of the SlickGrid addon (control or plugin). */ + getAddonInstance(): SlickRowDetailView | null { + return this; + } + + init(grid: SlickGrid) { + this._grid = grid; + super.init(this._grid); + this.gridContainerElement = grid.getContainerNode(); + this.register(grid?.getSelectionModel() as SlickRowSelectionModel); + } + + /** + * Create the plugin before the Grid creation, else it will behave oddly. + * Mostly because the column definitions might change after the grid creation + */ + register(rowSelectionPlugin?: SlickRowSelectionModel) { + if (typeof this.gridOptions.rowDetailView?.process === 'function') { + // we need to keep the user "process" method and replace it with our own execution method + // we do this because when we get the item detail, we need to call "onAsyncResponse.notify" for the plugin to work + this._userProcessFn = this.gridOptions.rowDetailView.process as (item: any) => Promise; // keep user's process method + this.addonOptions.process = (item) => this.onProcessing(item); // replace process method & run our internal one + } else { + throw new Error('[Slickgrid-React] You need to provide a "process" function for the Row Detail Extension to work properly'); + } + + if (this._grid && this.gridOptions?.rowDetailView) { + // load the Preload & RowDetail Templates (could be straight HTML or React Components) + // when those are React Components, we need to create View Component & provide the html containers to the Plugin (preTemplate/postTemplate methods) + if (!this.gridOptions.rowDetailView.preTemplate) { + this._preloadComponent = this.gridOptions?.rowDetailView?.preloadComponent; + this.addonOptions.preTemplate = () => this._grid.sanitizeHtmlString(`
`) as string; + } + if (!this.gridOptions.rowDetailView.postTemplate) { + this._component = this.gridOptions?.rowDetailView?.viewComponent; + this.addonOptions.postTemplate = (itemDetail: any) => { + return this._grid.sanitizeHtmlString(`
`) as string + }; + } + + if (this._grid && this.gridOptions) { + // this also requires the Row Selection Model to be registered as well + if (!rowSelectionPlugin || !this._grid.getSelectionModel()) { + rowSelectionPlugin = new SlickRowSelectionModel(this.gridOptions.rowSelectionOptions || { selectActiveRow: true }); + this._grid.setSelectionModel(rowSelectionPlugin); + } + + // hook all events + if (this._grid && this.rowDetailViewOptions) { + if (this.rowDetailViewOptions.onExtensionRegistered) { + this.rowDetailViewOptions.onExtensionRegistered(this); + } + + if (this.onAsyncResponse) { + this._eventHandler.subscribe(this.onAsyncResponse, (event, args) => { + if (typeof this.rowDetailViewOptions?.onAsyncResponse === 'function') { + this.rowDetailViewOptions.onAsyncResponse(event, args); + } + }); + } + + if (this.onAsyncEndUpdate) { + this._eventHandler.subscribe(this.onAsyncEndUpdate, async (event, args) => { + // triggers after backend called "onAsyncResponse.notify()" + await this.renderViewModel(args?.item); + + if (typeof this.rowDetailViewOptions?.onAsyncEndUpdate === 'function') { + this.rowDetailViewOptions.onAsyncEndUpdate(event, args); + } + }); + } + + if (this.onAfterRowDetailToggle) { + this._eventHandler.subscribe(this.onAfterRowDetailToggle, async (event, args) => { + // display preload template & re-render all the other Detail Views after toggling + // the preload View will eventually go away once the data gets loaded after the "onAsyncEndUpdate" event + await this.renderPreloadView(args.item); + this.renderAllViewModels(); + + if (typeof this.rowDetailViewOptions?.onAfterRowDetailToggle === 'function') { + this.rowDetailViewOptions.onAfterRowDetailToggle(event, args); + } + }); + } + + if (this.onBeforeRowDetailToggle) { + this._eventHandler.subscribe(this.onBeforeRowDetailToggle, (event, args) => { + // before toggling row detail, we need to create View Component if it doesn't exist + this.handleOnBeforeRowDetailToggle(event, args); + + if (typeof this.rowDetailViewOptions?.onBeforeRowDetailToggle === 'function') { + return this.rowDetailViewOptions.onBeforeRowDetailToggle(event, args); + } + return true; + }); + } + + if (this.onRowBackToViewportRange) { + this._eventHandler.subscribe(this.onRowBackToViewportRange, async (event, args) => { + // when row is back to viewport range, we will re-render the View Component(s) + await this.handleOnRowBackToViewportRange(event, args); + + if (typeof this.rowDetailViewOptions?.onRowBackToViewportRange === 'function') { + this.rowDetailViewOptions.onRowBackToViewportRange(event, args); + } + }); + } + + if (this.onRowOutOfViewportRange) { + this._eventHandler.subscribe(this.onRowOutOfViewportRange, (event, args) => { + if (typeof this.rowDetailViewOptions?.onRowOutOfViewportRange === 'function') { + this.rowDetailViewOptions.onRowOutOfViewportRange(event, args); + } + }); + } + + // -- + // hook some events needed by the Plugin itself + + // we need to redraw the open detail views if we change column position (column reorder) + this.eventHandler.subscribe(this._grid.onColumnsReordered, this.redrawAllViewComponents.bind(this)); + + // on row selection changed, we also need to redraw + if (this.gridOptions.enableRowSelection || this.gridOptions.enableCheckboxSelector) { + this._eventHandler.subscribe(this._grid.onSelectedRowsChanged, this.redrawAllViewComponents.bind(this)); + } + + // on column sort/reorder, all row detail are collapsed so we can dispose of all the Views as well + this._eventHandler.subscribe(this._grid.onSort, this.disposeAllViewComponents.bind(this)); + + // on filter changed, we need to re-render all Views + this._subscriptions.push( + this.eventPubSubService?.subscribe(['onFilterChanged', 'onGridMenuColumnsChanged', 'onColumnPickerColumnsChanged'], this.redrawAllViewComponents.bind(this)), + this.eventPubSubService?.subscribe(['onGridMenuClearAllFilters', 'onGridMenuClearAllSorting'], () => window.setTimeout(() => this.redrawAllViewComponents())), + ); + } + } + } + + return this; + } + + /** Redraw (re-render) all the expanded row detail View Components */ + async redrawAllViewComponents() { + await Promise.all(this._views.map(async x => this.redrawViewComponent(x))); + } + + /** Render all the expanded row detail View Components */ + async renderAllViewModels() { + await Promise.all(this._views.filter(x => x?.dataContext).map(async x => this.renderViewModel(x.dataContext))); + } + + /** Redraw the necessary View Component */ + async redrawViewComponent(view: CreatedView) { + const containerElement = this.gridContainerElement.getElementsByClassName(`${ROW_DETAIL_CONTAINER_PREFIX}${view.id}`); + if (containerElement?.length >= 0) { + await this.renderViewModel(view.dataContext); + } + } + + /** Render (or re-render) the View Component (Row Detail) */ + async renderPreloadView(item: any) { + const containerElements = this.gridContainerElement.getElementsByClassName(`${PRELOAD_CONTAINER_PREFIX}`); + if (this._preloadComponent && containerElements?.length) { + const detailContainer = document.createElement('section'); + containerElements[containerElements.length - 1]!.appendChild(detailContainer); + + console.log('elm', containerElements[containerElements.length - 1]) + const { root } = await loadReactComponentDynamically(this._preloadComponent, detailContainer as HTMLElement); + const viewObj = this._views.find(obj => obj.id === item[this.datasetIdPropName]); + this._root = root; + if (viewObj) { + viewObj.root = root; + } + } + } + + /** Render (or re-render) the View Component (Row Detail) */ + async renderViewModel(item: any) { + const containerElements = this.gridContainerElement.getElementsByClassName(`${ROW_DETAIL_CONTAINER_PREFIX}${item[this.datasetIdPropName]}`); + if (this._component && containerElements?.length) { + const bindableData = { + model: item, + addon: this, + grid: this._grid, + dataView: this.dataView, + parent: this.rowDetailViewOptions?.parent, + } as ViewModelBindableInputData; + const viewObj = this._views.find(obj => obj.id === item[this.datasetIdPropName]); + + // load our Row Detail React Component dynamically, typically we would want to use `root.render()` after the preload component (last argument below) + // BUT the root render doesn't seem to work and shows a blank element, so we'll use `createRoot()` every time even though it shows a console log in Dev + // that is the only way I got it working so let's use it anyway and console warnings are removed in production anyway + const { root } = await loadReactComponentDynamically(this._component, containerElements[containerElements.length - 1] as HTMLElement, bindableData /*, viewObj?.root */); + if (!viewObj) { + this.addViewInfoToViewsRef(item, root); + } + } + } + + // -- + // protected functions + // ------------------ + + protected addViewInfoToViewsRef(item: any, root: Root | null) { + const viewInfo: CreatedView = { + id: item[this.datasetIdPropName], + dataContext: item, + root + }; + const idPropName = this.gridOptions.datasetIdPropertyName || 'id'; + addToArrayWhenNotExists(this._views, viewInfo, idPropName); + } + + protected disposeViewComponent(expandedView: CreatedView): CreatedView | void { + if (expandedView) { + if (expandedView?.root) { + const container = this.gridContainerElement.getElementsByClassName(`${ROW_DETAIL_CONTAINER_PREFIX}${this._views[0].id}`); + if (container?.length) { + expandedView.root.unmount(); + container[0].textContent = ''; + return expandedView; + } + } + } + } + + /** + * Just before the row get expanded or collapsed we will do the following + * First determine if the row is expanding or collapsing, + * if it's expanding we will add it to our View Components reference array if we don't already have it + * or if it's collapsing we will remove it from our View Components reference array + */ + protected handleOnBeforeRowDetailToggle(_e: SlickEventData, args: { grid: SlickGrid; item: any; }) { + // expanding + if (args?.item?.__collapsed) { + // expanding row detail + this.addViewInfoToViewsRef(args.item, null); + } else { + // collapsing, so dispose of the View + const foundViewIdx = this._views.findIndex((view: CreatedView) => view.id === args.item[this.datasetIdPropName]); + if (foundViewIdx >= 0 && this.disposeViewComponent(this._views[foundViewIdx])) { + this._views.splice(foundViewIdx, 1); + } + } + } + + /** When Row comes back to Viewport Range, we need to redraw the View */ + protected async handleOnRowBackToViewportRange(_e: SlickEventData, args: { + item: any; + rowId: string | number; + rowIndex: number; + expandedRows: (string | number)[]; + rowIdsOutOfViewport: (string | number)[]; + grid: SlickGrid; + }) { + if (args?.item) { + await this.redrawAllViewComponents(); + } + } + + /** + * notify the onAsyncResponse with the "args.item" (required property) + * the plugin will then use item to populate the row detail panel with the "postTemplate" + * @param item + */ + protected notifyTemplate(item: any) { + if (this.onAsyncResponse) { + this.onAsyncResponse.notify({ item, itemDetail: item }, new SlickEventData(), this); + } + } + + /** + * On Processing, we will notify the plugin with the new item detail once backend server call completes + * @param item + */ + protected async onProcessing(item: any) { + if (item && typeof this._userProcessFn === 'function') { + let awaitedItemDetail: any; + const userProcessFn = this._userProcessFn(item); + + // wait for the "userProcessFn", once resolved we will save it into the "collection" + const response: any | any[] = await userProcessFn; + + if (response.hasOwnProperty(this.datasetIdPropName)) { + awaitedItemDetail = response; // from Promise + } else if (response instanceof Response && typeof response['json'] === 'function') { + awaitedItemDetail = await response['json'](); // from Fetch + } else if (response && response['content']) { + awaitedItemDetail = response['content']; // from http-client + } + + if (!awaitedItemDetail || !awaitedItemDetail.hasOwnProperty(this.datasetIdPropName)) { + throw new Error('[Slickgrid-React] could not process the Row Detail, please make sure that your "process" callback ' + + '(a Promise or an HttpClient call returning an Observable) returns an item object that has an "${this.datasetIdPropName}" property'); + } + + // notify the plugin with the new item details + this.notifyTemplate(awaitedItemDetail || {}); + } + } +} diff --git a/src/slickgrid-react/global-grid-options.ts b/src/slickgrid-react/global-grid-options.ts index d436299..6ecde43 100644 --- a/src/slickgrid-react/global-grid-options.ts +++ b/src/slickgrid-react/global-grid-options.ts @@ -1,5 +1,5 @@ import { type Column, DelimiterType, EventNamingStyle, FileType, Filters, OperatorType, type TreeDataOption } from '@slickgrid-universal/common'; -import type { GridOption } from './models/index'; +import type { GridOption, RowDetailView } from './models/index'; /** * Default Options that can be passed to the Slickgrid-React @@ -227,6 +227,15 @@ export const GlobalGridOptions: Partial = { pageSize: 25, totalItems: 0 }, + rowDetailView: { + collapseAllOnSort: true, + cssClass: 'detail-view-toggle', + panelRows: 1, + keyPrefix: '__', + useRowClick: false, + useSimpleViewportCalc: true, + saveDetailViewOnScroll: false, + } as RowDetailView, headerRowHeight: 35, rowHeight: 35, topPanelHeight: 30, diff --git a/src/slickgrid-react/index.ts b/src/slickgrid-react/index.ts index a4c4fd8..27fa2c9 100644 --- a/src/slickgrid-react/index.ts +++ b/src/slickgrid-react/index.ts @@ -2,12 +2,14 @@ import 'regenerator-runtime/runtime.js'; export * from '@slickgrid-universal/common'; import { SlickgridReact } from './components/slickgrid-react'; +import { SlickRowDetailView } from './extensions/slickRowDetailView'; import type { SlickgridEventAggregator } from './components/slickgridEventAggregator'; import type { SlickgridConfig } from './slickgrid-config'; import type { SlickgridReactInstance, SlickgridReactComponentOutput, + RowDetailView, GridOption, } from './models/index'; @@ -22,6 +24,8 @@ export { type SlickgridReactInstance, type SlickgridReactComponentOutput, type GridOption, + type RowDetailView, SlickgridReact, - SlickgridConfig + SlickgridConfig, + SlickRowDetailView, }; diff --git a/src/slickgrid-react/models/gridOption.interface.ts b/src/slickgrid-react/models/gridOption.interface.ts index 4b816a8..741c395 100644 --- a/src/slickgrid-react/models/gridOption.interface.ts +++ b/src/slickgrid-react/models/gridOption.interface.ts @@ -1,7 +1,12 @@ import type { GridOption as UniversalGridOption } from '@slickgrid-universal/common'; import type * as i18next from 'i18next'; +import type { RowDetailView } from './rowDetailView.interface'; + export interface GridOption extends UniversalGridOption { /** I18N translation service instance */ i18n?: i18next.i18n; + + /** Row Detail View Plugin options & events (columnId, cssClass, toolTip, width) */ + rowDetailView?: RowDetailView; } diff --git a/src/slickgrid-react/models/index.ts b/src/slickgrid-react/models/index.ts index a3712a5..61b9455 100644 --- a/src/slickgrid-react/models/index.ts +++ b/src/slickgrid-react/models/index.ts @@ -1,3 +1,6 @@ -export type * from './slickgridReactInstance.interface'; -export type * from './reactComponentOutput.interface'; export type * from './gridOption.interface'; +export type * from './reactComponentOutput.interface'; +export type * from './rowDetailView.interface'; +export type * from './slickgridReactInstance.interface'; +export type * from './viewModelBindableData.interface'; +export type * from './viewModelBindableInputData.interface'; diff --git a/src/slickgrid-react/models/rowDetailView.interface.ts b/src/slickgrid-react/models/rowDetailView.interface.ts new file mode 100644 index 0000000..dc73830 --- /dev/null +++ b/src/slickgrid-react/models/rowDetailView.interface.ts @@ -0,0 +1,15 @@ +import type { RowDetailView as UniversalRowDetailView } from '@slickgrid-universal/common'; + +export interface RowDetailView extends UniversalRowDetailView { + /** + * Optionally pass your Parent Component reference to your Child Component (row detail component). + * note:: If anyone finds a better way of passing the parent to the row detail extension, please reach out and/or create a PR + */ + parent?: any; + + /** View Model of the preload template which shows after opening row detail & before row detail data shows up */ + preloadComponent?: any; + + /** View Model template that will be loaded once the async function finishes */ + viewComponent?: any; +} diff --git a/src/slickgrid-react/models/viewModelBindableData.interface.ts b/src/slickgrid-react/models/viewModelBindableData.interface.ts new file mode 100644 index 0000000..46c4377 --- /dev/null +++ b/src/slickgrid-react/models/viewModelBindableData.interface.ts @@ -0,0 +1,10 @@ +import type { SlickDataView, SlickGrid } from '@slickgrid-universal/common'; + +export interface ViewModelBindableData { + template: string; + model: any; + addon: any; + grid: SlickGrid; + dataView: SlickDataView; + parent?: any; +} diff --git a/src/slickgrid-react/models/viewModelBindableInputData.interface.ts b/src/slickgrid-react/models/viewModelBindableInputData.interface.ts new file mode 100644 index 0000000..ea165fa --- /dev/null +++ b/src/slickgrid-react/models/viewModelBindableInputData.interface.ts @@ -0,0 +1,9 @@ +import type { SlickDataView, SlickGrid } from '@slickgrid-universal/common'; + +export interface ViewModelBindableInputData { + model: any; + addon: any; + grid: SlickGrid; + dataView: SlickDataView; + parent?: any; +} diff --git a/test/cypress/e2e/example19.cy.ts b/test/cypress/e2e/example19.cy.ts new file mode 100644 index 0000000..a5193b7 --- /dev/null +++ b/test/cypress/e2e/example19.cy.ts @@ -0,0 +1,344 @@ +describe('Example 19 - Row Detail View', () => { + const titles = ['', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example19`); + cy.get('h2').should('contain', 'Example 19: Row Detail View'); + }); + + it('should have exact column titles on 1st grid', () => { + cy.get('#grid19') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + + it('should display first few rows of Task 0 to 5', () => { + const expectedTasks = ['Task 0', 'Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']; + + cy.get('#grid19') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedTasks.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedTasks[index]); + }); + }); + + it('should click anywhere on 3rd row to open its Row Detail and expect its title to be Task 2 in an H2 tag', () => { + cy.get('#grid19') + .find('.slick-row:nth(2)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_2 .container_2') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('h3') + .contains('Task 2'); + }); + + it('should click on the "Click Me" button and expect the assignee name to showing in uppercase in an Alert', () => { + let assignee = ''; + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_2 .container_2') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('input') + .invoke('val') + .then(text => assignee = text as string); + + cy.get('@detailContainer') + .find('[data-test=assignee-btn]') + .click() + .then(() => { + if (assignee === '') { + expect(alertStub.getCall(0)).to.be.calledWith(`No one is assigned to this task.`); + } else { + expect(alertStub.getCall(0)).to.be.calledWith(`Assignee on this task is: ${assignee.toUpperCase()}`); + } + }); + }); + + it('should click on the "Call Parent Method" button and expect a Bootstrap Alert to show up with some text containing the Task 2', () => { + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_2 .container_2') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('[data-test=parent-btn]') + .click(); + + cy.get('.alert-info[data-test=flash-msg]') + .contains('We just called Parent Method from the Row Detail Child Component on Task 2'); + }); + + it('should click on the "Delete Row" button and expect the Task 2 to be deleted from the grid', () => { + const expectedTasks = ['Task 0', 'Task 1', 'Task 3', 'Task 4', 'Task 5']; + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_2 .container_2') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('[data-test=delete-btn]') + .click(); + + cy.get('.slick-viewport-top.slick-viewport-left') + .scrollTo('top'); + + cy.get('#grid19') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedTasks.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedTasks[index]); + }); + + cy.get('.alert-danger[data-test=flash-msg]') + .contains('Deleted row with Task 2'); + }); + + it('should open a few Row Details and expect them to be closed after clicking on the "Close All Row Details" button', () => { + const expectedTasks = ['Task 0', 'Task 1', 'Task 3', 'Task 4', 'Task 5']; + + cy.get('#grid19') + .find('.slick-row:nth(2)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_3 .container_3') + .as('detailContainer3'); + + cy.get('@detailContainer3') + .find('h3') + .contains('Task 3'); + + cy.get('#grid19') + .find('.slick-row:nth(0)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_0 .container_0') + .as('detailContainer0'); + + cy.get('@detailContainer0') + .find('h3') + .contains('Task 0'); + + cy.get('[data-test=collapse-all-btn]') + .click(); + + cy.get('.slick-viewport-top.slick-viewport-left') + .scrollTo('top'); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_0 .container_0') + .should('not.exist'); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_1 .container_1') + .should('not.exist'); + + cy.get('#grid19') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedTasks.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedTasks[index]); + }); + }); + + it('should open a few Row Details, then sort by Title and expect all Row Details to be closed afterward', () => { + const expectedTasks = ['Task 0', 'Task 1', 'Task 10', 'Task 100', 'Task 101', 'Task 102', 'Task 103', 'Task 104']; + + cy.get('#grid19') + .find('.slick-row:nth(0)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_0 .container_0') + .as('detailContainer0'); + + cy.get('@detailContainer0') + .find('h3') + .contains('Task 0'); + + cy.get('#grid19') + .find('.slick-row:nth(9)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_3 .container_3') + .as('detailContainer3'); + + cy.get('@detailContainer3') + .find('h3') + .contains('Task 3'); + + cy.get('#slickGridContainer-grid19') + .find('.slick-header-column:nth(1)') + .trigger('mouseover') + .children('.slick-header-menu-button') + .should('be.hidden') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item:nth-of-type(4)') + .children('.slick-menu-content') + .should('contain', 'Sort Descending') + .click(); + + cy.get('#slickGridContainer-grid19') + .find('.slick-header-column:nth(1)') + .trigger('mouseover') + .children('.slick-header-menu-button') + .invoke('show') + .click(); + + cy.get('.slick-header-menu .slick-menu-command-list') + .should('be.visible') + .children('.slick-menu-item:nth-of-type(3)') + .children('.slick-menu-content') + .should('contain', 'Sort Ascending') + .click(); + + cy.get('#grid19') + .find('.slick-header-column:nth(1)') + .find('.slick-sort-indicator-asc') + .should('have.length', 1); + + cy.get('.slick-viewport-top.slick-viewport-left') + .scrollTo('top'); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_0 .container_0') + .should('not.exist'); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_3 .container_3') + .should('not.exist'); + + cy.get('#grid19') + .find('.slick-row') + .each(($row, index) => { + if (index > expectedTasks.length - 1) { + return; + } + cy.wrap($row).children('.slick-cell:nth(1)') + .first() + .should('contain', expectedTasks[index]); + }); + }); + + it('should click open Row Detail of Task 1 and Task 101 then type a title filter of "Task 101" and expect Row Detail to be opened and still be rendered', () => { + cy.get('#grid19') + .find('.slick-row:nth(4)') + .click(); + + cy.get('#grid19') + .find('.slick-row:nth(1)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_101') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('h3') + .contains('Task 101'); + + cy.get('.search-filter.filter-title') + .type('Task 101'); + }); + + it('should call "Clear all Filters" from Grid Menu and expect "Task 101" to still be rendered correctly', () => { + cy.get('#grid19') + .find('button.slick-grid-menu-button') + .trigger('click') + .click(); + + cy.get(`.slick-grid-menu:visible`) + .find('.slick-menu-item') + .first() + .find('span') + .contains('Clear all Filters') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_101') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('h3') + .contains('Task 101'); + }); + + it('should call "Clear all Sorting" from Grid Menu and expect all row details to be collapsed', () => { + cy.get('#grid19') + .find('button.slick-grid-menu-button') + .trigger('click') + .click(); + + cy.get(`.slick-grid-menu:visible`) + .find('.slick-menu-item') + .find('span') + .contains('Clear all Sorting') + .click(); + + cy.get('#grid19') + .find('.slick-sort-indicator-asc') + .should('have.length', 0); + + cy.get('.dynamic-cell-detail').should('have.length', 0); + }); + + it('should close all row details & make grid editable', () => { + cy.get('[data-test="collapse-all-btn"]').click(); + cy.get('[data-test="editable-grid-btn"]').click(); + }); + + it('should click on 5th row detail open icon and expect it to open', () => { + cy.get('#grid19') + .find('.slick-row:nth(4) .slick-cell:nth(0)') + .click(); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_101') + .as('detailContainer'); + + cy.get('@detailContainer') + .find('h3') + .contains('Task 101'); + }); + + it('should click on 2nd row "Title" cell to edit it and expect Task 5 row detail to get closed', () => { + cy.get('#grid19') + .find('.slick-row:nth(1) .slick-cell:nth(1)') + .click(); + + cy.get('.editor-title') + .invoke('val') + .then(text => expect(text).to.eq('Task 1')); + + cy.get('#grid19') + .find('.slick-cell + .dynamic-cell-detail .innerDetailView_101') + .should('not.exist'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9d8cb69..758edcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,6 +1021,14 @@ "@slickgrid-universal/binding" "~5.9.0" "@slickgrid-universal/common" "~5.9.0" +"@slickgrid-universal/row-detail-view-plugin@~5.9.0": + version "5.9.0" + resolved "https://registry.yarnpkg.com/@slickgrid-universal/row-detail-view-plugin/-/row-detail-view-plugin-5.9.0.tgz#1805908a0c56e7480875ef1fb6fc7bc64c7ee1a1" + integrity sha512-QkOoGODCABsvRPoeHG4lAd6fOFAIh2fRsrbnvgeVPXQ8dXGoPUKAW2jMbuVPm0cX+YDiydh2O/1lSpGptXphCA== + dependencies: + "@slickgrid-universal/common" "~5.9.0" + "@slickgrid-universal/utils" "~5.9.0" + "@slickgrid-universal/rxjs-observable@~5.9.0": version "5.9.0" resolved "https://registry.yarnpkg.com/@slickgrid-universal/rxjs-observable/-/rxjs-observable-5.9.0.tgz#0efa6da06d9ddf94ae5e6e2d8513ddedecdcb512"