Search code examples
angularag-gridag-grid-angular

AG Grid data is persisting between components causing duplicate node errors


Edit: I'm attempting to manually use the grid api destroy function in the re-usable component and the parent component actually calling ag grid (in the template). Still having an issue with ag-grid retaining the old data.

I'm using a re-usable component for our app (it's got 50 some odd tables, so we've wrapped up the tooling so it's easier to update/change). I've coded in some filter persistence and row persistence (so if you click on an item, navigate to details, and come back, it'll take you back to that item and have it selected. Useful for our very large grids)

The issue is that when I go from one component where I didn't set the getRowNodeId callback to one where I do, it's freaking out saying there's duplicate node ids (b/c it's still referencing the data from the old grid. The property doesn't exist in that data set so everything is null).

I've tried manually destroying the instance of aggrid onDestroy, but that didn't seem to do anything. I've tried setting the row data to an empty array on init, but that seems to have broken the init logic (I have some logic for first data draw) and in general the initial loading logic for ag grid seems to decide it's done immediately. The persistence logic no longer works if I do this. I've also tried delaying setting that callback until after the first data draw, but then it doesn't seem to use the callback.

Any thoughts on how to keep the getRowNodeId from spazzing out while ag grid resolves the new grid data? (or clear the previous instance of ag grid properly. It seems it's retaining the reference to it somewhere.)

How the grids are setup

                    <grid-tools pagination = "true" IdField = "RouteId" agGrid = "true" persistFilters = "true" [gridEnum]="gridEnum" [agDataGrid]="transMatrixAgGrid" standardTools = "true"  [filterOptions]="filterOptions"></grid-tools>

                </div>
            </div>
            <div class="row add-top add-bottom">
                <div class="col-xs-12" style="width:100%;height:500px;">

                    <mat-progress-bar mode="indeterminate" *ngIf="loading"></mat-progress-bar>

                    <ag-grid-angular #transMatrixAgGrid class="ag-theme-material agGrid"
                        [rowData]="routes" 
                        [gridOptions] = "gridOptions"
                        (rowClicked) = "rowClick($event)" 
                        (selectionChanged)="updateSelectedItems($event)"                                                    
                        [frameworkComponents] = "frameworkComponents"                             
                        [columnDefs]="agColumns">
                    </ag-grid-angular>

                </div>

In the component, save the reference to the template variable.

@ViewChild("transMatrixAgGrid") transMatrixAgGrid;

The re-used component. We're transitioning from devextreme so there's some conditional logic for ag grid scenarios. I didn't want to try and delete some of that code in case someone tried to paste this code into an IDE. This is the current working state.

import { Component, OnInit, Input, OnDestroy } from "@angular/core";
import { MatBottomSheet, MatBottomSheetRef } from "@angular/material";
import { ToolsOptionsMenu } from "./ToolsOptionsMenu/ToolsOptionsMenu.component";
import { DxDataGridComponent } from "devextreme-angular";
import * as _ from "lodash";
import FilterOption from "@classes/FilterOptions.class";
import { utc } from 'moment';
import { FormControl } from '@angular/forms';

// Careful about modifications. Large grids can become slow if this isn't efficient
export function compareDates(filterLocalDateAtMidnight, cellValue) {
    var dateAsString = cellValue;
    if (dateAsString == null) return 0;

    // In the example application, dates are stored as dd/mm/yyyy
    // We create a Date object for comparison against the filter date
    var dateParts = dateAsString.split("T")[0].split("-");
    var day = Number(dateParts[2]);
    var month = Number(dateParts[1]) - 1;
    var year = Number(dateParts[0]);
    var cellDate = new Date(year, month, day);

    // Now that both parameters are Date objects, we can compare
    if (cellDate < filterLocalDateAtMidnight) {
        return -1;
    } else if (cellDate > filterLocalDateAtMidnight) {
        return 1;
    } else {
        return 0;
    }
}

//Ag-grid date formatter.
export function dateFormatter(dateIn) {

    return dateIn.value ? utc(dateIn.value).format("MM/DD/YYYY") : "";
}

//Default grid options
export var gridOptions = {
    rowSelection: "multiple",
    pagination: true,
    rowMultiSelectWithClick: true,
    animateRows: true,
    floatingFilter: true,
    rowBuffer: 20
}

//Default checkbox column options
export var checkboxColumnOptions = {
    field: 'RowSelect',
    width: 50,
    headerName: ' ',
    headerCheckboxSelection: true,
    headerCheckboxSelectionFilteredOnly: true,
    checkboxSelection: true,
    suppressMenu: true,
    sortable: false,
}

@Component({
    selector: "grid-tools",
    templateUrl: "./grid-tools.component.html",
    styleUrls: ["./grid-tools.component.scss"]
})
export class GridToolsComponent implements OnInit, OnDestroy {

    maxSelection = 30000;
    _filterOptions: Array<FilterOption> = []
    currentlySelected = [];
    currentPage = 1;
    pageControl: FormControl;
    storageKey = "";
    selectionStorageKey = "";

    ALL_FILTER = "ALL";
    currentFilter = this.ALL_FILTER;

    //!!!Using filterOptions.push will not trigger this!!!!
    @Input() set filterOptions(value: Array<FilterOption>) {

        // value.splice(0, 0, new FilterOption());
        value.push(new FilterOption());
        this._filterOptions = value;

    };

    get filterOptions() {
        return this._filterOptions;
    };

    @Input() dataGrid: DxDataGridComponent;
    @Input() agDataGrid;
    @Input() agGrid: boolean = false;
    @Input() standardTools: boolean = false;
    @Input() persistGridViewChild?;
    @Input() hideToolsButton = true; //This is until we make the change completely

    @Input() hideExport = false;
    @Input() hideColumnCustomization = false;
    @Input() hideClearFilters = false;
    @Input() hideResetGrid = false;
    @Input() persistFilters = false;
    @Input() pagination = false;
    @Input() gridEnum = null;
    @Input() IdField = null; //Required for navigating to the last selected row   

    constructor(private bottomSheet: MatBottomSheet) {

        this.filterOptions = [];
    }

    ngOnDestroy() {
        // console.log("Destroying component");
        // if (this.agDataGrid) {

        //     this.agDataGrid.api.destroy();
        // }
    }

    ngOnInit() {

        this.pageControl = new FormControl(this.currentPage);
        this.storageKey = "agGrid-" + this.gridEnum;
        this.selectionStorageKey = "agGrid-" + this.gridEnum + "-selection";

        if (this.dataGrid) {

            this.dataGrid.onContentReady.subscribe(result => {
                this.quickFilterSearch();
            });

            this.dataGrid.filterValueChange.subscribe(filterValue => {
                this.quickFilterSearch();
            });


            this.dataGrid.selectedRowKeysChange.subscribe(
                (selections) => {
                    this.currentlySelected = selections;
                }
            )
        }

        if (this.agDataGrid) {

            if (this.IdField) {

                this.agDataGrid.getRowNodeId = (data) => {
                    return data ? data[this.IdField] : data["Id"];
                };
            }

            this.agDataGrid.gridReady.subscribe(
                () => {

                }
            )

            if (this.pagination) {

                this.pageControl.valueChanges
                    .subscribe(newValue => {
                        if (newValue == undefined) {
                            newValue = 1;
                        }
                        this.agDataGrid.api.paginationGoToPage(newValue - 1);
                    });

                this.agDataGrid.paginationChanged.subscribe(($event) => {
                    this.onPaginationChanged($event);
                })

            }



            this.agDataGrid.selectionChanged.subscribe(
                (event) => {

                    this.currentlySelected = this.agDataGrid.api.getSelectedRows();
                }
            );

            this.agDataGrid.rowClicked.subscribe(
                (event) => {

                    if (this.persistFilters) {

                        this.storeSelectedItem(event.node);

                    }
                }
            )

            this.agDataGrid.rowSelected.subscribe(
                (event) => {

                    if (this.persistFilters) {
                        if (event.node.isSelected()) {

                            this.storeSelectedItem(event.node);
                        }
                        else {
                            if (this.agDataGrid.api.getSelectedRows().length == 0) {
                                localStorage.setItem(this.selectionStorageKey, null);
                            }
                        }
                    }
                }
            )

            this.agDataGrid.filterChanged.subscribe(
                (event) => {
                    let currentFilter = this.agDataGrid.api.getFilterModel();
                    if (Object.keys(currentFilter).length == 0) {
                        this.currentFilter = 'ALL';
                    }

                    if (this.persistFilters) {
                        this.setPersistedFilterState(currentFilter);
                    }

                    this.quickFilterSearch();
                }
            )

            this.agDataGrid.firstDataRendered.subscribe(
                (event) => {



                    if (this.persistFilters) {
                        this.restoreFilters();
                        this.restoreSelection();
                    }
                }
            )
        }
    }

    storeSelectedItem(node) {
        let selectedItem = {
            Id: node.id,
            data: node.data
        }
        localStorage.setItem(this.selectionStorageKey, JSON.stringify(selectedItem));
    }

    onPaginationChanged($event) {
        this.currentPage = this.agDataGrid.api.paginationGetCurrentPage()
        this.pageControl.patchValue(this.currentPage + 1);
        this.pageControl.updateValueAndValidity({ emitEvent: false, onlySelf: true });
    }

    restoreSelection() {
        try {

            let selection: any = localStorage.getItem(this.selectionStorageKey);

            if (selection) {

                selection = JSON.parse(selection);

                let node = this.agDataGrid.api.getRowNode(selection.Id);
                node.setSelected(true);
                this.agDataGrid.api.ensureNodeVisible(node, "middle");

            }
        }
        catch (error) {
            console.log("Something wrong with getting " + this.selectionStorageKey + " local storage");
        }

    }

    restoreFilters() {

        let filterModel = localStorage.getItem(this.storageKey);

        if (filterModel) {
            filterModel = JSON.parse(filterModel);

            //TODO: Manually compare to incoming filter options and see if something matches.
            this.currentFilter = '';
        }

        this.agDataGrid.api.setFilterModel(filterModel);
    }

    setPersistedFilterState = (filterValue: any): void => {

        const stringifiedState: string = JSON.stringify(filterValue);
        localStorage.setItem(this.storageKey, stringifiedState);
    };

    resetColumns() {
        if (this.persistGridViewChild) {

            this.persistGridViewChild.resetColumnDefaults();
        }

        if (this.agDataGrid) {
            this.agDataGrid.columnApi.resetColumnState();
        }
    }

    customGrid() {
        this.dataGrid.instance.showColumnChooser();
    }

    export() {
        if (this.currentlySelected.length < this.maxSelection) {

            let selectionOnly = this.currentlySelected.length == 0 ? false : true;

            if (this.agDataGrid) {
                this.agDataGrid.api.exportDataAsCsv({
                    onlySelected: selectionOnly
                });
            }

            if (this.dataGrid) {

                this.dataGrid.instance.exportToExcel(selectionOnly);
            }
        }
    }

    clearAgSelections() {
        this.agDataGrid.api.clearSelections();
    }

    clearSelections() {
        if (this.agDataGrid) {
            this.agDataGrid.api.deselectAll();
        }
        else {

            this.dataGrid.selectedRowKeys = [];
        }
    }

    quickFilterSearch() {
        let state: any = {};

        if (this.agDataGrid) {
            state = _.cloneDeep(this.agDataGrid.api.getFilterModel());
        }
        else {
            state = _.cloneDeep(this.dataGrid.instance.state());
        }


        if (this.agDataGrid) {
            //TODO
        }
        else {
            this.currentFilter = ""; //If a custom filter is created by the user, we don't want any of the chips to be highlighted.

            if (state.filterValue == null) {
                this.currentFilter = this.ALL_FILTER;
            } else {
                _.map(this._filterOptions, (option: any) => {
                    let isEqual = _.isEqual(option.filter, state.filterValue);

                    if (isEqual) {
                        this.currentFilter = option.label;
                    }
                });
            }
        }
    }

    isFilterActive(incomingFilter) {
        return this.currentFilter == incomingFilter;
    }

    showToolsOptions() {
        this.bottomSheet.open(ToolsOptionsMenu, {
            data: {
                grid: this.dataGrid,
                persistGrid: this.persistGridViewChild
            }
        });
    }

    public filterGrid(filterOptions = new FilterOption()) {
        if (this.dataGrid) {

            this.currentFilter = filterOptions.label;

            const state: any = _.cloneDeep(this.dataGrid.instance.state());
            state.filterValue = filterOptions.filter; // New filterValue to be applied to grid.
            this.dataGrid.instance.state(state);

            //The state mechanism seems to not work if persistance is not active on the grid. 
            //This grid is stupid.
            if (!this.persistGridViewChild) {

                this.dataGrid.instance.clearFilter();
            }
        }

        if (this.agDataGrid) {
            this.currentFilter = filterOptions.label;
            this.agDataGrid.api.setFilterModel(filterOptions.filter);

            if (this.persistFilters) {

                this.setPersistedFilterState(filterOptions.filter);
            }
        }
    }
}

Edit: I've stopped using the custom callback and it's still doing it. The firstDataRendered function is actually firing with the old data. Not ideal :(


Solution

  • I'm not sure if this is a bug of ag-grid or not, but it ended up being my attemps to re-use the gridOptions from a component.

    Grid tools Component

    //Default grid options
    export var gridOptions = {
        rowSelection: "multiple",
        pagination: true,
        rowMultiSelectWithClick: true,
        animateRows: true,
        floatingFilter: true,
        rowBuffer: 20
    }
    

    Component using grid tools

    import { gridOptions } from '@app/grid-tools/grid-tools.component';
    

    Assigning value

    this.gridOptions = gridOptions;
    

    Template

    <ag-grid-angular #transMatrixAgGrid class="ag-theme-material agGrid"
                                [rowData]="routes" 
                                [gridOptions] = "gridOptions"                            
                                [columnDefs]="agColumns">
                            </ag-grid-angular>
    

    Somehow this was causing ag-grid to retain data from previous instances of the grid. I fixed this by simply using

    this.gridOptions = Object.assign({}, gridOptions)

    I'm assuming ag-grid was modifying the reference and that contained information about the data.