| <link rel="import" href="../../../../third-party/polymer/polymer.html"> |
| <link rel="import" href="../../../../third-party/paper-icon-button/paper-icon-button.html"> |
| <link rel="import" href="../../../../third-party/paper-dialog/paper-dialog.html"> |
| <link rel="import" href="../../../../third-party/paper-dialog/paper-dialog-transition.html"> |
| <link rel="import" href="../../../../third-party/paper-fab/paper-fab.html"> |
| <link rel="import" href="../../../../third-party/core-collapse/core-collapse.html"> |
| <link rel="import" href="row/renderer.html"> |
| <link rel="import" href="cell/renderer.html"> |
| <link rel="import" href="column/renderer.html"> |
| |
| <polymer-element name="p2b-grid" attributes="summary dataSource defaultSortKey defaultSortAscending pageSize"> |
| <template> |
| <link rel="stylesheet" href="../../../css/common-style.css"> |
| <link rel="stylesheet" href="component.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="p2b-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="p2b-grid-row-renderer" repeat="{{ item in dataSourceResult }}" template> |
| <td is="p2b-grid-cell-renderer" data="{{ col.columnData }}" repeat="{{ col in columns }}" 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="hardware:keyboard-arrow-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="hardware:keyboard-arrow-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-dialog 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 label="Close" dismissive></paper-button> |
| </paper-dialog> |
| |
| </template> |
| <script> |
| /* |
| * Reusable grid that can host search, filters and supports sortable columns and custom cell renderer |
| * @example usage: |
| |
| <p2b-grid defaultSortKey="firstName" |
| defaultSortAscending |
| dataSource="{{ myContactsDataSource }}" |
| summary="Displays your contacts in a tabular format"> |
| |
| <!-- Search contacts--> |
| <p2b-grid-search label="Search Contacts"></p2b-grid-search> |
| |
| <!-- Filter for circles --> |
| <p2b-grid-filter-select multiple key="circle" label="Circles"> |
| <p2b-grid-filter-select-item checked label="Close Friends" value="close"></p2b-grid-filter-select-item> |
| <p2b-grid-filter-select-item label="Colleagues" value="far"></p2b-grid-filter-select-item> |
| </p2b-grid-filter-select> |
| |
| <!-- Toggle to allow filtering by online mode--> |
| <p2b-grid-filter-toggle key="online" label="Show online only" checked></p2b-grid-filter-toggle> |
| |
| <!-- Columns, sorting and cell templates --> |
| <p2b-grid-column sortable label="First Name" key="firstName" /> |
| <template>{{ item.firstName }}</template> |
| </p2b-grid-column> |
| |
| <p2b-grid-column sortable label="Last Name" key="lastName" /> |
| <template> |
| <span style="text-transform:uppercase;"> |
| {{ item.lastName }} |
| </span> |
| </template> |
| </p2b-grid-column> |
| |
| <p2b-grid-column label="Circle" key="circle"/> |
| <template> |
| <img src="images\circls\{{ item.circle }}.jpg" alt="in {{ item.circle }} circle"><img> |
| </template> |
| </p2b-grid-column> |
| |
| </p2b-grid> |
| |
| * DataSource attribute expects an object that has a fetch(search, sort, filters) method. Please see |
| * documentation on DataSource property for details. |
| */ |
| Polymer('p2b-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. |
| */ |
| dataSource: null, |
| |
| /* |
| * Summary for the grid. |
| * @type {string} |
| */ |
| summary: '', |
| |
| /* |
| * Initial sort key |
| * @type {string} |
| */ |
| defaultSortKey: '', |
| |
| /* |
| * Initial sort direction |
| * @type {string} |
| */ |
| defaultSortAscending: false, |
| |
| /* |
| * Number if items displayed in each page. |
| * Defaults to 30 |
| * @type {integer} |
| */ |
| pageSize: 30, |
| |
| showMoreInfo: function(e) { |
| var item = e.target.templateInstance.model.item; |
| this.selectedItems = [item]; |
| this.$.dialog.opened = true; |
| }, |
| |
| ready: function() { |
| |
| // private property fields |
| this.columns = []; |
| this.shouldRefetchData = true; |
| this.pageNumber = 1; |
| this.dataSource = null; |
| this.cachedDataSourceResult = []; |
| 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.shouldRefetchData = true; |
| }); |
| }, |
| |
| /* |
| * 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 = 768; |
| 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 >= 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 |
| */ |
| get dataSourceResult() { |
| if (!this.shouldRefetchData || !this.dataSource) { |
| return this.cachedDataSourceResult; |
| } |
| |
| // 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.shouldRefetchData = false; |
| |
| return 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> |