Skip to content

Instantly share code, notes, and snippets.

@alexspeller
Last active December 21, 2015 04:38
Show Gist options
  • Save alexspeller/6251054 to your computer and use it in GitHub Desktop.
Save alexspeller/6251054 to your computer and use it in GitHub Desktop.
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 '[email protected]', '[email protected]'
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
Copy link

adorum 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