AngularJS + ag-grid: sticky/remembered selections

2019-07-11 02:34发布

In an AngularJS application, I have an ag-grid that uses virtual paging/infinite scrolling to lazy-load rows from a dataset that is too large to show at once. I have turned on check-box selection in the first column, so that the user should be able to select individual rows for arbitrary application-specific actions.

The AngularJS application uses ui-router to control multiple views. So, building on the virtual-paging example with "sorting & filtering", with constructed data about Olympic winners, from the ag-grid documentation, I've further extended the code a bit. From index.html:

<body ng-controller="MainController" class="container">
  <div ui-view="contents"></div>
</body>

and the following ui-router states:

myapp.config(function($stateProvider, $urlRouterProvider) {
  $urlRouterProvider.otherwise("example.page1")

  $stateProvider
    .state('example', {
      abstract: true,
      views: {
        contents: {
          template: '<div ui-view="example"></div>'
        }
      }
    })
    .state('example.page1', {
      url: '/page1',
      views: {
        example: {
          templateUrl: 'page1.html'
        }
      }
    })
    .state('example.page2', {
      url: '/page2',
      views: {
        example: {
          template: 'Go back to the <a ui-sref="example.page1">example grid</a>.'
        }
      }
    });
});

where page1.html looks like the following:

<div ng-controller="GridController">
  <div ag-grid="gridOptions" class="ag-fresh" style="height: 250px;"></div>
</div>
<div>
  <h3>Selected rows:</h3>
  <ul class="list-inline">
    <li ng-repeat="row in currentSelection track by row.id">
      <a ng-click="remove(row)">
        <div class="badge">#{{ row.id }}, {{ row.athlete }}</div>
      </a>
    </li>
  </ul>
</div>
<p>Go to <a ui-sref="example.page2">the other page</a>.</p>

What I want to accomplish:

  1. That selections made in the ag-grid is remembered (sticky) when scrolling a (virtual) page out of view and back again, so that a user can select multiple rows on separate pages.
  2. That the remembered selections are available outside the grid, and support adding and removing selections (as intended by the ng-click="remove(row)" in page1.html, shown above).
  3. That the selections should be remembered when switching from the view with the ag-grid to another one, and back again.
  4. (Optional) That the selections are remembered for the user's session.

How can I accomplish this?

1条回答
时光不老,我们不散
2楼-- · 2019-07-11 03:28

I've created a working example of this can be implemented.

First of all, we'll write a AngularJS service, selectionService to keep track of the selections:

function _emptyArray(array) {
  while (array.length) {
    array.pop();
  }
}

function _updateSharedArray(target, source) {
  _emptyArray(target);
  _.each(source, function _addActivity(activity) {
    target.push(activity);
  });
}

myapp.factory('selectionService', function ($rootScope, $window) {
  var _collections = {},
    _storage = $window.sessionStorage,
    _prefix = 'selectionService';

  angular.element($window).on('storage', _updateOnStorageChange);

  function _persistCollection(collection, data) {
    _storage.setItem(_prefix + ':' + collection, angular.toJson(data));
  }

  function _loadCollection(collection) {
    var item = _storage.getItem(_prefix + ':' + collection);
    return item !== null ? angular.fromJson(item) : item;
  }

  function _updateOnStorageChange(event) {
    var item = event.originalEvent.newValue;
    var keyParts = event.originalEvent.key.split(':');

    if (keyParts.length < 2 || keyParts[0] !== _prefix) {
      return;
    }
    var collection = keyParts[1];
    _updateSharedArray(_getCollection(collection), angular.fromJson(item));
    _broadcastUpdate(collection);
  }

  function _broadcastUpdate(collection) {
    $rootScope.$emit(_service.getUpdatedSignal(collection));
  }

  function _afterUpdate(collection, selected) {
    _persistCollection(collection, selected);
    _broadcastUpdate(collection);
  }

  function _getCollection(collection) {
    if (!_.has(_collections, collection)) {
      var data = _loadCollection(collection);
      // Holds reference to a shared array.  Only mutate, don't replace it.
      _collections[collection] = data !== null ? data : [];
    }

    return _collections[collection];
  }

  function _add(item, path, collection) {
    // Add `item` to `collection` where item will be identified by `path`.
    // For example, path could be 'id', 'row_id', 'data.athlete_id',
    // whatever fits the row data being added.
    var selected = _getCollection(collection);

    if (!_.any(selected, path, _.get(item, path))) {
      selected.push(item);
    }

    _afterUpdate(collection, selected);
  }

  function _remove(item, path, collection) {
    // Remove `item` from `collection`, where item is identified by `path`,
    // just like in _add().
    var selected = _getCollection(collection);

    _.remove(selected, path, _.get(item, path));

    _afterUpdate(collection, selected);
  }

  function _getUpdatedSignal(collection) {
    return 'selectionService:updated:' + collection;
  }

  function _updateInGridSelections(gridApi, path, collection) {
    var selectedInGrid = gridApi.getSelectedNodes(),
      currentlySelected = _getCollection(collection),
      gridPath = 'data.' + path;

    _.each(selectedInGrid, function (node) {
      if (!_.any(currentlySelected, path, _.get(node, gridPath))) {
        // The following suppressEvents=true flag is ignored for now, but a
        // fixing pull request is waiting at ag-grid GitHub.
        gridApi.deselectNode(node, true);
      }
    });

    var selectedIdsInGrid = _.pluck(selectedInGrid, gridPath),
      currentlySelectedIds = _.pluck(currentlySelected, path),
      missingIdsInGrid = _.difference(currentlySelectedIds, selectedIdsInGrid);

    if (missingIdsInGrid.length > 0) {
      // We're trying to avoid the following loop, since it seems horrible to
      // have to loop through all the nodes only to select some.  I wish there
      // was a way to select nodes/rows based on an id.
      var i;

      gridApi.forEachNode(function (node) {
        i = _.indexOf(missingIdsInGrid, _.get(node, gridPath));
        if (i >= 0) {
          // multi=true, suppressEvents=true:
          gridApi.selectNode(node, true, true);

          missingIdsInGrid.splice(i, 1);  // Reduce haystack.
          if (!missingIdsInGrid.length) {
            // I'd love for `forEachNode` to support breaking the loop here.
          }
        }
      });
    }
  }

  var _service = {
    getCollection: _getCollection,
    add: _add,
    remove: _remove,
    getUpdatedSignal: _getUpdatedSignal,
    updateInGridSelections: _updateInGridSelections
  };

  return _service;
});

