blob: e4dff5d1fbf0926784a888bf2c6240d94b7f4d4b [file] [log] [blame]
<!--
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">&nbsp;<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>