import Ember from 'ember';
/**
* ## Basic Usage
* - `columns` - Controller array to setup the table headers/columns (detailed below).
- `modelName` - for the component to make the proper request when filtering/sorting, you must pass the model type matching your Ember model structure. e.g. brand/diagram, product.
- `record` - this is bound to the controller and is used to iterate over the table's model data.
* ### Template
```hbs
{{! app/templates/my-route.hbs }}
{{#ember-tabular columns=columns modelName="user" record=users as |section|}}
{{#if section.isBody}}
{{#each users as |row|}}
<tr>
<td>{{row.username}}</td>
<td>{{row.emailAddress}}</td>
<td>{{row.firstName}}</td>
<td>{{row.lastName}}</td>
<td>
{{#link-to "index" class="btn btn-xs" role="button"}}
Edit
{{/link-to}}
</td>
</tr>
{{/each}}
{{/if}}
{{/ember-tabular}}
```
* ### Controller
* Setup the columns array, which is how the table headers are constructed, `label` is required in all cases.
```js
// app/controllers/my-route.js
export default Ember.Controller.extend({
users: null,
columns: [
{
property: 'username',
label: 'Username',
defaultSort: 'username',
},
{
property: 'emailAddress',
label: 'Email',
},
{
property: 'firstName',
label: 'First Name',
},
{
property: 'lastName',
label: 'Last Name',
},
{
property: 'updatedAt',
label: 'Last Updated',
type: 'date',
},
],
});
```
*
* @class EmberTabular
*/
export default Ember.Component.extend({
store: Ember.inject.service('store'),
action: null,
classNames: ['ember-tabular'],
/**
* Component will attempt to make a request to fetch the data.
*
* @property makeRequest
* @type Boolean
* @default true
*/
makeRequest: true,
/**
* Used to toggle the filter row bar.
*
* @property showFilterRow
* @type Boolean
* @default false
*/
showFilterRow: false,
/**
* @property sortableClass
* @type String
* @default 'sortable'
*/
sortableClass: 'sortable',
/**
* @property tableWrapperClass
* @type String
* @default ''
*/
tableWrapperClass: '',
/**
* @property tableClass
* @type String
* @default 'table-bordered table-hover'
*/
tableClass: 'table-bordered table-hover',
/**
* @property paginationWrapperClass
* @type String
* @default ''
*/
paginationWrapperClass: '',
/**
* Once the `isRecordLoaded` is determined if true and no data exists then this is displayed.
*
* @property tableLoadedMessage
* @type String
* @default 'No Data.'
*/
tableLoadedMessage: 'No Data.',
/**
* Computed Property to determine the column length dependent upon `columns`.
*
* @property columnLength
* @param columns {Array}
* @return {Number}
*/
columnLength: Ember.computed('columns', function () {
return this.get('columns').length;
}),
// Allows multiple yields
header: {
isHeader: true,
},
body: {
isBody: true,
},
footer: {
isFooter: true,
},
/**
* Model to be requested using `makeRequest: true`.
*
* @property modelName
* @type String
* @default null
*/
modelName: null,
/**
* This is typically bound to the controller and is used to iterate over the table's model data.
*
* @property record
* @type Object
* @default null
*/
record: null,
/**
* This is typically setup on the controller and passed into the component, and is used to construct the table headers/filtering.
*
```js
export default Ember.Controller.extend({
users: null,
columns: [
{
property: 'username',
label: 'Username',
defaultSort: 'username',
},
{
property: 'emailAddress',
label: 'Email',
},
{
property: 'firstName',
label: 'First Name',
sort: false,
},
{
property: 'isAdmin',
label: 'Is Admin',
list: [
{
label: 'Yes',
value: true,
},
{
label: 'No',
value: false,
}
],
},
{
property: 'updatedAt',
label: 'Last Updated',
type: 'date',
},
{
label: 'Actions',
},
],
});
```
*
- `columns.property` - {String}
- Required for column filtering/sorting
- Properties should be in camelCase format
- `columns.label` - {String}
- Required in all use-cases
- `columns.type` - {String} - Default: text
- Sets the filter `<input type="">`
- `columns.sort` - {Boolean} - Default: `true`
- Required for column sorting
- `columns.list` - {Array} - Default: `null` - Filtering the column based on a dropdown list.
- `list.label` - Displayed to the user for selection.
- `list.value` - Value that is sent in the request.
- `columns.defaultSort` - {String}
- Initial sort value for API request
- Will be overridden with any sorting changes
*
* @property columns
* @type Array
* @default null
*/
columns: null,
// pagination defaults
/**
* @property page
* @type Number
* @default 1
*/
page: 1,
/**
* Used in request to construct pagination.
*
* @property limit
* @type Number
* @default 10
*/
limit: 10,
/**
* Number passed to the pagination add-on.
*
* @property pageLimit
* @type Number
* @default 0
*/
pageLimit: 0,
/**
* Used in request to construct pagination.
*
* @property offset
* @type Number
* @default 0
*/
offset: 0,
/**
* @property sort
* @type String
* @default null
*/
sort: null,
/**
* @property filter
* @type Object
* @default null
*/
filter: null,
/**
* Object to pass in static query-params that will not change based on any filter/sort criteria.
* Additional table-wide filters that need to be applied in all requests. Typically bound to the controller.
```js
// app/controllers/location.js
export default Ember.Controller.extend({
staticParams: Ember.computed('model', function() {
return {
'filter[is-open]': '1',
include: 'hours',
};
}),
...
});
```
```hbs
{{! app/templates/my-route.hbs }}
{{#ember-tabular columns=columns modelName="user" record=users staticParams=staticParams as |section|}}
...
{{/ember-tabular}}
```
*
* @property staticParams
* @type Object
* @default null
*/
staticParams: null,
// State flags
/**
* @property isSuccess
* @type Boolean
* @default false
*/
isSuccess: false,
/**
* @property isFailure
* @type Boolean
* @default false
*/
isFailure: false,
/**
* @property isLoading
* @type Boolean
* @default false
*/
isLoading: false,
/**
* @property defaultSuccessMessage
* @type String
* @default 'Success!'
*/
defaultSuccessMessage: 'Success!',
/**
* @property defaultFailureMessage
* @type String
* @default 'There was an issue. Please check below for errors.'
*/
defaultFailureMessage: 'There was an issue. Please check below for errors.',
// Messages
successMessage: Ember.get(Ember.Component, 'defaultSuccessMessage'),
failureMessage: Ember.get(Ember.Component, 'defaultFailureMessage'),
// For pushing any per field errors
/**
* Conforms to json:api spec: http://jsonapi.org/format/#errors
*
* @property errors
* @type Array
* @default null
*/
errors: null,
/**
* Used to serialize the parameters within `request`.
*
* @method serialize
* @param params {Object} An object of query parameters.
* @return params {Object} The serialized query parameters.
*/
serialize(params) {
// Serialize Pagination
params = this.serializePagination(params);
// Serialize Filter
params = this.serializeFilter(params);
// Serialize Sort
params = this.serializeSort(params);
return params;
},
/**
* Transform params related to pagination into API expected format.
* Follows json:api spec by default: http://jsonapi.org/format/#fetching-pagination.
*
* `offset` => `?page[offset]`.
*
* `limit` => `?page[limit]`.
*
* If you are not using Ember Data then you can extend this addon's component and override a set of serialize and normalized methods:
```js
import EmberTabular from 'ember-tabular/components/ember-tabular';
export default EmberTabular.extend({
serializePagination(params) {
// override default pagination ?page[offset]= and ?[page]limit=
// offset and limit will be sent as ?offset= and ?limit=
params.offset = (params.page * params.limit) - params.limit;
if (isNaN(params.offset)) {
params.offset = null;
}
return params;
},
});
```
*
* @method serializePagination
* @param params {Object} An object of query parameters.
* @return params {Object} The serialized pagination query parameters.
*/
serializePagination(params) {
// Override to set dynamic offset based on page and limit
params.offset = (params.page * params.limit) - params.limit;
if (isNaN(params.offset)) {
params.offset = null;
}
// Support json api page[offset]/page[limit] spec
params.page = {};
params.page.limit = params.limit;
delete params.limit;
params.page.offset = params.offset;
delete params.offset;
return params;
},
/**
* Transform params related to filtering into API expected format.
* Follows json:api spec by default: http://jsonapi.org/recommendations/#filtering.
* `?filter[lastName]` => `?filter[last-name]`.
*
* @method serializeFilter
* @param params {Object} An object of query parameters.
* @return params {Object} The serialized filter query parameters.
*/
serializeFilter(params) {
// serialize filter query params
const filter = params.filter;
for (let key in filter) {
if (filter.hasOwnProperty(key)) {
const value = filter[key];
const serializedKey = this.serializeProperty(key);
// delete unserialized key
delete filter[key];
key = serializedKey;
filter[key] = value;
}
}
return params;
},
/**
* Transform params related to sorting into API expected format.
* Follows json:api spec by default: http://jsonapi.org/format/#fetching-sorting.
* `?sort=lastName` => `?sort=last-name`.
*
* @method serializeSort
* @param params {Object} An object of query parameters.
* @return params {Object} The serialized sort query parameters.
*/
serializeSort(params) {
params.sort = this.serializeProperty(params.sort);
return params;
},
/**
* Follows json:api dasherized naming.
* `lastName` => `last-name`.
*
* If you are not supporting json:api's dasherized properties this can be extended to support other conventions:
```js
import EmberTabular from 'ember-tabular/components/ember-tabular';
export default EmberTabular.extend({
serializeProperty(property) {
// Override to convert all properties sent in requests to camelize instead of the default dasherized
// ?filter[lastName]&sort=isAdmin
// (pseudo code)
if (property) {
return Ember.String.camelize(property);
}
return null;
},
});
```
*
* @method serializeProperty
* @param property {String}
* @return property {String}
*/
serializeProperty(property) {
if (property) {
return Ember.String.dasherize(property);
}
return null;
},
/**
* Used to normalize query parameters returned from `request` to components expected format.
*
* @method normalize
* @param data {Object} Data object returned from request.
* @param params {Object} The returned object of query parameters.
* @return data {Object}
*/
normalize(data, params) {
// Normalize Pagination
data = this.normalizePagination(data, params);
// Normalize Filter
data.query = this.normalizeFilter(data.query);
// Normalize Sort
data.query = this.normalizeSort(data.query);
return data;
},
/**
* Used to normalize pagination related query parameters returned from `request` to components expected format.
* `?page[offset]` => `offset`.
* `?page[limit]` => `limit`.
*
* @method normalizePagination
* @param data {Object} Data object returned from request.
* @param params {Object} The returned object of query parameters.
* @return data {Object}
*/
normalizePagination(data, params) {
// pagination - return number of pages
const pageLimit = Math.ceil(data.meta.total / params.page.limit);
// determine if pageLimit is a valid number value
if (isFinite(pageLimit)) {
this.set('pageLimit', pageLimit);
} else {
this.set('pageLimit', null);
}
return data;
},
/**
* Used to normalize filter related query parameters returned from `request` to components expected format.
* `?filter[last-name]` => `filter[lastName]`.
* `?filter[user.first-name]` => `filter[user.firstName]`.
*
* @method normalizeFilter
* @param query {Object} The returned object of query parameters.
* @return query {Object}
*/
normalizeFilter(query) {
// normalize filter[property-key]
// into filter[propertyKey]
let filter = query.filter;
for (let key in filter) {
if (filter.hasOwnProperty(key)) {
const value = filter[key];
const propertySegments = this.segmentProperty(key);
let normalizedKey;
// handle/retain dot notation relationships `property.propertyName`
propertySegments.forEach(function(el, i, normalizedSegments) {
normalizedSegments[i] = this.normalizeProperty(propertySegments[i]);
}.bind(this));
// join segments to create normalizedProperty
normalizedKey = propertySegments.join('.');
// delete unserialized key
delete filter[key];
key = normalizedKey;
filter[key] = value;
}
}
return query;
},
/**
* Used to normalize sort related query parameters returned from `request` to components expected format.
* Expects json:api by default.
*
* @method normalizeSort
* @param query {Object} The returned object of query parameters.
* @return query {Object}
*/
normalizeSort(query) {
return query;
},
/**
* Used to normalize properties to components expected format.
* By default this will camelize the property.
*
* @method normalizeProperty
* @param property {String}
* @return property {String}
*/
normalizeProperty(property) {
if (property) {
return Ember.String.camelize(property);
}
return null;
},
/**
* @method segmentProperty
* @param property {String}
* @return segments {Array}
*/
segmentProperty(property) {
let segments = property.split('.');
return segments;
},
/**
* Determine if `record` is loaded using a number of different property checks.
*
* @property isrecordLoaded
* @type Function
*/
isrecordLoaded: Ember.computed('errors', 'record', 'record.isFulfilled', 'record.isLoaded',
'modelName', function () {
// If record array isLoaded but empty
if (this.get('record.isLoaded')) {
return true;
}
// If record.content array loaded is empty
if (this.get('record.isFulfilled')) {
return true;
}
// If errors
if (this.get('errors')) {
return true;
}
// If record array is empty
if (this.get('record') && this.get('record').length === 0) {
return true;
}
// Show custom tableLoadedMessage
if (this.get('record') === null && this.get('modelName') === null) {
return true;
}
return false;
}),
/**
* Used in templates to determine if table header will allow filtering.
*
* @property isColumnFilters
* @type Boolean
* @return {Boolean}
* @default false
*/
isColumnFilters: Ember.computed('columns', function () {
const columns = this.get('columns');
for (let i = columns.length - 1; i >= 0; i--) {
if (columns[i].hasOwnProperty('property')) {
return true;
}
}
return false;
}),
/**
* Runs on init to setup the table header default columns.
*
* @method setColumnDefaults
*/
setColumnDefaults: Ember.on('init', function () {
this.get('columns').map(function (column) {
// if column does not have a sort property defined set to true
if (!column.hasOwnProperty('sort')) {
Ember.set(column, 'sort', true);
}
// if column does not have a type property defined set to text
if (!column.hasOwnProperty('type')) {
Ember.set(column, 'type', 'text');
}
});
}),
/**
* Runs on init to set the default sort param.
*
* @method defaultSort
*/
defaultSort: Ember.on('init', function () {
this.get('columns').map(function (el) {
if (el.hasOwnProperty('defaultSort')) {
this.set('sort', el.defaultSort);
}
}.bind(this));
}),
/**
* Constructs the query object to be used in `request`.
*
* @property query
* @type Object
* @return {Object}
*/
query: Ember.computed('page', 'limit', 'offset', 'sort', 'filter.@each.value',
'staticParams', function () {
let query = {};
const filter = this.get('filter') || [];
query = {
page: this.get('page'),
limit: this.get('limit'),
offset: this.get('offset'),
sort: this.get('sort'),
filter: filter.reduce((memo, filter) => Ember.merge(memo, {
[filter.field]: filter.value,
}), {}),
};
// Merge staticParams/query into query
Ember.merge(query, this.get('staticParams'));
return query;
}),
/**
* Make request to API for data.
*
* @method request
* @param params {Object} Serialized query parameters.
* @param modelName {String}
*/
request(params, modelName) {
params = this.serialize(params);
return this.get('store').query(modelName, params).then(
function (data) {
if (!this.isDestroyed || !this.isDestroying) {
data = this.normalize(data, params);
this.set('isLoading', false);
this.set('record', data);
}
}.bind(this),
function (errors) {
if (!this.isDestroyed || !this.isDestroying) {
this.failure(errors);
}
}.bind(this)
);
},
/**
* Sets the `record` after the `request` is resolved.
*
* @method setModel
*/
setModel: Ember.on('init', Ember.observer('query', 'makeRequest', function () {
Ember.run.once(this, function () {
// If makeRequest is false do not make request and setModel
if (this.get('makeRequest')) {
this.reset();
this.set('isLoading', true);
const modelName = this.get('modelName');
const params = this.get('query');
return this.request(params, modelName);
}
});
})),
actions: {
sortBy(property) {
this.setSort(property);
this.updateSortUI(property);
},
toggleFilterRow() {
this.toggleProperty('showFilterRow');
},
},
/**
* Sets the active sort property.
*
* @method setSort
* @param sortProperty {String}
*/
setSort: Ember.on('didInsertElement', function (sortProperty) {
if (this.get('sort') || sortProperty) {
let property;
if (sortProperty) {
property = sortProperty;
} else {
property = this.get('sort').replace(/^-/, '');
// Must be the opposite of property
sortProperty = `-${property}`;
}
property = property;
if (this.get('sort') === sortProperty) {
this.set('sort', `-${property}`);
} else {
this.set('sort', property);
}
}
}),
/**
* Sets the proper classes on table headers when sorting.
*
* @method updateSortUI
* @param sortProperty {String}
*/
updateSortUI: Ember.on('didInsertElement', function (sortProperty) {
if (this.get('sort') || sortProperty) {
const _this = this;
const $table = this.$();
const sort = this.get('sort');
let property,
classProperty,
$tableHeader;
// convert property to camelCase
property = sort.replace(/^-/, '');
// convert relationships
classProperty = property.replace(/\./g, '-');
$tableHeader = Ember.$(`#${classProperty}`);
// Remove all classes on th.sortable but sortable class
$table.find('th').removeClass(function (i, group) {
const list = group.split(' ');
return list.filter(function (val) {
return (val !== _this.get('sortableClass') && val !== 'filterable');
}).join(' ');
});
if (sort.charAt(0) === '-') {
$tableHeader.addClass('sort-desc');
} else {
$tableHeader.addClass('sort-asc');
}
}
}),
/**
* @method failure
* @param response {Object}
*/
failure(response) {
this.reset();
this.setProperties({
isFailure: true,
pageLimit: null,
});
// Set per field errors if found
if ('errors' in response) {
this.set('errors', response.errors);
}
},
/**
* Resets all state specific properties.
*
* @method reset
*/
reset() {
this.setProperties({
isLoading: false,
errors: null,
isSuccess: false,
isFailure: false,
successMessage: this.get('defaultSuccessMessage'),
failureMessage: this.get('defaultFailureMessage'),
});
},
});