| <!-- |
| ag-data-grid is an Web Component to display tabular data. |
| It has built-in support for paging, search, filters, sortable columns and custom cell renderer. |
| |
| ag-data-grid is responsive and can automatically hide columns on smaller screens based |
| on a few simple attributes defined by developer such as "importance" of a particular column. |
| |
| Simple usage: |
| ``` |
| <ag-data-grid summary="Displays each person's score" |
| pageSize=5 |
| dataSource="{{dataSource}}"> |
| <ag-data-grid-column label="Name"> |
| <template> |
| {{item.name}} |
| </template> |
| </ag-data-grid-column> |
| <ag-data-grid-column label="Score"> |
| <template> |
| {{item.score}} |
| </template> |
| </ag-data-grid-column> |
| </ag-data-grid> |
| ``` |
| |
| DataSource attribute expects an object that has a fetch(search, sort, filters) method. |
| Please see documentation on DataSource property for details. |
| |
| Please see the ```demo.html``` for a full featured example. |
| |
| @element ag-data-grid |
| @homepage https://github.com/aghassemi/ag-data-grid |
| @status unstable |
| --> |
| <link rel="import" href="../polymer/polymer.html"> |
| <link rel="import" href="../paper-icon-button/paper-icon-button.html"> |
| <link rel="import" href="../paper-dialog/paper-dialog.html"> |
| <link rel="import" href="../paper-dialog/paper-dialog-transition.html"> |
| <link rel="import" href="../paper-dialog/paper-action-dialog.html"> |
| <link rel="import" href="../paper-fab/paper-fab.html"> |
| <link rel="import" href="../core-collapse/core-collapse.html"> |
| |
| <link rel="import" href="column/ag-data-grid-column.html"> |
| <link rel="import" href="filter/select/item/ag-data-grid-filter-select-item.html"> |
| <link rel="import" href="filter/select/ag-data-grid-filter-select.html"> |
| <link rel="import" href="filter/toggle/ag-data-grid-filter-toggle.html"> |
| <link rel="import" href="search/ag-data-grid-search.html"> |
| <link rel="import" href="cell/renderer.html"> |
| <link rel="import" href="column/renderer.html"> |
| <link rel="import" href="row/renderer.html"> |
| |
| <polymer-element name="ag-data-grid" attributes="summary dataSource defaultSortKey defaultSortAscending pageSize responsiveWidth"> |
| <template> |
| <link rel="stylesheet" href="ag-data-grid.css"> |
| <div id="templates"></div> |
| <core-collapse id="searchTools"> |
| <div class="result-count">Showing {{ dataSourceResult.length }} items of {{totalNumItems}}</div> |
| <div> |
| <content select="[grid-search]"></content> |
| </div> |
| <div> |
| <content select="[grid-filter]"></content> |
| </div> |
| </core-collapse> |
| <table id="table" summary="{{ summary }}" cellpadding="0" cellpadding="0" border="0" style="visibility:hidden" > |
| <thead> |
| <tr> |
| <th is="ag-data-grid-column-renderer" gridState="{{ gridState }}" data="{{ col.columnData }}" repeat="{{ col in columns }}" template></th> |
| <th style="width:40px"> <span class="screen-reader">More info</span> |
| <paper-fab class="search-fab" focused icon="search" on-tap="{{ toggleSearchTools }}"></paper-fab> |
| </th> |
| </tr> |
| </thead> |
| <tbody> |
| <!-- quirk: Shadow Dom breaks parent-child relationships in HTML, this causes issues with |
| elements like table. Ideally we could have had <grid><grid-row><grid-cell> but we can't do |
| that yet since the tr and td rendered by <grid-row> <grid-cell> will be in shadow Dom and isolated. |
| Chromium bug: https://code.google.com/p/chromium/issues/detail?id=374315 |
| W3C Spec bug: https://www.w3.org/Bugs/Public/show_bug.cgi?id=15616 |
| --> |
| <tr is="ag-data-grid-row-renderer" repeat="{{ item in dataSourceResult }}" template> |
| <td is="ag-data-grid-cell-renderer" data="{{ col.columnData }}" repeat="{{ col in columns }}" style="text-align: {{ col.columnData.align }};" template> |
| <template ref="{{ col.cellTemplateId }}" bind></template> |
| </td> |
| <td class="info-column"> |
| <paper-icon-button on-click="{{ showMoreInfo }}" class="more-icon" icon="more-vert" title="more info"></paper-icon-button |
| > |
| </td> |
| </tr> |
| </tbody> |
| </table> |
| |
| <!-- Pagination --> |
| <template if="{{totalNumPages > 1}}"> |
| <div class="paginator"> |
| <paper-icon-button title="Previous page" icon="chevron-left" |
| class="{{ {invisible : pageNumber == 1} | tokenList }}" on-click="{{ previousPage }}"></paper-icon-button> |
| <span>Page {{ pageNumber }} of {{ totalNumPages }}</span> |
| <paper-icon-button title="Next page" icon="chevron-right" |
| class="{{ {invisible : onLastPage } | tokenList }}" on-click="{{ nextPage }}"></paper-icon-button> |
| </div> |
| </template> |
| |
| <!-- Dialog that displays all columns and their values when more info icon activated --> |
| <paper-action-dialog backdrop layered="false" id="dialog" heading="Details" transition="paper-dialog-transition-bottom"> |
| <template id="moreInfoTemplate" bind> |
| <div class="more-dialog-content"> |
| <template repeat="{{ item in selectedItems }}"> |
| <template repeat="{{ col in columns }}"> |
| <h3 class="heading">{{ col.columnData.label }}</h3> |
| <div class="details"><template ref="{{ col.cellTemplateId }}" bind></template></div> |
| </template> |
| </template> |
| </div> |
| </template> |
| <paper-button dismissive>Close</paper-button> |
| </paper-action-dialog> |
| |
| </template> |
| <script> |
| Polymer('ag-data-grid', { |
| /** |
| * DataSource is an object that has a fetch(search, sort, filters) method where |
| * search{key<string>} is current search keyword |
| * sort{key<string>, ascending<bool>} current sort key and direction |
| * filter{map{key<string>, values<Array>}} Map of filter keys to currently selected filter values |
| * search, sort and filters are provided by the grid control whenever they are changed by the user. |
| * DataSource is called automatically by the grid when user interacts with the component |
| * Grid does some batching of user actions and only calls fetch when needed in a requestAnimationFrame |
| * Keys provided for sort and filters correspond to keys set in the markup when constructing the grid. |
| * DataSource.fetch() is expected to return an array of filtered sorted results of the items. |
| * |
| * @attribute dataSource |
| * @type Object |
| * @default null |
| */ |
| dataSource: null, |
| |
| /** |
| * Summary for the grid. |
| * @attribute summary |
| * @type string |
| * @default '' |
| */ |
| summary: '', |
| |
| /** |
| * Initial sort key |
| * @attribute defaultSortKey |
| * @type string |
| * @default '' |
| */ |
| defaultSortKey: '', |
| |
| /** |
| * Initial sort direction |
| * @attribute defaultSortAscending |
| * @type boolean |
| * @default false |
| */ |
| defaultSortAscending: false, |
| |
| /** |
| * Number if items displayed in each page. |
| * @attribute pageSize |
| * @type integer |
| * @default 20 |
| */ |
| pageSize: 20, |
| |
| /** |
| * The minimum width that all columns can fix nicely in it. |
| * When screen is resized to less than this, responsive behaviour kicks in |
| * and starts hiding columns. |
| * @attribute responsiveWidth |
| * @type integer |
| * @default 768 |
| */ |
| responsiveWidth: 768, |
| |
| showMoreInfo: function(e) { |
| var item = e.target.templateInstance.model.item; |
| this.selectedItems = [item]; |
| var me = this; |
| setTimeout(function() { |
| me.toggleDialog(); |
| }); |
| }, |
| |
| toggleDialog: function() { |
| this.$.dialog.toggle(); |
| }, |
| |
| ready: function() { |
| // private property fields |
| this.columns = []; |
| this.pageNumber = 1; |
| //this.dataSource = null; |
| this.cachedDataSourceResult = []; |
| this.dataSourceResult = []; |
| this.gridState = { |
| sort: { |
| key: '', |
| ascending: false |
| }, |
| search: { |
| keyword: '' |
| }, |
| filters: {} |
| }, |
| |
| // set the default sort and direction on the state object |
| this.gridState.sort.key = this.defaultSortKey; |
| this.gridState.sort.ascending = this.defaultSortAscending; |
| |
| this.initTemplates(); // loads cell templates |
| this.initGridStateDependents(); // initialize filters and search |
| this.initGridStateObserver(); // observe changes to grid state by filters |
| |
| }, |
| |
| /* |
| * Called by Polymer when DOM is read. |
| * @private |
| */ |
| domReady: function() { |
| this.adjustFlexWidths(); |
| this.$.table.style.visibility = 'visible'; |
| }, |
| |
| /* |
| * Called by Polymer when dataSource attribute changes. |
| * @private |
| */ |
| dataSourceChanged: function() { |
| this.refresh(true); |
| }, |
| |
| /* |
| * Sets up an object observer to get any mutations on the grid state object. |
| * Filters or sortable columns can change the state and we like to refresh |
| * when changes happens. |
| * @private |
| */ |
| initGridStateObserver: function() { |
| var self = this; |
| for (key in this.gridState) { |
| var observer = new ObjectObserver(this.gridState[key]) |
| observer.open(function() { |
| // refresh the grid on any mutations and go back to page one |
| self.refresh(true); |
| }); |
| } |
| }, |
| |
| /* |
| * Copies the cell templates as defined by the user for each column into |
| * the grid so that we can reference them in a loop. |
| * quirk: Need to reference them by Id so a new Id is generated for each one |
| * Ids are scoped in the shadow DOM so no collisions. |
| * @private |
| */ |
| initTemplates: function() { |
| var self = this; |
| var columnNodes = this.querySelectorAll('[grid-column]'); |
| var totalFlex = 0; |
| for (var i = 0; i < columnNodes.length; i++) { |
| var col = columnNodes[i]; |
| var cellTemplate = col.querySelector('template'); |
| this.originalContext = cellTemplate.model; |
| var cellTemplateId = "userProvidedCellTemplate" + i; |
| cellTemplate.id = cellTemplateId; |
| this.$.templates.appendChild(cellTemplate); |
| totalFlex += col.flex; |
| col.origFlex = col.flex; |
| this.columns.push({ |
| cellTemplateId: cellTemplateId, |
| columnData: col |
| }); |
| } |
| |
| // add up the total value of flex attribute on each column and add it to data |
| this.columns.forEach(function(c) { |
| c.columnData.totalFlex = totalFlex; |
| }); |
| |
| // readjust the widths on resize |
| var previousTableWidth = self.$.table.offsetWidth; |
| onResizeHandler = function() { |
| var newWidth = self.$.table.offsetWidth; |
| if (newWidth != previousTableWidth && newWidth > 0) { |
| self.adjustFlexWidths(); |
| } |
| previousTableWidth = newWidth; |
| }; |
| |
| // quirks: since there is no good way to know if width changes, we pull. |
| // window.resize does not cover all resize cases |
| this.resizeInterval = setInterval(onResizeHandler, 50); |
| }, |
| |
| /* |
| * Called by Polymer when DOM is gone. We need to unbind custom event listeners here. |
| * @private |
| */ |
| detached: function() { |
| clearInterval(this.resizeInterval); |
| }, |
| |
| /* |
| * Provide the grid state to any component that expects it so they can mutate |
| * without the grid needing to know about them at all. |
| * @private |
| */ |
| initGridStateDependents: function() { |
| var gridStateDependents = this.querySelectorAll('[expects-grid-state]'); |
| for (var i = 0; i < gridStateDependents.length; i++) { |
| gridStateDependents[i].gridState = this.gridState; |
| } |
| }, |
| |
| /* |
| * Refreshed the grid by fetching the data again and updating the UI in the next render tick |
| * @param {bool} goBackToPageOne Optional parameter indicating that grid should go back |
| * to page 1 after refresh. false by default |
| */ |
| refresh: function(goBackToPageOne) { |
| var self = this; |
| requestAnimationFrame(function() { |
| if (goBackToPageOne) { |
| self.pageNumber = 1; |
| } |
| self.updateDataSource(); |
| }); |
| }, |
| |
| /* |
| * Performs responsive changes for the grid. |
| * Values of flex, minFlex and priority attributes on the grid column decides |
| * the responsive behavior. |
| * Grid assumes the original total number of columns can fit on a 768px width, |
| * if width of the grid container is less than that, then it starts to reduce |
| * flex values for each columns in reverse priority one by one until it reaches |
| * the minFlex value for all columns. |
| * If it still needs to reduce the width of the table at this point, it starts hiding |
| * columns in reverse priority order. |
| */ |
| adjustFlexWidths: function() { |
| var minWidth = this.responsiveWidth; |
| var tableWidth = this.$.table.offsetWidth; |
| |
| // reset to original flex values |
| for (var i = 0; i < this.columns.length; i++) { |
| var col = this.columns[i]; |
| col.columnData.flex = col.columnData.origFlex; |
| } |
| |
| if (tableWidth === 0 || tableWidth >= minWidth) { |
| return; |
| } |
| |
| // total of all flex values from all columns |
| var totalFlex = this.columns.reduce( function(prev, col) { |
| return prev + col.columnData.flex; |
| }, 0); |
| |
| // number of pixels per flex point |
| var pixelPerFlex = Math.floor(tableWidth / totalFlex); |
| // number of flex points we need to eliminate to same pixelPerFlex as the minWidth case |
| var numFlexToEliminate = Math.ceil((minWidth - tableWidth) / pixelPerFlex); |
| |
| // sort from least important to most important |
| var sortedColumnsData = this.columns.map(function(col) { |
| return col.columnData |
| }).sort(function(a, b) { |
| return b.priority - a.priority; |
| }); |
| |
| // first try to reduce each flex value until we hit min-flex for each column |
| var numElimintedFlex = 0 |
| var numIrreducableColumns = 0; |
| var numColumns = sortedColumnsData.length; |
| while (numElimintedFlex < numFlexToEliminate && numIrreducableColumns < numColumns) { |
| for (var i = 0; i < numColumns; i++) { |
| var col = sortedColumnsData[i]; |
| if (col.flex > col.minFlex) { |
| col.flex--; |
| numElimintedFlex++; |
| } else { |
| numIrreducableColumns++; |
| } |
| } |
| } |
| |
| // if still need to reduce, start eliminating whole columns based on priority |
| // never eliminate the top priority column, hence only iterate to numColumns - 1 |
| if (numElimintedFlex < numFlexToEliminate) { |
| for (var i = 0; i < numColumns - 1 && numElimintedFlex < numFlexToEliminate; i++) { |
| var col = sortedColumnsData[i]; |
| numElimintedFlex += col.flex; |
| col.flex = 0; |
| } |
| } |
| |
| // update the new totalFlex for each column |
| this.columns.forEach(function(c) { |
| c.columnData.totalFlex = totalFlex - numFlexToEliminate; |
| }); |
| }, |
| |
| /* |
| * dataSourceResult is what the UI binds to and integrate over. |
| * Only fetches data if scheduled to do so |
| * @private |
| */ |
| updateDataSource: function() { |
| if (!this.dataSource) { |
| return; |
| } |
| |
| // fetch the data |
| this.cachedDataSourceResult = this.dataSource.fetch( |
| this.gridState.search, |
| this.gridState.sort, |
| this.gridState.filters |
| ); |
| |
| // page the data |
| this.totalNumItems = this.cachedDataSourceResult.length; |
| // if there less data than current page number, go back to page 1 |
| if (this.totalNumItems < (this.pageNumber - 1) * this.pageSize) { |
| this.pageNumber = 1; |
| } |
| this.totalNumPages = Math.ceil(this.totalNumItems / this.pageSize); |
| this.onLastPage = this.totalNumPages == this.pageNumber; |
| |
| // skip and take |
| var startIndex = (this.pageNumber - 1) * this.pageSize; |
| var endIndex = startIndex + this.pageSize; |
| this.cachedDataSourceResult = this.cachedDataSourceResult.slice(startIndex, endIndex); |
| |
| this.dataSourceResult = this.cachedDataSourceResult; |
| }, |
| |
| /* |
| * collapse/show search and filter container. |
| * @private |
| */ |
| toggleSearchTools: function() { |
| this.$.searchTools.toggle(); |
| }, |
| |
| nextPage: function() { |
| if (!this.onLastPage) { |
| this.pageNumber++; |
| this.refresh(); |
| } |
| }, |
| |
| previousPage: function() { |
| if (this.pageNumber > 1) { |
| this.pageNumber--; |
| this.refresh(); |
| } |
| } |
| }); |
| </script> |
| </polymer-element> |