Last active
December 21, 2015 04:38
-
-
Save alexspeller/6251054 to your computer and use it in GitHub Desktop.
Ember Table Extensions
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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('') | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$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); | |
} | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, @ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#= 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' | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.