// Adopted and converted to typescript from https://github.com/6pac/SlickGrid/blob/master/plugins/slick.rowdetailview.js // heavily modified import { escape } from 'sql/base/common/strings'; import { mixin } from 'vs/base/common/objects'; import * as nls from 'vs/nls'; export interface IRowDetailViewOptions { columnId?: string; cssClass?: string; toolTip?: string; width?: number; panelRows: number; useRowClick?: boolean; loadOnce?: boolean; preTemplate: (item: ExtendedItem) => string; process: (item: ExtendedItem) => void; postTemplate: (item: ExtendedItem) => string; } const defaultOptions = { columnId: '_detail_selector', toolTip: '', width: 30 }; export interface IExtendedItem { _collapsed?: boolean; _parent?: IExtendedItem & T; _detailContent?: string; _detailViewLoaded?: boolean; _sizePadding?: number; id?: string; _height?: number; _isPadding?: boolean; name?: string; _child?: IExtendedItem; message?: string; } export type ExtendedItem = T & IExtendedItem; interface AugmentedDataView extends Slick.Data.DataView { getItem(row: number): ExtendedItem; getItemByIdx(row: number): ExtendedItem; } export class RowDetailView { public readonly onAsyncResponse = new Slick.Event<{ itemDetail: ExtendedItem, detailView: string }>(); public readonly onAsyncEndUpdate = new Slick.Event<{ grid: Slick.Grid, itemDetail: T }>(); public readonly onAfterRowDetailToggle = new Slick.Event<{ grid: Slick.Grid, item: T }>(); public readonly onBeforeRowDetailToggle = new Slick.Event<{ grid: Slick.Grid, item: T }>(); private _grid: Slick.Grid; private _expandedRows: Array> = []; private _handler = new Slick.EventHandler(); private _dataView: AugmentedDataView; private _options: IRowDetailViewOptions; constructor(options: IRowDetailViewOptions) { this._options = options || Object.create(null); mixin(this._options, defaultOptions, false); } public init(grid: Slick.Grid): void { this._grid = grid; this._dataView = this._grid.getData() as AugmentedDataView; // this is a bad assumption but the code is written with this assumption // Update the minRowBuffer so that the view doesn't disappear when it's at top of screen + the original default 3 this._grid.getOptions().minRowBuffer = this._options.panelRows + 3; this._handler .subscribe(this._grid.onClick, (e: MouseEvent, args: Slick.OnClickEventArgs) => this.handleClick(e, args)) .subscribe(this._grid.onSort, () => this.handleSort()) .subscribe(this._grid.onScroll, () => this.handleScroll()); this._dataView.onRowCountChanged.subscribe(() => { this._grid.updateRowCount(); this._grid.render(); }); this._dataView.onRowsChanged.subscribe((e, a) => { this._grid.invalidateRows(a.rows); this._grid.render(); }); // subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished this.subscribeToOnAsyncResponse(); } public destroy() { this._handler.unsubscribeAll(); this.onAsyncResponse.unsubscribe(); this.onAsyncEndUpdate.unsubscribe(); this.onAfterRowDetailToggle.unsubscribe(); this.onBeforeRowDetailToggle.unsubscribe(); } public getOptions() { return this._options; } public setOptions(options: IRowDetailViewOptions) { this._options = jQuery.extend(true, {}, this._options, options); } public handleClick(e: MouseEvent, args: Slick.OnClickEventArgs): void { // clicking on a row select checkbox if (this._options.useRowClick || this._grid.getColumns()[args.cell].id === this._options.columnId && jQuery(e.target!).hasClass('detailView-toggle')) { // if editing, try to commit if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) { e.preventDefault(); e.stopImmediatePropagation(); return; } const item = this._dataView.getItem(args.row); // trigger an event before toggling this.onBeforeRowDetailToggle.notify({ grid: this._grid, item: item }, e, this); this.toggleRowSelection(item); // trigger an event after toggling this.onAfterRowDetailToggle.notify({ grid: this._grid, item: item }, e, this); e.stopPropagation(); e.stopImmediatePropagation(); } } // Sort will just collapse all of the open items public handleSort() { this.collapseAll(); } // If we scroll save detail views that go out of cache range public handleScroll() { const range = this._grid.getRenderedRange(); const start: number = (range.top > 0 ? range.top : 0); const end: number = (range.bottom > this._dataView.getLength() ? range.bottom : this._dataView.getLength()); if (end <= 0) { return; } // Get the item at the top of the view const topMostItem = this._dataView.getItemByIdx(start); // Check it is a parent item if (topMostItem._parent === undefined) { // This is a standard row as we have no parent. const nextItem = this._dataView.getItemByIdx(start + 1); if (nextItem !== undefined && nextItem._parent !== undefined) { // This is likely the expanded Detail Row View // Check for safety if (nextItem._parent === topMostItem) { this.saveDetailView(topMostItem); } } } // Find the bottom most item that is likely to go off screen const bottomMostItem = this._dataView.getItemByIdx(end - 1); // If we are a detailView and we are about to go out of cache view if (bottomMostItem._parent !== undefined) { this.saveDetailView(bottomMostItem._parent); } } // Toggle between showing and hiding a row public toggleRowSelection(row: T) { this._dataView.beginUpdate(); this.handleAccordionShowHide(row); this._dataView.endUpdate(); } // Collapse all of the open items public collapseAll() { for (let i = this._expandedRows.length - 1; i >= 0; i--) { this.collapseItem(this._expandedRows[i]); } } // Saves the current state of the detail view public saveDetailView(item: ExtendedItem) { const view = jQuery('#innerDetailView_' + item.id); if (view) { const html = jQuery('#innerDetailView_' + item.id).html(); if (html !== undefined) { item._detailContent = html; } } } // Colapse an Item so it is notlonger seen public collapseItem(item: ExtendedItem) { // Save the details on the collapse assuming onetime loading if (this._options.loadOnce) { this.saveDetailView(item); } item._collapsed = true; for (let idx = 1; idx <= item._sizePadding!; idx++) { this._dataView.deleteItem(item.id + '.' + idx); } item._sizePadding = 0; this._dataView.updateItem(item.id!, item); // Remove the item from the expandedRows this._expandedRows = this._expandedRows.filter((r) => { return r.id !== item.id; }); } // Expand a row given the dataview item that is to be expanded public expandItem(item: ExtendedItem) { item._collapsed = false; this._expandedRows.push(item); // In the case something went wrong loading it the first time such a scroll of screen before loaded if (!item._detailContent) { item._detailViewLoaded = false; } // display pre-loading template if (!item._detailViewLoaded || this._options.loadOnce !== true) { item._detailContent = this._options.preTemplate(item); } else { this.onAsyncResponse.notify({ itemDetail: item, detailView: item._detailContent! }, undefined, this); this.applyTemplateNewLineHeight(item); this._dataView.updateItem(item.id!, item); return; } this.applyTemplateNewLineHeight(item); this._dataView.updateItem(item.id!, item); // async server call this._options.process(item); } /** * subscribe to the onAsyncResponse so that the plugin knows when the user server side calls finished * the response has to be as "args.itemDetail" with it's data back */ public subscribeToOnAsyncResponse() { this.onAsyncResponse.subscribe((e, args) => { if (!args || !args.itemDetail) { throw new Error('Slick.RowDetailView plugin requires the onAsyncResponse() to supply "args.itemDetail" property.'); } // If we just want to load in a view directly we can use detailView property to do so if (args.detailView) { args.itemDetail._detailContent = args.detailView; } else { args.itemDetail._detailContent = this._options.postTemplate(args.itemDetail); } args.itemDetail._detailViewLoaded = true; this._dataView.updateItem(args.itemDetail.id!, args.itemDetail); // trigger an event once the post template is finished loading this.onAsyncEndUpdate.notify({ grid: this._grid, itemDetail: args.itemDetail }, e, this); }); } public handleAccordionShowHide(item?: ExtendedItem) { if (item) { if (!item._collapsed) { this.collapseItem(item); } else { this.expandItem(item); } } } ////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////// public getPaddingItem(parent: ExtendedItem, offset: number | string) { const item: any = {}; for (const prop in this._grid.getData()) { item[prop] = null; } item.id = parent.id + '.' + offset; //additional hidden padding metadata fields item._collapsed = true; item._isPadding = true; item._parent = parent; return item; } public getErrorItem(parent: ExtendedItem, offset: number | string) { const item: ExtendedItem = Object.create(null); item.id = parent.id + '.' + offset; item._collapsed = true; item._isPadding = false; item._parent = parent; item.name = parent.message ? parent.message : nls.localize('rowDetailView.loadError', 'Loading Error...'); parent._child = item; return item; } ////////////////////////////////////////////////////////////// //create the detail ctr node. this belongs to the dev & can be custom-styled as per ////////////////////////////////////////////////////////////// public applyTemplateNewLineHeight(item: ExtendedItem, showError = false) { // the height seems to be calculated by the template row count (how many line of items does the template have) const rowCount = this._options.panelRows; //calculate padding requirements based on detail-content.. //ie. worst-case: create an invisible dom node now &find it's height. const lineHeight = 13; //we know cuz we wrote the custom css innit ;) item._sizePadding = Math.ceil(((rowCount * 2) * lineHeight) / this._grid.getOptions().rowHeight!); item._height = (item._sizePadding * this._grid.getOptions().rowHeight!); const idxParent = this._dataView.getIdxById(item.id!); for (let idx = 1; idx <= item._sizePadding; idx++) { if (showError) { this._dataView.insertItem(idxParent + idx, this.getErrorItem(item, 'error')); } else { this._dataView.insertItem(idxParent + idx, this.getPaddingItem(item, idx)); } } } public getColumnDefinition(): Slick.Column { return { id: this._options.columnId, name: '', toolTip: this._options.toolTip, field: 'sel', width: this._options.width, resizable: false, sortable: false, cssClass: this._options.cssClass, formatter: (row, cell, value, columnDef, dataContext) => this.detailSelectionFormatter(row, cell, value, columnDef, dataContext as ExtendedItem) }; } public detailSelectionFormatter(row: number, cell: number, value: any, columnDef: Slick.Column, dataContext: ExtendedItem): string | undefined { if (dataContext._collapsed === undefined) { dataContext._collapsed = true; dataContext._sizePadding = 0; //the required number of pading rows dataContext._height = 0; //the actual height in pixels of the detail field dataContext._isPadding = false; dataContext._parent = undefined; } if (dataContext._isPadding === true) { //render nothing } else if (dataContext._collapsed) { return '
'; } else { const html: Array = []; const rowHeight = this._grid.getOptions().rowHeight!; const bottomMargin = 5; //V313HAX: //putting in an extra closing div after the closing toggle div and ommiting a //final closing div for the detail ctr div causes the slickgrid renderer to //insert our detail div as a new column ;) ~since it wraps whatever we provide //in a generic div column container. so our detail becomes a child directly of //the row not the cell. nice =) ~no need to apply a css change to the parent //slick-cell to escape the cell overflow clipping. //sneaky extra inserted here-----------------v html.push('
'); html.push(`
`); //shift detail below 1st row html.push(`
`); //sub ctr for custom styling html.push(`
${escape(dataContext._detailContent!)}
`); //&omit a final closing detail container
that would come next return html.join(''); } return undefined; } }