Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Ember Table Extensions
# So, this is pretty horrible. If we just encode using btoa, any UTF-8 chars cause an error.
# If we use either of the workarounds on MDN[1], the £ sign is encoded wrong. I suspect
# Excel totally sucking at encodings is the reason why. So, the workaround is, to use
# the MDN workaround on chars with values > 255, and allow chars 0-255 to be encoded
# as is with btoa. Note that if you use either of the workarounds on MDN, chars
# 128-255 will be encoded as UTF-8, which includeds the £ sign. This will cause excel
# to choke on these chars. Excel will still choke on chars > 255, but at least the £
# sign works now...
# [1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Base64_encoding_and_decoding
App.encode64 = (str) ->
escaped = for i in [0...str.length]
if str.charCodeAt(i) > 255
unescape(encodeURIComponent(str.charAt(i)))
else
str.charAt(i)
btoa escaped.join('')
# The elsewhere handler will fire when anywhere outside the current view
# is clicked, e.g. to hide a popup when the background is clicked
Ember.ClickElsewhere = Ember.Mixin.create
onClickElsewhere: Ember.K
clickHandler: (->
@get('elsewhereHandler').bind @
).property()
elsewhereHandler: (event) ->
isThisElement = $(event.target).closest(@get('element')).length is 1
unless isThisElement
@onClickElsewhere event
didInsertElement: ->
@_super()
$(window).bind 'click', @get("clickHandler")
willDestroy: ->
$(window).unbind 'click', @get("clickHandler")
@_super()
class App.Compare
# a fast compare utility function
@fast: (ascending=true) ->
if ascending
(a, b) ->
if a < b then -1
else if a > b then 1
else 0
else
(b, a) ->
if a < b then -1
else if a > b then 1
else 0
$light-gray: #ccc;
$dark-gray: #666;
$accent-color: #66ccff;
.extended-table {
margin-top: 1em;
&>h2 {
float: left;
line-height: 1.4em;
}
.presentation-container {
max-width: 100%;
width: auto;
height: 400px;
clear: both;
a.csv-download {
float: right;
margin-top: 0.5em;
font-size: 0.8em;
}
}
.column-select, .column-group {
float: right;
position: relative;
margin-left: 1em;
.choose-columns, .group-column {
text-decoration: none;
color: black;
padding: 0.5em;
cursor: pointer;
float: right;
line-height: 1em;
border: 1px solid $light-gray;
span {
font-size: 0.8em;
color: $light-gray;
}
&.is-choosing-columns, &.is-choosing-group {
border-color: $accent-color;
span {
color: $accent-color;
}
}
margin-bottom: 1em;
}
ul, .radio-group {
z-index: 1;
position: absolute;
top: 3em;
right: 0;
background-color: white;
border: 1px solid $accent-color;
padding: 1em;
width: 15em;
@include border-radius(4px);
@include box-shadow($light-gray 0 0 15px);
label {
font-size: 0.8em;
}
input, label {
cursor: pointer;
}
li em {
font-size: 0.8em;
margin-bottom: 1em;
display: block;
text-align: center;
color: $dark-gray;
}
}
.radio-group label {
display: block;
}
}
.tables-container .table-container {
.table-scrollable-wrapper .table-block .table-row {
.table-cell.clickable {
cursor: pointer;
&:hover {
color: $accent-color;
}
}
}
.table-fixed-wrapper .table-block .table-row {
.table-cell.header-cell {
span.is-sortable {
cursor: pointer;
}
span.sort-direction {
color: $accent-color;
font-size: 0.9em;
padding: 0;
position: relative;
top: -1px;
&.inactive {
color: $light-gray;
}
}
span.filter-header-text {
&, & span {
line-height: 27px;
}
}
input.filter {
position: absolute;
left: 2px;
bottom: 2px;
right: 2px;
@include opacity(0.4);
&:focus, &:hover, &.is-populated {
@include opacity(1);
}
}
}
}
}
}
App.FilterField = Em.TextField.extend
classNames: ['filter']
classNameBindings: ['isPopulated']
type: 'search'
results: 0
attributeBindings: ['autofocus', 'results']
isPopulated: (->
!Em.isEmpty @get('value')
).property 'value'
keyDown: (event) ->
code = event.keyCode
action = switch code
when 38 then 'hilitePrev' # up arrow
when 40 then 'hiliteNext' # down arrow
else null
if action
@get('controller').send(action)
event.preventDefault()
cancel: ->
if (action = @get('cancelAction'))
@get('controller').send action, @
App.RadioButtonGroup = Em.View.extend
templateName: 'radio_button_group'
classNames: ['radio-group']
groupName: (->
"radiogroup-#{Em.guidFor(@)}"
).property()
change: ->
@set 'value', @$('input:checked').val()
updateSelected: (->
@$("input[value=#{@get('value')}]")[0].checked = true
).observes 'value'
didInsertElement: -> @updateSelected()
{{#each button in view.content}}
<label>
<input type="radio" {{bindAttr value="button.value"}} {{bindAttr name="view.groupName"}}/>
{{button.label}}
</label>
{{/each}}
<a href="#" {{action toggleColumnGroup}} {{bindAttr class=":group-column isChoosingGroup"}}>
Group by
{{groupColumn.headerCellName}}
<span>&#9660;</span>
</a>
{{#if isChoosingGroup}}
{{view App.RadioButtonGroup
contentBinding="groupRadioButtons"
valueBinding="groupColumnIndex"}}
{{/if}}
<a href="#" {{action toggleColumnSelect}} {{bindAttr class=":choose-columns isChoosingColumns"}}>
Choose columns
<span>&#9660;</span>
</a>
{{#if isChoosingColumns}}
<ul>
{{#if groupColumn}}
<li>
<em>Can't change columns when grouping</em>
</li>
{{/if}}
{{#each allColumns}}
<li>
<label>
{{view Em.Checkbox checkedBinding=isVisible
disabledBinding=isDisabled}}
{{headerCellName}}
</label>
</li>
{{/each}}
</ul>
{{/if}}
<span {{action sortByColumn view.content}} {{bindAttr class="view.isFilterable:filter-header-text view.isSortable"}}>
{{#if view.isSortable}}
<span {{bindAttr class=":sort-direction view.isSorting::inactive"}}>
{{#if view.isSortingAsc}}
&#9650;
{{/if}}
{{#if view.isSortingDesc}}
&#9660;
{{/if}}
{{#unless view.isSorting}}
&#9650;
{{/unless}}
</span>
{{/if}}
<span class="header-cell-name">{{view.content.headerCellName}}</span>
</span>
{{#if view.isFilterable}}
{{view App.FilterField valueBinding="view.content.filterString"
cancelAction="hideFilter"}}
{{/if}}
{{#if title}}
<h2>{{title}}</h2>
{{/if}}
{{#if hasColumnSelect}}
{{view App.Table.ColumnSelectView}}
{{/if}}
{{#if isGroupable}}
{{view App.Table.ColumnGroupView}}
{{/if}}
<div class="presentation-container">
{{view Ember.Table.TablesContainer}}
{{#if isDownloadable}}
<a href='#' {{action downloadCsv}} class='csv-download'>
{{#if isDownloading}}
Downloading - please wait&hellip;
{{else}}
Download as CSV / Excel
{{/if}}
</a>
{{/if}}
</div>
#= require util/compare
#= require click_elsewhere
#= require util/base64encode
App.Table = Ember.View.extend
contextBinding: 'controller'
templateName: 'table/table'
classNames: 'extended-table'
App.Table.ColumnSelectView = Em.View.extend Em.ClickElsewhere,
templateName: 'table/column_select'
classNames: ['column-select']
onClickElsewhere: ->
@set 'controller.isChoosingColumns', false
App.Table.ColumnGroupView = Em.View.extend Em.ClickElsewhere,
templateName: 'table/column_group'
classNames: ['column-group']
onClickElsewhere: ->
@set 'controller.isChoosingGroup', false
App.Table.SwankyHeaderCell = Ember.Table.HeaderCell.extend
templateName: 'table/swanky_header_cell'
isSortableBinding: 'content.isSortable'
isFilterableBinding: 'content.isFilterable'
sortAscendingBinding: 'controller.sortAscending'
isSorting: (->
@get('controller.sortColumn') is @get('content')
).property 'controller.sortColumn'
isSortingAsc: (->
@get('isSorting') and @get('sortAscending')
).property 'isSorting', 'sortAscending'
isSortingDesc: (->
@get('isSorting') and !@get('sortAscending')
).property 'isSorting', 'sortAscending'
App.Table.ClickableTableCell = Ember.Table.TableCell.extend
classNames: ['clickable']
click: (event) ->
@get('content').click @get('row')
App.Table.ColumnDefinition = Ember.Table.ColumnDefinition.extend
isSortable: false
isFilterable: false
isVisible: true
isGroupable: false
headerCellViewClass: 'App.Table.SwankyHeaderCell'
filterPresent: (->
!Em.isEmpty @get('filterString')
).property 'filterString'
observeFilterValue: (->
@controller.updateFilters @contentPath, @get('filterString')
).observes 'filterString'
isLastVisibleColumn: (->
@get('controller.columns.length') < 2 and @get('isVisible')
).property 'controller.columns.@each', 'isVisible'
isDisabled: (->
@get('controller.groupColumn')? or @get('isLastVisibleColumn')
).property 'controller.groupColumn', 'isLastVisibleColumn'
isGroupedColumn: (->
@get('controller.groupColumn') is @
).property 'controller.groupColumn'
App.SwankyTableController = Ember.Table.TableController.extend
# Extensions for general api improvement or multiple extra features
hasFooter: no
columnsByPath: {}
createColumn: (options) ->
col = App.Table.ColumnDefinition.create options
col.controller = @
if col.click?
col.tableCellViewClass = 'App.Table.ClickableTableCell'
col.contentPath ||= col.headerCellName.underscore()
@columnsByPath[col.contentPath] = col unless options.noPath
col
columns: (->
columns = if @get 'groupColumn'
@get 'groupColumns'
else
@get 'allColumns'
columns.filterProperty 'isVisible'
).property 'allColumns.@each.isVisible', 'groupColumns.@each.isVisible'
getCellContent: (key) ->
column = @columnsByPath[key]
column.getCellContent.bind column
# Extensions for downloading
downloadCsv: ->
@set 'isDownloading', true
Ember.run.later @, ->
# build the csv after a timeout
# to allow the ui time to update
@_doDownload()
@set 'isDownloading', false
, 300
_doDownload: ->
columns = @get('columns')
rows = []
# headers
headers = columns.mapProperty 'headerCellName'
# excel is amazingly stupid
# http://support.microsoft.com/kb/215591
if /^ID/.test headers[0]
headers[0] = " " + headers[0]
rows.push headers
# body
@get('sortedContent').forEach (row) ->
rows.push columns.map (column) ->
column.getCellContent row
csv = d3.csv.formatRows rows
a = document.createElement("a")
a.href = "data:text/csv;base64," + App.encode64(csv)
a.download = "App Data Download.csv"
document.body.appendChild a
a.click()
document.body.removeChild a
# Extensions for filtering
filters: {}
filterColumn: (view) ->
view.toggleProperty 'isFiltering'
unless view.get 'isFiltering'
view.set 'content.filterString', null
hideFilter: (filterView) ->
filterView.get('parentView').toggleProperty 'isFiltering'
updateFilters: (path, value) ->
if Em.isEmpty value
delete @filters[path]
else
@filters[path] = value.toLowerCase()
@set 'filtersKey', $.param(@filters)
filteredContent: (->
if Em.isEmpty(Em.keys(@filters)) or @get('groupColumn')?
return @get('groupedContent').toArray()
@get('groupedContent').filter (row) =>
shouldInclude = true
for own key, value of @filters
cellContent = @getCellContent(key)(row)
unless ~cellContent.toString().toLowerCase().indexOf(value)
shouldInclude = false
shouldInclude
).property 'groupedContent.@each', 'filtersKey', 'groupColumn'
# Extensions for column selection
hasColumnSelect: true
toggleColumnSelect: ->
@toggleProperty 'isChoosingColumns'
init: ->
if @get('hasColumnSelect') and !@get('allColumns')?
throw "Please specify allColumns not columns so that column hiding works"
firstSortable = @get('columns').findProperty 'isSortable'
@sortByColumn firstSortable if firstSortable?
@_super()
# Extensions for sorting
sortByColumn: (column) ->
return unless column.isSortable
if column is @get('sortColumn')
@toggleProperty 'sortAscending'
else
@set 'sortAscending', !column.initialSortDesc
@setProperties
sortColumn: column
_tableScrollTop: 0
getCellSort: (column) ->
if column.getSortValue
column.getSortValue.bind column
else
column.getCellContent.bind column
sortedContent: (->
return @get 'filteredContent' if Em.isEmpty @get('sortColumn')
getSortValue = @getCellSort @get('sortColumn')
compare = App.Compare.fast @get('sortAscending')
result = @get('filteredContent').sort (a, b) ->
compare getSortValue(a), getSortValue(b)
result
).property 'filteredContent.@each', 'sortColumn', 'sortAscending'
bodyContent: (->
@_super().set 'content', @get('sortedContent')
).property 'content', 'tableRowClass', 'sortedContent.@each'
# Extensions for grouping
groupColumnIndex: 'none'
isGroupable: (->
@get('allColumns').findProperty 'isGroupable'
).property 'allColumns.@each'
groupColumns: (->
column = @get('groupColumn')
return [] unless column?
group = @createColumn
isSortable: true
headerCellName: column.get('headerCellName')
contentPath: 'groupValue'
getSortValue: (row) -> row.get('sortValue')
count = @createColumn
isSortable: true
headerCellName: "Count"
contentPath: 'countValue'
columns = [group, count]
@get('allColumns').forEach (column) =>
if column.groupValue?
columns.push @createColumn
noPath: true
headerCellName: column.get('headerCellName')
isSortable: true
getSortValue: (row) ->
if column.groupSortValue?
column.groupSortValue(row.rowsValue || [])
else
@getCellContent row
getCellContent: (row) ->
column.groupValue(row.rowsValue || [])
columns
).property 'allColumns.@each', 'groupColumn'
groupedContent: (->
column = @get('groupColumn')
return @get 'content' unless column?
sortFunc = @getCellSort column
sortValues = {}
groups = _(@get('content').toArray()).groupBy (row) =>
key = column.getCellContent row
sortValues[key] ||= sortFunc(row)
key
Em.keys(groups).map (key) =>
rows = groups[key]
Em.Object.create
groupValue: key
sortValue: sortValues[key]
countValue: rows.length
rowsValue: rows
).property 'content.@each', 'groupColumn'
groupableColumns: (->
@get('allColumns').filterProperty 'isGroupable'
).property 'allColumns.@each'
groupRadioButtons: (->
buttons = [{label: "None", value: 'none'}]
@get('groupableColumns').forEach (column, i) ->
buttons.push
label: column.get('headerCellName')
value: i
buttons
).property 'groupableColumns.@each'
groupColumn: (->
index = parseInt @get('groupColumnIndex'), 10
return null if isNaN index
@get('groupableColumns')[index]
).property 'groupColumnIndex'
toggleColumnGroup: ->
@toggleProperty 'isChoosingGroup'
#Usage example:
# In the template:
# {{view App.Table controllerBinding="tableController"}}
# define your table controller like this:
App.MyTableController = App.SwankyTableController.extend
isDownloadable: true
# Note you have to define allColumns not columns now
allColumns: (->
col1 = @createColumn
isSortable: true
isFilterable: true
isGroupable: true
columnWidth: 75
headerCellName: 'Column 1'
contentPath: 'somePath.toTheContent'
col2 = @createColumn
isSortable: true
isFilterable: true
columnWidth: 100
headerCellName: 'Column 2'
contentPath: 'somePath'
# clicking on a cell
click: (theContentOfTheCell) ->
@controller.transitionToRoute 'aRoute', theContentOfTheCell
col3 = @createColumn
isFilterable: true
isSortable: true
# The row will sort by this value
getSortValue: (row) -> (row.get('title') || "").toLowerCase()
# contentPath is just a shortcut for getCellContent: (row) -> row.get(path)
contentPath: 'title'
columnWidth: 200
headerCellName: 'Title'
col4 = @createColumn
columnWidth: 100
headerCellName: "Total"
isSortable: true
initialSortDesc: true
getSortValue: (row) -> row.get('total')
getCellContent: (row) -> moneyFormat(row.get('total'))
groupSortValue: (rows) ->
# given a set of grouped rows, how should you sort that group
_(rows).sum (row) -> row.get('total')
groupValue: (rows) ->
# Given a set of grouped rows, how should you display that group
moneyFormat @groupSortValue(rows)
[col1, col2, col3, col4]
).property()
# In the controller:
tableController: (->
App.MyTableController.create
title: 'My Title'
target: @
contentBinding: 'target.tableRows'
).property()
tableRows: (->
# This should return the objects for the table
[
foo: 1, total: 2
,
foo: 1, total: 2
]
).property()
@adorum

This comment has been minimized.

Copy link

commented May 22, 2015

Hi, I am very interested in your Ember table extensions but I am not very familiar with coffeescript. How should I proceed to use the extension. Currently I am using Ember table as it is, just ember-table.js and ember-table.css grunt dist output. What is a way to put this 2 things together, your extensions and ember table?
Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.