The selectionService service allows adding and removing arbitrary objects to separate collections, identified by collection, a name you find suitable. This way the same service can be used for remembering selections in multiple ag-grid instances. Each object will be identified using a path parameter. The path is used to retrieve the unique identifier using lodash's get function.

Furthermore, the service uses sessionStorage to persist the selections during the user's whole tab/browser session. This might be overkill; we could have just relied on the service to keep track of the selections since it will only get instantiated once. This can of course be modified to your needs.

Then there were the changes that had to be done to the GridController. First of all the columnDefs entry for the first column had to be changed slightly

  var columnDefs = [
    {
      headerName: "#",
      width: 60,
      field: 'id',  // <-- Now we use a generated row ID.
      checkboxSelection: true,
      suppressSorting: true,
      suppressMenu: true
    }, …

where the new, generated row ID is generated once the data has been retrieved from the remote server

       // Add row ids.
       for (var i = 0; i < allOfTheData.length; i++) {
         var item = allOfTheData[i];

         item.id = 'm' + i;
       }

(The 'm' in the ID was included just to make sure I didn't confused that ID with other IDs used by ag-grid.)

Next, the necessary changes to gridOptions were to add

{
  …,
  onRowSelected: rowSelected,
  onRowDeselected: rowDeselected,
  onBeforeFilterChanged: clearSelections,
  onBeforeSortChanged: clearSelections,
  …
}

Were the different handlers are quite straight forward, communicating with the selectionService

  function rowSelected(event) {
    selectionService.add(event.node.data, 'id', 'page-1');
  }

  function rowDeselected(event) {
    selectionService.remove(event.node.data, 'id', 'page-1');
  }

  function clearSelections(event) {
    $scope.gridOptions.api.deselectAll();
  }

Now, the GridController needs to handle updates signalled by the selectionService too

  $scope.$on('$destroy',
             $rootScope.$on(selectionService.getUpdatedSignal('page-1'),
                            updateSelections));

and

  function updateSelections() {
    selectionService.updateInGridSelections($scope.gridOptions.api, 'id', 'page-1');
  }

calls selectionService.updateInGridSelections which will update the in-grid selections of the grid in question. That was the most cumbersome function to write. For example, if a selection has been added externally (outside the grid), then we'll have to perform a forEachNode run, even if we know all the necessary nodes have already been selected in-grid; there's no way to exit that loop early.

Finally, another crux was to clear and reapply the selections before and after, respectively, when the filters or sort orders are changed, or when new data is retrieved from the server (which is only simulated in the demo). The solution was to include a call to updateSelections after the params.successCallback inside the getRows handler

             params.successCallback(rowsThisPage, lastRow);
             updateSelections();

Now, the most puzzling findings during the implementation of this solution was that the ag-grid API grid options onAfterFilterChanged and onAfterSortChanged couldn't be used for reapplying the selections because they trigger before the (remote) data has finished loading.

查看更多
登录 后发表回答