├── app ├── .gitkeep ├── templates │ └── components │ │ └── fsg-list.hbs └── components │ └── fsg-list.js ├── addon ├── .gitkeep └── components │ └── fsg-list.js ├── vendor └── .gitkeep ├── tests ├── unit │ ├── .gitkeep │ └── components │ │ └── fsg-list-test.js ├── dummy │ ├── public │ │ ├── .gitkeep │ │ ├── robots.txt │ │ └── crossdomain.xml │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ └── .gitkeep │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── styles │ │ │ ├── .gitkeep │ │ │ └── app.css │ │ ├── views │ │ │ └── .gitkeep │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ ├── .gitkeep │ │ │ └── main.js │ │ ├── templates │ │ │ ├── .gitkeep │ │ │ ├── components │ │ │ │ └── .gitkeep │ │ │ ├── application.hbs │ │ │ ├── person.hbs │ │ │ └── main.hbs │ │ ├── router.js │ │ ├── app.js │ │ └── index.html │ ├── .jshintrc │ └── config │ │ └── environment.js ├── helpers │ ├── resolver.js │ └── start-app.js ├── test-helper.js ├── index.html └── .jshintrc ├── index.js ├── .bowerrc ├── testem.json ├── .travis.yml ├── .gitignore ├── bower.json ├── .editorconfig ├── Brocfile.js ├── package.json └── README.md /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{outlet}} 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'ember-cli-fsg-list' 3 | }; 4 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components", 3 | "analytics": false 4 | } 5 | -------------------------------------------------------------------------------- /app/templates/components/fsg-list.hbs: -------------------------------------------------------------------------------- 1 | {{#each item in fsgList}} 2 | {{partial itemPartial}} 3 | {{/each}} 4 | -------------------------------------------------------------------------------- /app/components/fsg-list.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import FSGList from 'ember-cli-fsg-list/components/fsg-list'; 3 | 4 | export default FSGList; 5 | -------------------------------------------------------------------------------- /tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember/resolver'; 2 | 3 | var resolver = Resolver.create(); 4 | 5 | resolver.namespace = { 6 | modulePrefix: 'dummy' 7 | }; 8 | 9 | export default resolver; 10 | -------------------------------------------------------------------------------- /testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "qunit", 3 | "test_page": "tests/index.html", 4 | "launch_in_ci": [ 5 | "PhantomJS" 6 | ], 7 | "launch_in_dev": [ 8 | "PhantomJS", 9 | "Chrome" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | var Router = Ember.Router.extend({ 4 | location: DummyENV.locationType 5 | }); 6 | 7 | Router.map(function() { 8 | this.route('main'); 9 | }); 10 | 11 | export default Router; 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | install: 11 | - npm install -g bower 12 | - npm install 13 | - bower install 14 | 15 | script: 16 | - npm test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components/* 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log 17 | testem.log 18 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 20px; 3 | } 4 | span.id { 5 | display: inline-block; 6 | width: 20px; 7 | } 8 | span.name { 9 | display: inline-block; 10 | width: 150px; 11 | } 12 | 13 | button { 14 | font-size: 15px; 15 | width: 200px; 16 | height: 40px; 17 | } 18 | 19 | .active { 20 | background: #337ab7; 21 | } 22 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/person.hbs: -------------------------------------------------------------------------------- 1 |
  • 2 | {{#if item._isTitle}} 3 | {{item._title}} 4 | {{else}} 5 |
    6 | {{item.id}} 7 | {{item.name}} 8 | {{item.occupation}} 9 |
    10 | {{/if}} 11 |
  • 12 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from 'ember/resolver'; 3 | import loadInitializers from 'ember/load-initializers'; 4 | 5 | Ember.MODEL_FACTORY_INJECTIONS = true; 6 | 7 | var App = Ember.Application.extend({ 8 | modulePrefix: 'dummy', // TODO: loaded via config 9 | Resolver: Resolver 10 | }); 11 | 12 | loadInitializers(App, 'dummy'); 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import resolver from './helpers/resolver'; 2 | import { 3 | setResolver 4 | } from 'ember-qunit'; 5 | 6 | setResolver(resolver); 7 | 8 | document.write('
    '); 9 | 10 | QUnit.config.urlConfig.push({ id: 'nocontainer', label: 'Hide container'}); 11 | if (QUnit.urlParams.nocontainer) { 12 | document.getElementById('ember-testing-container').style.visibility = 'hidden'; 13 | } else { 14 | document.getElementById('ember-testing-container').style.visibility = 'visible'; 15 | } 16 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dummy", 3 | "dependencies": { 4 | "handlebars": "~1.3.0", 5 | "jquery": "^1.11.1", 6 | "ember": "1.7.0", 7 | "ember-resolver": "~0.1.7", 8 | "loader": "stefanpenner/loader.js#1.0.1", 9 | "ember-cli-shims": "stefanpenner/ember-cli-shims#0.0.3", 10 | "ember-cli-test-loader": "rwjblue/ember-cli-test-loader#0.0.4", 11 | "ember-load-initializers": "stefanpenner/ember-load-initializers#0.0.2", 12 | "ember-qunit": "0.1.8", 13 | "ember-qunit-notifications": "0.0.4", 14 | "qunit": "~1.15.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/main.hbs: -------------------------------------------------------------------------------- 1 |

    Main

    2 |

    Filter: {{input value=filterTerm}}

    3 |

    4 | Order: 5 | 6 | 7 |

    8 | 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.css] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.html] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.md] 33 | trim_trailing_whitespace = false 34 | -------------------------------------------------------------------------------- /tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /tests/dummy/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": { 3 | "document": true, 4 | "window": true, 5 | "-Promise": true, 6 | "DummyENV": true 7 | }, 8 | "browser" : true, 9 | "boss" : true, 10 | "curly": true, 11 | "debug": false, 12 | "devel": true, 13 | "eqeqeq": true, 14 | "evil": true, 15 | "forin": false, 16 | "immed": false, 17 | "laxbreak": false, 18 | "newcap": true, 19 | "noarg": true, 20 | "noempty": false, 21 | "nonew": false, 22 | "nomen": false, 23 | "onevar": false, 24 | "plusplus": false, 25 | "regexp": false, 26 | "undef": true, 27 | "sub": true, 28 | "strict": false, 29 | "white": false, 30 | "eqnull": true, 31 | "esnext": true, 32 | "unused": true 33 | } 34 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Application from 'dummy/app'; 3 | import Router from 'dummy/router'; 4 | 5 | export default function startApp(attrs) { 6 | var App; 7 | 8 | var attributes = Ember.merge({ 9 | // useful Test defaults 10 | rootElement: '#ember-testing', 11 | LOG_ACTIVE_GENERATION: false, 12 | LOG_VIEW_LOOKUPS: false 13 | }, attrs); // but you can override; 14 | 15 | Router.reopen({ 16 | location: 'none' 17 | }); 18 | 19 | Ember.run(function() { 20 | App = Application.create(attributes); 21 | App.setupForTesting(); 22 | App.injectTestHelpers(); 23 | }); 24 | 25 | App.reset(); // this shouldn't be needed, i want to be able to "start an app at a specific URL" 26 | 27 | return App; 28 | } 29 | -------------------------------------------------------------------------------- /Brocfile.js: -------------------------------------------------------------------------------- 1 | /* global require, module */ 2 | 3 | var EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | var app = new EmberAddon(); 6 | 7 | // Use `app.import` to add additional libraries to the generated 8 | // output files. 9 | // 10 | // If you need to use different assets in different 11 | // environments, specify an object as the first parameter. That 12 | // object's keys should be the environment name and the values 13 | // should be the asset to use in that environment. 14 | // 15 | // If the library that you are including contains AMD or ES6 16 | // modules that you would like to import into your application 17 | // please specify an object with the list of modules as keys 18 | // along with the exports of each module as its value. 19 | 20 | module.exports = app.toTree(); 21 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{BASE_TAG}} 11 | 12 | 13 | 14 | 15 | 16 | 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | module.exports = function(environment) { 4 | var ENV = { 5 | environment: environment, 6 | baseURL: '/', 7 | locationType: 'auto', 8 | EmberENV: { 9 | FEATURES: { 10 | // Here you can enable experimental features on an ember canary build 11 | // e.g. 'with-controller': true 12 | } 13 | }, 14 | 15 | APP: { 16 | // Here you can pass flags/options to your application instance 17 | // when it is created 18 | } 19 | }; 20 | 21 | if (environment === 'development') { 22 | // ENV.APP.LOG_RESOLVER = true; 23 | ENV.APP.LOG_ACTIVE_GENERATION = true; 24 | // ENV.APP.LOG_TRANSITIONS = true; 25 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 26 | ENV.APP.LOG_VIEW_LOOKUPS = true; 27 | } 28 | 29 | if (environment === 'test') { 30 | ENV.baseURL = '/'; // Testem prefers this... 31 | } 32 | 33 | if (environment === 'production') { 34 | 35 | } 36 | 37 | return ENV; 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-cli-fsg-list", 3 | "version": "0.0.4", 4 | "directories": { 5 | "doc": "doc", 6 | "test": "test" 7 | }, 8 | "scripts": { 9 | "start": "ember server", 10 | "build": "ember build", 11 | "test": "ember test" 12 | }, 13 | "repository": "https://github.com/poetic/ember-cli-fsg-list", 14 | "engines": { 15 | "node": ">= 0.10.0" 16 | }, 17 | "keywords": [ 18 | "ember-addon", 19 | "filtered sorted grouped list component", 20 | "list component", 21 | "component" 22 | ], 23 | "ember-addon": { 24 | "configPath": "tests/dummy/config" 25 | }, 26 | "author": "Chun Yang", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "body-parser": "^1.2.0", 30 | "broccoli-asset-rev": "0.0.17", 31 | "broccoli-ember-hbs-template-compiler": "^1.6.1", 32 | "ember-cli": "0.0.44", 33 | "ember-cli-ember-data": "0.1.0", 34 | "ember-cli-ic-ajax": "0.1.1", 35 | "ember-cli-qunit": "0.0.5", 36 | "express": "^4.8.5", 37 | "glob": "^4.0.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{BASE_TAG}} 11 | 12 | 13 | 14 | 15 | 31 | 32 | 33 |
    34 |
    35 | 36 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "location", 6 | "setTimeout", 7 | "$", 8 | "-Promise", 9 | "QUnit", 10 | "define", 11 | "console", 12 | "equal", 13 | "notEqual", 14 | "notStrictEqual", 15 | "test", 16 | "asyncTest", 17 | "testBoth", 18 | "testWithDefault", 19 | "raises", 20 | "throws", 21 | "deepEqual", 22 | "start", 23 | "stop", 24 | "ok", 25 | "strictEqual", 26 | "module", 27 | "moduleFor", 28 | "moduleForComponent", 29 | "moduleForModel", 30 | "process", 31 | "expect", 32 | "visit", 33 | "exists", 34 | "fillIn", 35 | "click", 36 | "keyEvent", 37 | "triggerEvent", 38 | "find", 39 | "findWithAssert", 40 | "wait", 41 | "DS", 42 | "keyEvent", 43 | "isolatedContainer", 44 | "startApp", 45 | "andThen", 46 | "currentURL", 47 | "currentPath", 48 | "currentRouteName" 49 | ], 50 | "node": false, 51 | "browser": false, 52 | "boss": true, 53 | "curly": false, 54 | "debug": false, 55 | "devel": false, 56 | "eqeqeq": true, 57 | "evil": true, 58 | "forin": false, 59 | "immed": false, 60 | "laxbreak": false, 61 | "newcap": true, 62 | "noarg": true, 63 | "noempty": false, 64 | "nonew": false, 65 | "nomen": false, 66 | "onevar": false, 67 | "plusplus": false, 68 | "regexp": false, 69 | "undef": true, 70 | "sub": true, 71 | "strict": false, 72 | "white": false, 73 | "eqnull": true, 74 | "esnext": true 75 | } 76 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/main.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | var MainController = Ember.Controller.extend({ 4 | list: [ 5 | {id: 1 , name: 'Chun Yang' , occupation: 'Web Developer'} , 6 | {id: 2 , name: 'Dummy A' , occupation: 'Web Developer'} , 7 | {id: 3 , name: 'Dummy B' , occupation: 'QA'} , 8 | {id: 4 , name: 'Ellen Degeneres' , occupation: 'QA'} , 9 | {id: 5 , name: 'Jackie Chan' , occupation: 'Manager'} , 10 | ].map(function(obj){ 11 | return Ember.Object.create(obj); 12 | }), 13 | itemPartial: 'person', 14 | 15 | // ---------- Filter 16 | filterTerm: null, 17 | filterKeys: ['name'], 18 | filterFn: function(item){ 19 | return item.get('id') > this.get('filterTerm'); 20 | }, 21 | 22 | // ---------- Sort 23 | sortKeys: function(){ 24 | var orders = []; 25 | 26 | ['occupationOrder', 'nameOrder'].forEach(function(orderKey){ 27 | if(this.get(orderKey)){ 28 | orders.pushObject(orderKey.replace(/Order$/, ':') + this.get(orderKey)); 29 | } 30 | }.bind(this)); 31 | 32 | return orders; 33 | }.property('occupationOrder', 'nameOrder'), 34 | sortFn: function(a, b){ 35 | return b.get('id') - a.get('id'); 36 | }, 37 | 38 | // ---------- Group 39 | groupFn: function(item){ 40 | return item.occupation; 41 | }, 42 | 43 | actions: { 44 | toggleOrder: function(key){ 45 | if(this.get(key) === 'asc'){ 46 | this.set(key, 'desc'); 47 | } else { 48 | this.set(key, 'asc'); 49 | } 50 | }, 51 | clickItem: function(item){ 52 | var element = Ember.$('.id:contains("' + item.get('id') + '")'); 53 | element.parent().toggleClass('active'); 54 | }, 55 | } 56 | }); 57 | 58 | export default MainController; 59 | -------------------------------------------------------------------------------- /addon/components/fsg-list.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | // helpers 4 | var computed = { 5 | keys: function(sourceKey){ 6 | return function(){ 7 | var source = this.get(sourceKey); 8 | if(Ember.isArray(source) && source.length > 0) { 9 | return source; 10 | } 11 | }.property(sourceKey + '.[]'); 12 | }, 13 | 14 | fn: function(sourceKey){ 15 | return function(){ 16 | var source = this.get(sourceKey); 17 | if(typeof source === 'function') { 18 | return source; 19 | } 20 | }.property(sourceKey); 21 | }, 22 | }; 23 | 24 | var FilteredSortedGroupedListComponent = Ember.Component.extend({ 25 | list: [], 26 | 27 | // itemPartial 28 | itemPartial: '', 29 | 30 | // itemAction 31 | itemAction: null, 32 | actions: { 33 | _selectItem: function(){ 34 | // Bubble action up to the controller 35 | if(this.get('actionName')){ 36 | // Only for testing 37 | if(typeof DummyENV !== 'undefined') { 38 | if(DummyENV.environment === 'test' && window._counter >=0 ) { 39 | window._counter += 1; 40 | } 41 | } 42 | this.sendAction.bind(this, 'actionName').apply(this, arguments); 43 | } 44 | } 45 | }, 46 | 47 | // ---------- filter 48 | filterTerm: null, 49 | // filter can be a function of an array of list item keys 50 | // function : function(item, index, list) 51 | // keys : ['id', 'name'] 52 | filterBy: [], 53 | 54 | _filterKeys: computed.keys('filterBy'), 55 | _filterFn: computed.fn('filterBy'), 56 | _defaultFilterFn: function(item){ 57 | var purify = function(dirtyStr){ 58 | return dirtyStr.toLowerCase().replace(/\s+/g, ''); 59 | }; 60 | var getValue = function(key){ 61 | return item.get(key); 62 | }; 63 | var stack = purify(this.get('_filterKeys').map(getValue).join('')); 64 | var needle = purify(this.get('filterTerm')); 65 | return stack.indexOf(needle) > -1; 66 | }, 67 | 68 | _fList: function(){ 69 | var list = this.get('list').toArray(); 70 | 71 | if(!this.get('filterTerm')){ 72 | return list; 73 | } 74 | if(this.get('_filterKeys')) { 75 | return list.filter(this.get('_defaultFilterFn').bind(this)); 76 | } 77 | if(this.get('_filterFn')) { 78 | return list.filter(this.get('_filterFn').bind(this)); 79 | } 80 | return list; 81 | }.property('list.[]', 'filterTerm', '_filterKeys', '_filterFn'), 82 | 83 | // ---------- sort 84 | sortBy: [], 85 | 86 | _sortKeys: computed.keys('sortBy'), 87 | _sortFn: computed.fn('sortBy'), 88 | _defaultSortFn: function(a, b){ 89 | var compareValue; 90 | this.get('_sortKeys').some(function(metaKey){ 91 | var keys = metaKey.split(':'); 92 | var key = keys[0]; 93 | var asc = keys[1] === 'desc' ? -1 : 1; 94 | var propA = a.get(key); 95 | var propB = b.get(key); 96 | compareValue = Ember.compare(propA, propB) * asc; 97 | return compareValue; 98 | }); 99 | return compareValue || 0; 100 | }, 101 | 102 | _fsList: function(){ 103 | var list = this.get('_fList').toArray(); 104 | 105 | if(this.get('_sortKeys')) { 106 | return list.sort(this.get('_defaultSortFn').bind(this)); 107 | } 108 | if(this.get('_sortFn')) { 109 | return list.sort(this.get('_sortFn').bind(this)); 110 | } 111 | return list; 112 | }.property('_fList', '_sortKeys', '_sortFn'), 113 | 114 | // ---------- group 115 | groupBy: null, 116 | groupTitleAttrs: [], 117 | 118 | _fsgList: function(){ 119 | var groupBy = this.get('groupBy'); 120 | var list = this.get('_fsList').toArray(); 121 | 122 | if(!groupBy){ 123 | return list; 124 | } 125 | 126 | var groups = this._groupsFromList(list, groupBy); 127 | var fsgList = this._listFromGroups(groups); 128 | 129 | return fsgList; 130 | }.property('_fsList'), 131 | 132 | _groupsFromList: function(list, groupBy){ 133 | var addItemToGroup = function(groups, item){ 134 | var key = groupBy(item); 135 | if(groups[key]){ 136 | groups[key].push(item); 137 | } else { 138 | groups[key] = [item]; 139 | } 140 | return groups; 141 | }; 142 | 143 | return list.reduce(addItemToGroup, {}); 144 | }, 145 | 146 | _listFromGroups: function(groups){ 147 | var component = this; 148 | var list = []; 149 | for(var key in groups){ 150 | var titleObj = {_isTitle: true, _title: key}; 151 | component.get('groupTitleAttrs').forEach(function(attr){ 152 | var fn = component.get(attr); 153 | titleObj[attr] = fn(key); 154 | }); 155 | list.pushObject(titleObj); 156 | list.pushObjects(groups[key]); 157 | } 158 | return list; 159 | }, 160 | 161 | fsgList: Ember.computed.alias('_fsgList'), 162 | }); 163 | 164 | export default FilteredSortedGroupedListComponent; 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ember-cli-fsg-list 2 | ember-cli-fsg-list is an EmberCLI addon. 3 | It is a filterable, sortable, and groupable Ember component. 4 | You can also assign custom action to the items in the list. 5 | 6 | It is really helpful when you want to create something like this: 7 | 8 | ![](http://media.giphy.com/media/5xaOcLzHYY2LuCa8MDK/giphy.gif) 9 | 10 | # Example: 11 | - In your ember app, run: 12 | ```bash 13 | npm install ember-cli-fsg-list --save-dev 14 | ``` 15 | 16 | - In your controller 'app/controllers/demo.js': 17 | ```javascript 18 | var DemoController = Ember.Controller.extend({ 19 | // you MUST provide an Ember array of Ember object, E.G. a model of list 20 | list: [ 21 | {id: 1 , name: 'Chun Yang' , occupation: 'Web Developer'} , 22 | {id: 2 , name: 'Dummy A' , occupation: 'Web Developer'} , 23 | {id: 3 , name: 'Dummy B' , occupation: 'QA'} , 24 | {id: 4 , name: 'Ellen Degeneres' , occupation: 'QA'} , 25 | {id: 5 , name: 'Jackie Chan' , occupation: 'Manager'} , 26 | ].map(function(obj){ 27 | return Ember.Object.create(obj); 28 | }), 29 | 30 | // you MUST provide a partial for each item in the list 31 | itemPartial: 'person', 32 | 33 | // ---------- Filter(optional) 34 | filterTerm: null, // this is bounded to a input element in the template 35 | // you can assign an array OR a function to filterBy 36 | filterKeys: ['name'], 37 | filterFn: function(item){ 38 | return item.get('id') > this.get('filterTerm'); 39 | }, 40 | // if you do not provide a filterFn, the _defaultFilterFn is used: 41 | // E.G. 'a b' will match 'Abc', 'Dabc', 'De a b f' and so on. 42 | _defaultFilterFn: function(item){ 43 | var purify = function(dirtyStr){ 44 | return dirtyStr.toLowerCase().replace(/\s+/g, ''); 45 | }; 46 | var getValue = function(key){ 47 | return item.get(key); 48 | }; 49 | var stack = purify(this.get('_filterKeys').map(getValue).join('')); 50 | var needle = purify(this.get('filterTerm')); 51 | return stack.indexOf(needle) > -1; 52 | }, 53 | 54 | // ---------- Sort(optional) 55 | // you can assign an array OR a function to sortBy 56 | sortKeys: ['occupation', 'id:asc', 'name:desc'], 57 | sortFn: function(a, b){ 58 | return a.get('id') - b.get('id'); 59 | }, 60 | 61 | // ---------- Group(optional) 62 | // you can provide a function to groupBy 63 | groupFn: function(item){ 64 | return item.occupation; 65 | }, 66 | 67 | // you can provide custom attributes to the title item in a group 68 | // each attribute is a function with the group title obtained from groupFn 69 | // as argument 70 | groupTitleAttrs: ['titleImageUrl'], 71 | titleImageUrl: function(title){ 72 | return 'http://' + title + '.com'; 73 | }, 74 | 75 | // ---------- Actions(optional) 76 | actions: { 77 | logToConsole: function(item){ 78 | console.log('The item you clicked is: ', item); 79 | } 80 | } 81 | }); 82 | ``` 83 | 84 | - In your template 'app/templates/demo.hbs' 85 | ```javascript 86 | {{input value=filterTerm placeholder="name"}} 87 | 100 | ``` 101 | 102 | - In your partial template 'app/template/person.hbs' 103 | ```html 104 |
  • 105 | 106 | {{#if item._isTitle}} 107 | {{item._title}} 108 | 109 | {{else}} 110 | 111 |
    112 | {{item.id}} 113 | ({{item.occupation}}) 114 | {{item.name}} 115 |
    116 | {{/if}} 117 |
  • 118 | ``` 119 | 120 | # Component variables: 121 | - list: Ember array of Ember objects 122 | - itemPartial: string, template name 123 | - filterTerm: string 124 | - filterBy: array of strings OR function 125 | - sortBy: array of strings OR function 126 | - groupBy: a function 127 | - groupTitleAttrs: array of strings 128 | - actionName: string, action name in your controller 129 | 130 | # Emitted variables from the component to partial template: 131 | - item: Ember object, an object from the input list 132 | - item.\_isTitle: boolean, if this item is a group title 133 | - item.\_title: string, output of the group function 134 | - \_selectItem: function, used to bubble up the action to your controller 135 | -------------------------------------------------------------------------------- /tests/unit/components/fsg-list-test.js: -------------------------------------------------------------------------------- 1 | import { 2 | moduleForComponent, 3 | test 4 | } from 'ember-qunit'; 5 | import Ember from 'ember'; 6 | 7 | // list data used for testing; 8 | var list = [ 9 | {id: 1 , name: 'Chun Yang' , occupation: 'Web Developer'} , 10 | {id: 2 , name: 'Dummy A' , occupation: 'Web Developer'} , 11 | {id: 3 , name: 'Dummy B' , occupation: 'QA'} , 12 | {id: 4 , name: 'Ellen Degeneres' , occupation: 'QA'} , 13 | {id: 5 , name: 'Jackie Chan' , occupation: 'Manager'} , 14 | ].map(function(obj){ 15 | return Ember.Object.create(obj); 16 | }); 17 | list = Ember.A(list); 18 | 19 | moduleForComponent('fsg-list', 'FsgListComponent', { 20 | // specify the other units that are required for this test 21 | needs: ['template:person'], 22 | setup: function(){ 23 | var component = this.subject(); 24 | component.set('list', list); 25 | component.set('itemPartial', 'person'); 26 | }, 27 | }); 28 | 29 | test('it renders', function() { 30 | expect(2); 31 | 32 | // creates the component instance 33 | var component = this.subject(); 34 | equal(component._state, 'preRender'); 35 | 36 | // appends the component to the page 37 | this.append(); 38 | equal(component._state, 'inDOM'); 39 | }); 40 | 41 | test('it shows a list of partials', function(){ 42 | expect(5); 43 | 44 | this.append(); 45 | 46 | var items = $('.item'); 47 | list.forEach(function(item, index){ 48 | var name = items.eq(index).find('.name').text(); 49 | equal(name, item.get('name')); 50 | }); 51 | }); 52 | 53 | test('it should show new item when we add one', function(){ 54 | expect(2); 55 | 56 | var listCopy = Ember.copy(list); 57 | 58 | var component = this.subject(); 59 | component.set('list', listCopy); 60 | component.set('itemPartial', 'person'); 61 | this.append(); 62 | 63 | equal($('.item').length, 5); 64 | 65 | Ember.run(function(){ 66 | listCopy.pushObject(Ember.Object.create({ 67 | id: 100, name: 'Mike Chun', occupation: 'Web Designer' 68 | })); 69 | }); 70 | 71 | equal($('.item').length, 6); 72 | }); 73 | 74 | test('it should filter the list by filterTerm and filter(filterKeys)', function(){ 75 | expect(5); 76 | 77 | var component = this.subject(); 78 | component.set('filterTerm', 'Chun'); 79 | component.set('filterBy', ['name']); 80 | this.append(); 81 | 82 | var items = $('.item'); 83 | list.forEach(function(item){ 84 | var selector = '.name:contains("' + item.get('name') + '")'; 85 | var exist = !!items.find(selector).length; 86 | if(item.get('name') === 'Chun Yang'){ 87 | equal(exist, true); 88 | } else { 89 | equal(exist, false); 90 | } 91 | }); 92 | }); 93 | 94 | test('it should filter the list by filterTerm and filter(filterFn)', function(){ 95 | expect(5); 96 | 97 | var component = this.subject(); 98 | component.set('filterTerm', 'Chun'); 99 | component.set('filterBy', function(item){ 100 | return item.get('id') > 3; 101 | }); 102 | this.append(); 103 | 104 | var items = $('.item'); 105 | list.forEach(function(item){ 106 | var selector = '.id:contains("' + item.get('id') + '")'; 107 | var exist = !!items.find(selector).length; 108 | if(item.get('id') > 3){ 109 | equal(exist, true); 110 | } else { 111 | equal(exist, false); 112 | } 113 | }); 114 | }); 115 | 116 | test('it can be sorted by an array of strings', function(){ 117 | expect(2); 118 | 119 | var component = this.subject(); 120 | component.set('sortBy', ['occupation:desc', 'name:desc']); 121 | this.append(); 122 | 123 | var sequence = $('.item .id').text(); 124 | deepEqual(sequence, '21435'); 125 | 126 | Ember.run(function(){ 127 | component.set('sortBy', ['id']); 128 | }); 129 | 130 | sequence = $('.item .id').text(); 131 | deepEqual(sequence, '12345'); 132 | }); 133 | 134 | test('it can be sorted by a function', function(){ 135 | expect(1); 136 | 137 | var component = this.subject(); 138 | component.set('sortBy', function(a, b){ 139 | return b.get('id') - a.get('id'); 140 | }); 141 | this.append(); 142 | 143 | var sequence = $('.item .id').text(); 144 | deepEqual(sequence, '54321'); 145 | }); 146 | 147 | test('it can be grouped by a function', function(){ 148 | expect(3); 149 | 150 | var component = this.subject(); 151 | component.set('groupBy', function(item){ 152 | return item.get('occupation'); 153 | }); 154 | this.append(); 155 | 156 | equal($('.item').length, 8); 157 | equal($('.item .id').length, 5); 158 | equal($('.item .title').length, 3); 159 | }); 160 | 161 | test('it can be filtered, sorted and grouped', function(){ 162 | expect(2); 163 | 164 | var component = this.subject(); 165 | component.set('sortBy', ['name:desc']); 166 | component.set('filterBy', ['name', 'occupation']); 167 | component.set('filterTerm', 'm'); 168 | component.set('groupBy', function(item){ 169 | return item.get('occupation'); 170 | }); 171 | this.append(); 172 | 173 | equal($('.item .id').text(), '532', 'it is filtered and sorted'); 174 | equal($('.item .title').text().replace(/\s+/g, ''), 175 | 'ManagerQAWebDeveloper', 176 | 'it is grouped'); 177 | }); 178 | 179 | test('it can trigger an action when clicked', function(){ 180 | expect(1); 181 | 182 | window._counter = 0; 183 | var component = this.subject(); 184 | component.set('actionName', 'clickItem'); 185 | this.append(); 186 | 187 | $('.item div').first().click(); 188 | equal(window._counter, 1); 189 | }); 190 | --------------------------------------------------------------------------------