├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── css ├── _react-virtualized-styles.scss └── style.scss ├── dist ├── propersearch.css ├── propersearch.js ├── propersearch.min.css └── propersearch.min.js ├── examples ├── dist │ └── app.js ├── favicon.ico ├── index.html ├── jsx │ ├── app.js │ └── example.js └── screenshots │ └── example.png ├── index.html ├── karma.conf.js ├── lib ├── components │ ├── __tests__ │ │ ├── search-test.js │ │ └── searchList-test.js │ ├── search.js │ └── searchList.js ├── lang │ └── messages.js ├── lib │ └── cache.js ├── propersearch.js └── utils │ └── normalize.js ├── package.json ├── src ├── css │ ├── _react-virtualized-styles.scss │ └── style.scss └── jsx │ ├── components │ ├── __tests__ │ │ ├── search-test.js │ │ └── searchList-test.js │ ├── search.js │ └── searchList.js │ ├── lang │ └── messages.js │ ├── lib │ └── cache.js │ ├── propersearch.js │ └── utils │ └── normalize.js ├── tests.webpack.js ├── webpack.config.example.dist.js ├── webpack.config.example.js ├── webpack.config.js └── webpack.config.min.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015" 5 | ], 6 | "plugins": [ 7 | "add-module-exports", 8 | "transform-es3-member-expression-literals", 9 | "transform-es3-property-literals" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "plugins": [ 4 | "react" 5 | ], 6 | "ecmaFeatures": { 7 | "jsx": true, 8 | "modules": true 9 | }, 10 | "env": { 11 | "browser": true, 12 | "node": true, 13 | "es6": true 14 | }, 15 | "rules": { 16 | "strict": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | webpack.config.example.js 3 | webpack.config.js 4 | webpack.config.min.js 5 | examples/* 6 | dist/*.html 7 | dist/*.js 8 | src/**/__tests__/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.4" 4 | install: npm install 5 | before_install: 6 | - npm -g install karma-cli 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 CBI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProperSearch 2 | 3 | [![Build Status](https://travis-ci.org/CBIConsulting/ProperSearch.svg)](https://travis-ci.org/CBIConsulting/ProperSearch) 4 | 5 | A proper search component for react. With a search field and a list of items allows the user to filter that list and select the items. The component return the 6 | selected data when it get selected. Allows multi and single selection. The list is virtual rendered, was designed to handle thousands of items without sacrificing 7 | performance, only render the items in the view. Use react-virtualized to render the list items. This component has a lot of configurable settings, read the 8 | component properties section for more info. 9 | 10 | Used technologies: 11 | 12 | - React 13 | - ES6 14 | - Webpack 15 | - Babel 16 | - Node 17 | - Compass 18 | - Jasmine 19 | - Karma 20 | 21 | 22 | 23 | Features of ProperSearch: 24 | 25 | * Data selection allowed from a list 26 | * List filtering on search 27 | * Allow multi and single selection 28 | * Return the selection 29 | * List virtual rendered 30 | 31 | 32 | The compile and compressed ProperSearch distribution file can be found in the dist folder along with the css file. Add the default stylesheet `dist/propersearch.min.css`, then import it into any module. 33 | 34 | ## Live Demo 35 | ##### [Code](https://github.com/CBIConsulting/ProperSearch/tree/gh-pages/examples/jsx/app.js) 36 | ##### [Demo](http://cbiconsulting.github.io/ProperSearch/) 37 | 38 | 39 | ## External dependencies 40 | * React and React DOM 41 | * Underscore 42 | 43 | 44 | ## Preview 45 | ![screen shot 2016-04-04 at 11 40 00](examples/screenshots/example.png "Example of ProperSearch with multiselect") 46 | 47 | ## Use this module in your projects 48 | ``` 49 | npm install react-propersearch --save 50 | ``` 51 | 52 | ## How to start 53 | 54 | Run: 55 | ``` 56 | npm install 57 | npm start 58 | ``` 59 | 60 | Check your http://localhost:8080/ or `open http://localhost:8080/` 61 | 62 | ## How to test 63 | 64 | `npm test` 65 | 66 | ### Component properties 67 | * data: List data. (Array) (You can send data as an Inmutable but it should have a similar structure to the procesed data in method prepareData() [src/search::445](https://github.com/CBIConsulting/ProperSearch/tree/dev/src/jsx/components/search.js), and in this case don't forget to send indexed and rawdata too. It's not recomended) 68 | * value: Id field name. (String) 69 | * label: Name of the field to be displayed (String) 70 | * messages: Get the translated messages of the lang selected in the property lang. Default ENG (An example can be found in src/lang) 71 | * Default: 72 | ```javascript 73 | 'ENG': { 74 | all: 'Select All', 75 | none: 'Unselect All', 76 | loading: 'Loading...', 77 | noData:'No data found' 78 | } 79 | ``` 80 | * lang: Language for the component (String) 81 | * allowsEmptySelection: The empty string values will never be rendered into the list but if you set this prop to true then a new button will appear. When you click that button ('Select Empty') you'll get selection => [''] and the data array with all the elements that has empty values in idField or displayField 82 | * rowFormater: Process the data of each element of the list, it's a function that get the value, it should return the value formated. (NOTE: If the element it's a function then this prop does nothing) 83 | * defaultSelection: Items of the list selected by default. (String or Array) 84 | * multiSelect: Type of the selection, multiple or single (Boolean) 85 | * listWidth: Custom width for the list under the search field (Integer) Default component's width. 86 | * listHeight: Height of the list. Default 200 (Integer) 87 | * listRowHeight: Height of each row of the list 88 | * afterSelect: Function called after select a row. Return the seleted rows. 89 | * Ex: 90 | ```javascript 91 | afterSelect ={ 92 | function(data, selection){ 93 | console.info(data); // Selected data 94 | console.info(selection); // Array of selected values 95 | } 96 | } 97 | ``` 98 | * afterSearch: Function called after type something into the search field. Return the written string. 99 | * Ex: 100 | ```javascript 101 | afterSearch={ 102 | function(search_string) { 103 | console.info('Filtering by: ', search_string); 104 | } 105 | } 106 | ``` 107 | * afterSelectGetSelection: Function called after select a row. This one works same as afterSelec(data, selection) but is faster because doesn't work over data, only get selection instead. If you are working with large amount of data and just need the id's this one is more optimal. 108 | * Ex: 109 | ```javascript 110 | afterSelectGetSelection={ 111 | function(selectionAsArray, selectionAsSetObj) { 112 | console.info(selectionAsArray); // Array of selected values (idField) 113 | console.info(selectionAsSetObj); // Set object which contains selected values (idField) 114 | } 115 | } 116 | ``` 117 | * fieldClass: ClassName for the search field (String) 118 | * listClass: ClassName for the list (String) 119 | * listElementClass: ClassName for each element of the list (String) 120 | * className: ClassName for the component container (String) 121 | * placeholder: Placeholder for the search field (String) Default 'Search...' 122 | * searchIcon: ClassName for the search icon in the left of the search field (String) Default 'fa fa-search fa-fw' (FontAwesome) 123 | * clearIcon: ClassName for the clear icon (X) in the right side of the search field. (String) Default 'fa fa-times fa-fw' (FontAwesome) 124 | * throttle: Time between filtering action and the next. It affects to the search input onChange method setting an timeout (Integer) Default 160 125 | * minLength: Min. length of the search input to start filtering. (Integer) Default 3 126 | * onEnter: Custom function to be called on Enter key up. 127 | * idField: Name of the field that will be used to build the selection. Default 'value' 128 | * Ex: 129 | ```javascript 130 | let data = []; 131 | data.push(value:'3', label: 'Orange', price: '9', kg: 200); 132 | 133 | 138 | 139 | Selecting Orange you ill get a selection -> [3] and data -> [{value:'3', label: 'Orange', price: '9', kg: 200}] 140 | ``` 141 | * displayField: Field of the data which should be used to display each element of the list. It can be a string or a function, just remenber to set the showIcon property to false if you are using another component and then only that component will be rendered inside each list element. Default: 'label'. 142 | * Ex: 143 | ```javascript 144 | let buttonClick = (e, name) => { 145 | alert('Button ' + name + ' has been clicked'); 146 | } 147 | 148 | let formater = listElement => { 149 | return ; 150 | } 151 | 152 | let data = []; 153 | data.push(id:'16', display: formater, name: 'test 1'); 154 | 155 | 161 | ``` 162 | * listShowIcon: If the checked icon on the left of each list element must be printed or not 163 | * autoComplete: If the search field has autocomplete 'on' or 'off'. Default 'off' 164 | * defaultSearch: Set a default searching string to search input when the components get mounted or this prop is updated. 165 | * indexed: In case you want to use your own data (it has to be an Immutable obj) you must send indexed data by its idField. 166 | * rawdata: In case you want to use your own data (it has to be an Immutable obj) you must send raw data. (the data you'll get when someone clicks in list) 167 | * filterField: Field used for filtering on search input change. 168 | * filter: Function to filter on type something in the search input. By default the data will be filtered by its displayfield, if displayfield is a function then by it's name, if name doesn't exist then by its idField. (Important: If you set filterField then the data will be filter by the field you have chosen). Note: if you use the filter then you'll get each element of list and the search input value, then you can filter that data in the way you wanted). The search value it's normalized. 169 | * Ex: 170 | ```javascript 171 | let filter = (listElement, searchValue) => { 172 | let data = listElement.name.toLowerCase(); 173 | data = Normalizer.normalize(data); 174 | return data.indexOf(searchValue) >= 0; 175 | } 176 | 177 | let buttonClick = (e, name) => { 178 | alert('Button ' + name + ' has been clicked'); 179 | } 180 | 181 | let formater = listElement => { 182 | return ; 183 | } 184 | 185 | let data = []; 186 | data.push(id:'16', display: formater, name: 'test 1'); 187 | 188 | 195 | * hiddenSelection: Thats the selection of what elements should not be displayed. It's an array, string, number or Set obj of the data ids (idField). 196 | 197 | ``` 198 | ### Basic Example 199 | 200 | ```javascript 201 | import React from 'react'; 202 | import ReactDOM from 'react-dom'; 203 | import ProperSearch from 'react-propercombo'; 204 | 205 | // Function Called after select items in the list. 206 | 207 | const afterSelect = (data, selection) => { 208 | console.info(data); 209 | console.info(selection); 210 | } 211 | 212 | // List data 213 | const data = []; 214 | 215 | for (var i = 10000; i >= 0; i--) { 216 | data.push({value: 'item-' + i, label: 'Item ' + i}); 217 | } 218 | 219 | // Render the Search component 220 | ReactDOM.render( 221 | , 226 | document.getElementById('example') 227 | ); 228 | ``` 229 | 230 | 231 | Contributions 232 | ------------ 233 | 234 | Use [GitHub issues](https://github.com/CBIConsulting/ProperSearch/issues) for requests. 235 | 236 | Changelog 237 | --------- 238 | 239 | Changes are tracked as [GitHub releases](https://github.com/CBIConsulting/ProperSearch/releases). 240 | -------------------------------------------------------------------------------- /css/_react-virtualized-styles.scss: -------------------------------------------------------------------------------- 1 | /* Grid default theme */ 2 | 3 | .Grid { 4 | position: relative; 5 | overflow: auto; 6 | -webkit-overflow-scrolling: touch; 7 | 8 | /* Without this property, Chrome repaints the entire Grid any time a new row or column is added. 9 | Firefox only repaints the new row or column (regardless of this property). 10 | Safari and IE don't support the property at all. */ 11 | will-change: transform; 12 | } 13 | 14 | .Grid__innerScrollContainer { 15 | box-sizing: border-box; 16 | overflow: hidden; 17 | } 18 | 19 | .Grid__cell { 20 | position: absolute; 21 | } 22 | 23 | /* FlexTable default theme */ 24 | 25 | .FlexTable { 26 | } 27 | 28 | .FlexTable__Grid { 29 | overflow-x: hidden; 30 | } 31 | 32 | .FlexTable__headerRow { 33 | font-weight: 700; 34 | text-transform: uppercase; 35 | display: flex; 36 | flex-direction: row; 37 | align-items: center; 38 | overflow: hidden; 39 | } 40 | .FlexTable__headerTruncatedText { 41 | white-space: nowrap; 42 | text-overflow: ellipsis; 43 | overflow: hidden; 44 | } 45 | .FlexTable__row { 46 | display: flex; 47 | flex-direction: row; 48 | align-items: center; 49 | overflow: hidden; 50 | } 51 | 52 | .FlexTable__headerColumn, 53 | .FlexTable__rowColumn { 54 | margin-right: 10px; 55 | min-width: 0px; 56 | } 57 | 58 | .FlexTable__headerColumn:first-of-type, 59 | .FlexTable__rowColumn:first-of-type { 60 | margin-left: 10px; 61 | } 62 | .FlexTable__headerColumn { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: row; 66 | overflow: hidden; 67 | } 68 | .FlexTable__sortableHeaderColumn { 69 | cursor: pointer; 70 | } 71 | .FlexTable__rowColumn { 72 | justify-content: center; 73 | flex-direction: column; 74 | display: flex; 75 | overflow: hidden; 76 | height: 100%; 77 | } 78 | 79 | .FlexTable__sortableHeaderIconContainer { 80 | display: flex; 81 | align-items: center; 82 | } 83 | .FlexTable__sortableHeaderIcon { 84 | flex: 0 0 24px; 85 | height: 1em; 86 | width: 1em; 87 | fill: currentColor; 88 | } 89 | 90 | .FlexTable__truncatedColumnText { 91 | text-overflow: ellipsis; 92 | overflow: hidden; 93 | } 94 | 95 | /* VirtualScroll default theme */ 96 | 97 | .VirtualScroll { 98 | position: relative; 99 | overflow-y: auto; 100 | overflow-x: hidden; 101 | -webkit-overflow-scrolling: touch; 102 | } 103 | 104 | .VirtualScroll__innerScrollContainer { 105 | box-sizing: border-box; 106 | overflow: hidden; 107 | } 108 | 109 | .VirtualScroll__row { 110 | position: absolute; 111 | } -------------------------------------------------------------------------------- /css/style.scss: -------------------------------------------------------------------------------- 1 | @import "compass"; 2 | @import "react-virtualized-styles"; 3 | 4 | .proper-search { 5 | background-color: #fff; 6 | 7 | .proper-search-field { 8 | font-size: 14px; 9 | margin: 0; 10 | line-height: 22px; 11 | border: none; 12 | 13 | .proper-search-input { 14 | background: #fff; 15 | padding: 3px 2px; 16 | background-color: #ccc; 17 | @include border-radius(3px 2px, 2px 3px); 18 | 19 | input[type="text"] { 20 | width: 100%; 21 | height: 1.8em; 22 | margin-bottom: 0; 23 | color: #777777; 24 | padding-left: 30px; 25 | padding-right: 30px; 26 | border: none; 27 | outline: none; 28 | box-shadow: none; 29 | webkit-box-shadow: none; 30 | } 31 | 32 | i.proper-search-field-icon { 33 | color: #777777; 34 | overflow: visible; 35 | margin-right: -20px; 36 | position: absolute; 37 | border: 0; 38 | padding: 4px; 39 | } 40 | 41 | .btn-clear { 42 | i { 43 | color: #777777; 44 | } 45 | background: none; 46 | overflow: visible; 47 | position: absolute; 48 | border: 0; 49 | padding-top: 0.2em; 50 | margin-left: -26px; 51 | outline: none; 52 | 53 | &:active, &:focus { 54 | box-shadow: none; 55 | webkit-box-shadow: none; 56 | } 57 | } 58 | } 59 | } 60 | 61 | .proper-search-list { 62 | .proper-search-list-bar { 63 | background-color: #f6f7f8; 64 | background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjEuMCIgeDI9IjAuNSIgeTI9IjAuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2U2ZTZlNiIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); 65 | background-size: 100%; 66 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, rgba(230, 230, 230, 0.61)), color-stop(100%, #ffffff)); 67 | background-image: -moz-linear-gradient(bottom, rgba(230,230,230,0.61), #ffffff); 68 | background-image: -webkit-linear-gradient(#fff,#efefef); 69 | background-image: linear-gradient(#fff,#efefef); 70 | padding: 1px 0; 71 | width: 100%; 72 | border: 0; 73 | line-height: 5px; 74 | 75 | .btn-group { 76 | width: 100%; 77 | } 78 | 79 | .btn-group label { 80 | position: relative; 81 | cursor: pointer; 82 | margin: 0; 83 | font-weight: 100; 84 | font-family: Helvetica; 85 | font-size: x-small; 86 | } 87 | 88 | .btn-group .btn-select { 89 | display: inline-block; 90 | line-height: 1.4; 91 | text-align: center; 92 | width: 100%; 93 | cursor: pointer !important; 94 | border: none; 95 | margin: 0; 96 | padding: 5px 8px 5px 8px !important; 97 | border-radius: 0; 98 | color: #333; 99 | } 100 | 101 | .btn-group .btn-select:hover, 102 | .btn-group .btn-select:focus, 103 | .btn-group .btn-select:active, 104 | .btn-group .btn-select.disabled, 105 | .btn-group .btn-select[disabled] { 106 | background-color: #CECECE; 107 | text-decoration: none; 108 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, #CECECE), color-stop(100%, #ffffff)); 109 | background-image: -moz-linear-gradient(bottom, #CECECE, #ffffff); 110 | background-image: -webkit-linear-gradient(#fff,#CECECE); 111 | background-image: linear-gradient(#fff,#CECECE); 112 | } 113 | } 114 | 115 | .proper-search-list-virtual { 116 | &:focus,&:active { 117 | border: none; 118 | outline: none; 119 | } 120 | 121 | .proper-search-list-element { 122 | height: inherit; 123 | width: inherit; 124 | padding-left: 0.3em; 125 | margin-bottom: 0.1em; 126 | padding-top: 0.1em; 127 | cursor: pointer; 128 | white-space: nowrap; 129 | overflow: hidden; 130 | text-overflow: ellipsis; 131 | box-sizing: border-box; 132 | 133 | &.proper-search-selected { 134 | i { 135 | color: #10AB10; 136 | } 137 | } 138 | 139 | &.proper-search-single-selected { 140 | background-color: #C7EBFF; 141 | } 142 | 143 | i { 144 | width: 14.5px; 145 | margin-right: 8px; 146 | } 147 | } 148 | } 149 | } 150 | 151 | .proper-search-loading { 152 | text-align: center; 153 | font-size: 14px; 154 | margin: auto; 155 | } 156 | } -------------------------------------------------------------------------------- /dist/propersearch.css: -------------------------------------------------------------------------------- 1 | /* Grid default theme */ 2 | .Grid { 3 | position: relative; 4 | overflow: auto; 5 | -webkit-overflow-scrolling: touch; 6 | /* Without this property, Chrome repaints the entire Grid any time a new row or column is added. 7 | Firefox only repaints the new row or column (regardless of this property). 8 | Safari and IE don't support the property at all. */ 9 | will-change: transform; } 10 | 11 | .Grid__innerScrollContainer { 12 | box-sizing: border-box; 13 | overflow: hidden; } 14 | 15 | .Grid__cell { 16 | position: absolute; } 17 | 18 | /* FlexTable default theme */ 19 | .FlexTable__Grid { 20 | overflow-x: hidden; } 21 | 22 | .FlexTable__headerRow { 23 | font-weight: 700; 24 | text-transform: uppercase; 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | overflow: hidden; } 29 | 30 | .FlexTable__headerTruncatedText { 31 | white-space: nowrap; 32 | text-overflow: ellipsis; 33 | overflow: hidden; } 34 | 35 | .FlexTable__row { 36 | display: flex; 37 | flex-direction: row; 38 | align-items: center; 39 | overflow: hidden; } 40 | 41 | .FlexTable__headerColumn, 42 | .FlexTable__rowColumn { 43 | margin-right: 10px; 44 | min-width: 0px; } 45 | 46 | .FlexTable__headerColumn:first-of-type, 47 | .FlexTable__rowColumn:first-of-type { 48 | margin-left: 10px; } 49 | 50 | .FlexTable__headerColumn { 51 | align-items: center; 52 | display: flex; 53 | flex-direction: row; 54 | overflow: hidden; } 55 | 56 | .FlexTable__sortableHeaderColumn { 57 | cursor: pointer; } 58 | 59 | .FlexTable__rowColumn { 60 | justify-content: center; 61 | flex-direction: column; 62 | display: flex; 63 | overflow: hidden; 64 | height: 100%; } 65 | 66 | .FlexTable__sortableHeaderIconContainer { 67 | display: flex; 68 | align-items: center; } 69 | 70 | .FlexTable__sortableHeaderIcon { 71 | flex: 0 0 24px; 72 | height: 1em; 73 | width: 1em; 74 | fill: currentColor; } 75 | 76 | .FlexTable__truncatedColumnText { 77 | text-overflow: ellipsis; 78 | overflow: hidden; } 79 | 80 | /* VirtualScroll default theme */ 81 | .VirtualScroll { 82 | position: relative; 83 | overflow-y: auto; 84 | overflow-x: hidden; 85 | -webkit-overflow-scrolling: touch; } 86 | 87 | .VirtualScroll__innerScrollContainer { 88 | box-sizing: border-box; 89 | overflow: hidden; } 90 | 91 | .VirtualScroll__row { 92 | position: absolute; } 93 | 94 | .proper-search { 95 | background-color: #fff; } 96 | .proper-search .proper-search-field { 97 | font-size: 14px; 98 | margin: 0; 99 | line-height: 22px; 100 | border: none; } 101 | .proper-search .proper-search-field .proper-search-input { 102 | background: #fff; 103 | padding: 3px 2px; 104 | background-color: #ccc; 105 | -webkit-border-radius: 3px 2px; 106 | -moz-border-radius: 3px 2px / 2px 3px; 107 | border-radius: 3px 2px / 2px 3px; } 108 | .proper-search .proper-search-field .proper-search-input input[type="text"] { 109 | width: 100%; 110 | height: 1.8em; 111 | margin-bottom: 0; 112 | color: #777777; 113 | padding-left: 30px; 114 | padding-right: 30px; 115 | border: none; 116 | outline: none; 117 | box-shadow: none; 118 | webkit-box-shadow: none; } 119 | .proper-search .proper-search-field .proper-search-input i.proper-search-field-icon { 120 | color: #777777; 121 | overflow: visible; 122 | margin-right: -20px; 123 | position: absolute; 124 | border: 0; 125 | padding: 4px; } 126 | .proper-search .proper-search-field .proper-search-input .btn-clear { 127 | background: none; 128 | overflow: visible; 129 | position: absolute; 130 | border: 0; 131 | padding-top: 0.2em; 132 | margin-left: -26px; 133 | outline: none; } 134 | .proper-search .proper-search-field .proper-search-input .btn-clear i { 135 | color: #777777; } 136 | .proper-search .proper-search-field .proper-search-input .btn-clear:active, .proper-search .proper-search-field .proper-search-input .btn-clear:focus { 137 | box-shadow: none; 138 | webkit-box-shadow: none; } 139 | .proper-search .proper-search-list .proper-search-list-bar { 140 | background-color: #f6f7f8; 141 | background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjEuMCIgeDI9IjAuNSIgeTI9IjAuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2U2ZTZlNiIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=="); 142 | background-size: 100%; 143 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, rgba(230, 230, 230, 0.61)), color-stop(100%, #ffffff)); 144 | background-image: -moz-linear-gradient(bottom, rgba(230, 230, 230, 0.61), #ffffff); 145 | background-image: -webkit-linear-gradient(#fff, #efefef); 146 | background-image: linear-gradient(#fff, #efefef); 147 | padding: 1px 0; 148 | width: 100%; 149 | border: 0; 150 | line-height: 5px; } 151 | .proper-search .proper-search-list .proper-search-list-bar .btn-group { 152 | width: 100%; } 153 | .proper-search .proper-search-list .proper-search-list-bar .btn-group label { 154 | position: relative; 155 | cursor: pointer; 156 | margin: 0; 157 | font-weight: 100; 158 | font-family: Helvetica; 159 | font-size: x-small; } 160 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select { 161 | display: inline-block; 162 | line-height: 1.4; 163 | text-align: center; 164 | width: 100%; 165 | cursor: pointer !important; 166 | border: none; 167 | margin: 0; 168 | padding: 5px 8px 5px 8px !important; 169 | border-radius: 0; 170 | color: #333; } 171 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:hover, 172 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:focus, 173 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:active, 174 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select.disabled, 175 | .proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select[disabled] { 176 | background-color: #CECECE; 177 | text-decoration: none; 178 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, #CECECE), color-stop(100%, #ffffff)); 179 | background-image: -moz-linear-gradient(bottom, #CECECE, #ffffff); 180 | background-image: -webkit-linear-gradient(#fff, #CECECE); 181 | background-image: linear-gradient(#fff, #CECECE); } 182 | .proper-search .proper-search-list .proper-search-list-virtual:focus, .proper-search .proper-search-list .proper-search-list-virtual:active { 183 | border: none; 184 | outline: none; } 185 | .proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element { 186 | height: inherit; 187 | width: inherit; 188 | padding-left: 0.3em; 189 | margin-bottom: 0.1em; 190 | padding-top: 0.1em; 191 | cursor: pointer; 192 | white-space: nowrap; 193 | overflow: hidden; 194 | text-overflow: ellipsis; 195 | box-sizing: border-box; } 196 | .proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element.proper-search-selected i { 197 | color: #10AB10; } 198 | .proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element.proper-search-single-selected { 199 | background-color: #C7EBFF; } 200 | .proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element i { 201 | width: 14.5px; 202 | margin-right: 8px; } 203 | .proper-search .proper-search-loading { 204 | text-align: center; 205 | font-size: 14px; 206 | margin: auto; } 207 | -------------------------------------------------------------------------------- /dist/propersearch.min.css: -------------------------------------------------------------------------------- 1 | .Grid{position:relative;overflow:auto;-webkit-overflow-scrolling:touch;will-change:transform}.Grid__innerScrollContainer{box-sizing:border-box;overflow:hidden}.Grid__cell{position:absolute}.FlexTable__Grid{overflow-x:hidden}.FlexTable__headerRow{font-weight:700;text-transform:uppercase;display:flex;flex-direction:row;align-items:center;overflow:hidden}.FlexTable__headerTruncatedText{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.FlexTable__row{display:flex;flex-direction:row;align-items:center;overflow:hidden}.FlexTable__headerColumn,.FlexTable__rowColumn{margin-right:10px;min-width:0}.FlexTable__headerColumn:first-of-type,.FlexTable__rowColumn:first-of-type{margin-left:10px}.FlexTable__headerColumn{align-items:center;display:flex;flex-direction:row;overflow:hidden}.FlexTable__sortableHeaderColumn{cursor:pointer}.FlexTable__rowColumn{justify-content:center;flex-direction:column;display:flex;overflow:hidden;height:100%}.FlexTable__sortableHeaderIconContainer{display:flex;align-items:center}.FlexTable__sortableHeaderIcon{flex:0 0 24px;height:1em;width:1em;fill:currentColor}.FlexTable__truncatedColumnText{text-overflow:ellipsis;overflow:hidden}.VirtualScroll{position:relative;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch}.VirtualScroll__innerScrollContainer{box-sizing:border-box;overflow:hidden}.VirtualScroll__row{position:absolute}.proper-search{background-color:#fff}.proper-search .proper-search-field{font-size:14px;margin:0;line-height:22px;border:none}.proper-search .proper-search-field .proper-search-input{background:#fff;padding:3px 2px;background-color:#ccc;border-radius:3px 2px/2px 3px}.proper-search .proper-search-field .proper-search-input input[type=text]{width:100%;height:1.8em;margin-bottom:0;color:#777;padding-left:30px;padding-right:30px;border:none;outline:none;box-shadow:none;webkit-box-shadow:none}.proper-search .proper-search-field .proper-search-input i.proper-search-field-icon{color:#777;overflow:visible;margin-right:-20px;position:absolute;border:0;padding:4px}.proper-search .proper-search-field .proper-search-input .btn-clear{background:none;overflow:visible;position:absolute;border:0;padding-top:.2em;margin-left:-26px;outline:none}.proper-search .proper-search-field .proper-search-input .btn-clear i{color:#777}.proper-search .proper-search-field .proper-search-input .btn-clear:active,.proper-search .proper-search-field .proper-search-input .btn-clear:focus{box-shadow:none;webkit-box-shadow:none}.proper-search .proper-search-list .proper-search-list-bar{background-color:#f6f7f8;background-image:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjEuMCIgeDI9IjAuNSIgeTI9IjAuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2U2ZTZlNiIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA==");background-size:100%;background-image:-webkit-gradient(linear,50% 100%,50% 0,color-stop(0,hsla(0,0%,90%,.61)),color-stop(100%,#fff));background-image:-webkit-linear-gradient(#fff,#efefef);background-image:linear-gradient(#fff,#efefef);padding:1px 0;width:100%;border:0;line-height:5px}.proper-search .proper-search-list .proper-search-list-bar .btn-group{width:100%}.proper-search .proper-search-list .proper-search-list-bar .btn-group label{position:relative;cursor:pointer;margin:0;font-weight:100;font-family:Helvetica;font-size:x-small}.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select{display:inline-block;line-height:1.4;text-align:center;width:100%;cursor:pointer!important;border:none;margin:0;padding:5px 8px!important;border-radius:0;color:#333}.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select.disabled,.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:active,.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:focus,.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select:hover,.proper-search .proper-search-list .proper-search-list-bar .btn-group .btn-select[disabled]{background-color:#cecece;text-decoration:none;background-image:-webkit-gradient(linear,50% 100%,50% 0,color-stop(0,#cecece),color-stop(100%,#fff));background-image:-webkit-linear-gradient(#fff,#cecece);background-image:linear-gradient(#fff,#cecece)}.proper-search .proper-search-list .proper-search-list-virtual:active,.proper-search .proper-search-list .proper-search-list-virtual:focus{border:none;outline:none}.proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element{height:inherit;width:inherit;padding-left:.3em;margin-bottom:.1em;padding-top:.1em;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}.proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element.proper-search-selected i{color:#10ab10}.proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element.proper-search-single-selected{background-color:#c7ebff}.proper-search .proper-search-list .proper-search-list-virtual .proper-search-list-element i{width:14.5px;margin-right:8px}.proper-search .proper-search-loading{text-align:center;font-size:14px;margin:auto} -------------------------------------------------------------------------------- /examples/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBIConsulting/ProperSearch/085bf4f1104ffa1290de5a6a41ddec63a4b77871/examples/favicon.ico -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ProperSearch - React Component made by Cbi developers 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/jsx/app.js: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | import Search from "../../src/jsx/propersearch"; 3 | import Normalizer from "../../src/jsx/utils/normalize"; 4 | import {shallowEqualImmutable} from 'react-immutable-render-mixin'; 5 | 6 | function getDefaultProps() { 7 | return { 8 | listHeight: 200, 9 | listRowHeight: 35 10 | } 11 | } 12 | 13 | class App extends Component { 14 | 15 | constructor(props) { 16 | super(props); 17 | 18 | this.state = { 19 | data: [], 20 | fieldsSet: null, 21 | selection: null, 22 | language: 'ENG', 23 | idField: 'value', 24 | displayField: 'label', 25 | defaultSearch: '', 26 | multiSelect: true, 27 | listHeight: this.props.listHeight, 28 | listRowHeight: this.props.listRowHeight, 29 | placeholder: 'Search placeHolder', 30 | filterOff: false, 31 | dataSize: 100, 32 | hidden: null 33 | } 34 | } 35 | 36 | componentWillMount() { 37 | let data = [], fieldsSet = null; 38 | 39 | for (let i = this.state.dataSize; i >= 0; i--) { 40 | if (i == 8 || i == 9 || i == 16) data.push({itemID: '', display: '', name: 'Tést ' + i, moreFields: 'moreFields values'}); 41 | else data.push({itemID: 'item-' + i, display: this.formater.bind(this), name: 'Tést ' + i, moreFields: 'moreFields values'}); 42 | }; 43 | 44 | fieldsSet = new Set(_.keys(data[0])); 45 | 46 | this.setState({ 47 | data: data, 48 | fieldsSet: fieldsSet, 49 | idField: 'itemID', 50 | displayField: 'display' 51 | }); 52 | } 53 | 54 | shouldComponentUpdate(nextProps, nextState) { 55 | let stateChanged = !shallowEqualImmutable(this.state, nextState); 56 | let propsChanged = !shallowEqualImmutable(this.props, nextProps); 57 | let somethingChanged = propsChanged || stateChanged; 58 | 59 | if (nextState.dataSize != this.state.dataSize) { 60 | let newData = []; 61 | 62 | for (let i = nextState.dataSize; i >= 0; i--) { 63 | newData.push({ 64 | [nextState.idField]: 'item-' + i, 65 | [nextState.displayField]: 'Item ' + i, 66 | name: 'Teeést ' + i, 67 | fieldx: 'xxx ' + i, 68 | fieldy: 'yyy ' + i 69 | }); 70 | } 71 | 72 | this.setState({ 73 | data: newData, 74 | fieldsSet: new Set(_.keys(newData[0])) 75 | }); 76 | 77 | return false; 78 | } 79 | 80 | // If something change update form 81 | if (somethingChanged) { 82 | this.refs.listHeight.value = nextState.listHeight; 83 | this.refs.listElementHeight.value = nextState.listRowHeight; 84 | this.refs.idField.value = nextState.idField; 85 | this.refs.displayField.value = nextState.displayField; 86 | this.refs.dataSize.value = nextState.dataSize; 87 | this.refs.listElementHeight.value = nextState.listRowHeight; 88 | this.refs.lang.value = nextState.language; 89 | this.refs.multi.value = nextState.multiSelect; 90 | } 91 | 92 | return somethingChanged; 93 | } 94 | 95 | afterSearch(value) { 96 | console.info('Search: ', value); 97 | } 98 | 99 | afterSelect(data, selection) { 100 | console.info('Data: ', data); 101 | console.info('Selection: ', selection); 102 | 103 | this.setState({ 104 | selection: selection 105 | }); 106 | } 107 | 108 | filter(listElement, value) { 109 | let data = listElement.name; 110 | data = Normalizer.normalize(data); 111 | return data.indexOf(value) >= 0; 112 | } 113 | 114 | formater(listElement) { 115 | return ; 116 | } 117 | 118 | onButtonClick(e, name) { 119 | console.log('Button ' + name + ' has been clicked'); 120 | } 121 | 122 | onChangeData (e) { 123 | e.preventDefault(); 124 | let data = [], fieldsSet = null, language = '', random = Math.floor(Math.random()* 10); 125 | let selection = ['item-'+ random,'item-' + (random+1)]; 126 | let defaultSearch = 'Item '+ random, placeholder = 'Search Placeholder ' + random; 127 | let listHeight = this.props.listHeight + random, listRowHeight = this.props.listRowHeight + random; 128 | let multiSelect = !this.state.multiSelect, dataSize = (Math.floor(Math.random()* 1000) + 10); 129 | 130 | if (random % 2 == 0) language = 'ENG'; 131 | else language = 'SPA'; 132 | 133 | for (let i = dataSize; i >= 0; i--) { 134 | data.push({value: 'item-' + i, label: 'Item ' + i, name: 'Teeést ' + i, fieldx: 'xxx ' + i, fieldy: 'yyy ' + i}); 135 | } 136 | 137 | fieldsSet = new Set(_.keys(data[0])); 138 | 139 | this.setState({ 140 | data: data, 141 | fieldsSet: fieldsSet, 142 | idField: 'value', 143 | displayField: 'label', 144 | language: language, 145 | defaultSelection: selection, 146 | defaultSearch: defaultSearch, 147 | listHeight: listHeight, 148 | listRowHeight: listRowHeight, 149 | multiSelect: multiSelect, 150 | filter: null, 151 | placeholder: placeholder, 152 | afterSelect: this.afterSelect.bind(this), 153 | afterSearch: this.afterSearch.bind(this), 154 | dataSize: dataSize 155 | }); 156 | 157 | } 158 | 159 | onChangeSize(e) { 160 | e.preventDefault(); 161 | let size = this.refs.dataSize.value; 162 | size = parseInt(size); 163 | 164 | if (!isNaN(size)) { 165 | this.setState({ 166 | dataSize: size 167 | }); 168 | } else { 169 | this.refs.dataSize.value = this.state.dataSize; 170 | } 171 | } 172 | 173 | onChangeIdField(e) { 174 | e.preventDefault(); 175 | let fieldsSet = this.state.fieldsSet, newIdField = this.refs.idField.value; 176 | 177 | // Data has this field so update state otherwise set field to current state value 178 | // (SEARCH Component has prevent this and throws an error message in console and don't update the idField if that field doesn't exist in data) 179 | if (fieldsSet.has(newIdField)) { 180 | this.setState({ 181 | idField: newIdField 182 | }); 183 | } else { 184 | console.error('The data has no field with the name ' + newIdField + '. The fields of the data are: ', fieldsSet); 185 | this.refs.idField.value = this.state.idField; 186 | } 187 | } 188 | 189 | onChangeDisplay(e) { 190 | e.preventDefault(); 191 | let fieldsSet = this.state.fieldsSet, newDisplayField = this.refs.displayField.value; 192 | 193 | // Data has this field so update state otherwise set field to current state value 194 | // (SEARCH Component has prevent this and throws an error message in console and don't update the displayField if that field doesn't exist in data) 195 | if (fieldsSet.has(newDisplayField)) { 196 | this.setState({ 197 | displayField: newDisplayField 198 | }); 199 | } else { 200 | console.error('The data has no field with the name ' + newDisplayField + '. The fields of the data are: ', fieldsSet); 201 | this.refs.displayField.value = this.state.displayField; 202 | } 203 | } 204 | 205 | onChangeListHeight(e) { 206 | e.preventDefault(); 207 | let height = this.refs.listHeight.value; 208 | height = parseInt(height); 209 | 210 | if (!isNaN(height)) { 211 | this.setState({ 212 | listHeight: height 213 | }); 214 | } else { 215 | this.refs.listHeight.value = this.state.listHeight; 216 | } 217 | } 218 | 219 | onChangeElementHeight(e) { 220 | e.preventDefault(); 221 | let height = this.refs.listElementHeight.value; 222 | height = parseInt(height); 223 | 224 | if (!isNaN(height)) { 225 | this.setState({ 226 | listRowHeight: height 227 | }); 228 | } else { 229 | this.refs.listElementHeight.value = this.state.listRowHeight; 230 | } 231 | } 232 | 233 | onChangeMultiselect(e) { 234 | e.preventDefault(); 235 | let multi = null, selection = this.state.selection ? this.state.selection[0] : null; 236 | if (this.refs.multi.value == 'true') multi = true; 237 | else multi = false; 238 | 239 | this.setState({ 240 | multiSelect: multi, 241 | selection: selection 242 | }); 243 | } 244 | 245 | onChangeLang(e) { 246 | e.preventDefault(); 247 | 248 | this.setState({ 249 | language: this.refs.lang.value 250 | }); 251 | } 252 | 253 | apllyHidden(e) { 254 | e.preventDefault(); 255 | let hidden = ["item-1", "item-2", "item-3"]; 256 | 257 | if (this.state.hidden && this.state.hidden[0] === hidden[0]) { 258 | hidden = ["item-5", "item-6", "item-7"]; 259 | } 260 | 261 | this.setState({ 262 | hidden: hidden 263 | }); 264 | } 265 | 266 | render() { 267 | let filter = !this.state.filterOff ? this.filter.bind(this) : null; 268 | let multiSelect = this.state.multiSelect, language = this.state.language; 269 | 270 | return ( 271 |
272 |
273 |

Code

274 | 275 |
276 |
277 |
278 |
279 |
280 |
281 | 282 |
283 | 284 | 285 | 286 |
287 |
288 |
289 | 290 |
291 | 292 | 293 |
294 |
295 |
296 | 297 |
298 | 299 | 300 |
301 |
302 |
303 | 304 |
305 | 306 | 307 |
308 |
309 |
310 | 311 |
312 | 313 | 314 |
315 |
316 |
317 | 318 |
319 | 323 |
324 |
325 |
326 | 327 |
328 | 332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 | 358 |
359 |
360 | 378 |   379 |
Get Empty values allowed (the empty values never get rendered "", just get that data after click the button, with selection [""])
380 |
381 |
382 |
383 |
384 | ); 385 | } 386 | } 387 | 388 | App.defaultProps = getDefaultProps(); 389 | 390 | export default App; -------------------------------------------------------------------------------- /examples/jsx/example.js: -------------------------------------------------------------------------------- 1 | import App from "./app"; 2 | 3 | const body = document.getElementById('body'); 4 | ReactDOM.render(, body); -------------------------------------------------------------------------------- /examples/screenshots/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CBIConsulting/ProperSearch/085bf4f1104ffa1290de5a6a41ddec63a4b77871/examples/screenshots/example.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ProperSearch - React Component made by Cbi developers 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | browsers: ['PhantomJS'], 6 | files: [ 7 | 'tests.webpack.js', 8 | { 9 | pattern: 'src/**/__tests__/*.js', 10 | included: false, 11 | served: false, 12 | watched: true 13 | } 14 | ], 15 | frameworks: ['jasmine'], 16 | preprocessors: { 17 | 'tests.webpack.js': ['webpack', 'sourcemap', 'coverage'], 18 | }, 19 | reporters: ['progress', 'notification'], 20 | webpack: { 21 | devtool: 'inline-source-map', 22 | module: { 23 | loaders: [ 24 | { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.DefinePlugin({ 29 | 'process.env': { 30 | NODE_ENV: JSON.stringify('Test') 31 | } 32 | }) 33 | ], 34 | watch: true 35 | }, 36 | webpackServer: { 37 | noInfo: true, 38 | } 39 | }); 40 | }; -------------------------------------------------------------------------------- /lib/components/__tests__/search-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _search = require('../search'); 4 | 5 | var _search2 = _interopRequireDefault(_search); 6 | 7 | var _reactAddonsTestUtils = require('react-addons-test-utils'); 8 | 9 | var _reactAddonsTestUtils2 = _interopRequireDefault(_reactAddonsTestUtils); 10 | 11 | var _react = require('react'); 12 | 13 | var _react2 = _interopRequireDefault(_react); 14 | 15 | var _reactDom = require('react-dom'); 16 | 17 | var _reactDom2 = _interopRequireDefault(_reactDom); 18 | 19 | var _jquery = require('jquery'); 20 | 21 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 22 | 23 | var Set = require('es6-set'); 24 | 25 | describe('Search', function () { 26 | var wrapper = null; 27 | 28 | beforeEach(function () { 29 | wrapper = document.createElement('div'); 30 | }); 31 | 32 | it('is available', function () { 33 | expect(_search2['default']).not.toBe(null); 34 | }); 35 | 36 | it('filter', function (done) { 37 | var def = (0, _jquery.Deferred)(), 38 | component = null, 39 | node = null, 40 | props = getProps(); 41 | props.afterSearch = function (searchValue) { 42 | if (searchValue) def.resolve(searchValue); 43 | }; 44 | 45 | component = prepare(props); 46 | 47 | // Check filter 48 | component.handleSearch('foo'); 49 | 50 | def.done(function (value) { 51 | expect(component.state.data.toJSON()[0].itemID).toBe('3'); // Testing filter 52 | expect(value).toBe('foo'); 53 | }).always(done); 54 | }); 55 | 56 | it('selection multiselect', function (done) { 57 | var def = (0, _jquery.Deferred)(), 58 | component = null, 59 | props = getProps(); 60 | props.afterSelect = function (data, selection) { 61 | def.resolve(data, selection); 62 | }; 63 | component = prepare(props); 64 | 65 | // Check selection 66 | component.triggerSelection(new Set(['1', '2'])); 67 | 68 | def.done(function (data, selection) { 69 | expect(data.length).toBe(2); 70 | expect(selection.length).toBe(2); 71 | expect(data).toContain({ itemID: 1, display: 'Test1', toFilter: 'fee' }); 72 | expect(data[2]).not.toBeDefined(); 73 | expect(selection).toContain('1'); 74 | expect(selection).toContain('2'); 75 | }).always(done); 76 | }); 77 | 78 | it('selection multiselect display-function', function (done) { 79 | var def = (0, _jquery.Deferred)(), 80 | component = null, 81 | props = getPropsBigData(); 82 | 83 | props.multiSelect = true; 84 | props.afterSelect = function (data, selection) { 85 | def.resolve(data, selection); 86 | }; 87 | component = prepare(props); 88 | 89 | // Check selection 90 | component.triggerSelection(new Set(['1', 'item_2', 'item_5', 'item_6', 'item_8'])); 91 | 92 | def.done(function (data, selection) { 93 | expect(data.length).toBe(4); 94 | expect(selection.length).toBe(5); 95 | expect(selection).toContain('1'); 96 | expect(selection).toContain('item_2'); 97 | expect(selection).toContain('item_6'); 98 | expect(selection).not.toContain('item_4'); 99 | expect(data).toContain({ itemID: 'item_8', display: formater, name: 'Tést 8', moreFields: 'moreFields values' }); 100 | }).always(done); 101 | }); 102 | 103 | it('selection singleselection', function (done) { 104 | var def = (0, _jquery.Deferred)(), 105 | def2 = (0, _jquery.Deferred)(), 106 | component = null, 107 | props = getPropsBigData(); 108 | 109 | props.defaultSelection = null; 110 | props.afterSelect = function (data, selection) { 111 | if (data.length >= 1) { 112 | def.resolve(data, selection); 113 | } else { 114 | def2.resolve(data, selection); 115 | } 116 | }; 117 | component = prepare(props); 118 | component.triggerSelection(new Set(['item_190'])); 119 | 120 | def.done(function (data, selection) { 121 | expect(data.length).toBe(1); 122 | expect(selection.length).toBe(1); 123 | expect(selection[0]).toBe('item_190'); 124 | }); 125 | 126 | component.triggerSelection(new Set([])); 127 | def2.done(function (data, selection) { 128 | expect(data.length).toBe(0); 129 | expect(selection.length).toBe(0); 130 | }).always(done); 131 | }); 132 | 133 | it('selectAll on filtered data', function (done) { 134 | var def = (0, _jquery.Deferred)(), 135 | component = null, 136 | node = null, 137 | props = null; 138 | props = getPropsBigData(); 139 | 140 | props.multiSelect = true; 141 | props.defaultSelection = null; 142 | props.afterSelect = function (data, selection) { 143 | if (data.length > 1) { 144 | def.resolve(data, selection); 145 | } 146 | }; 147 | 148 | component = prepare(props); 149 | node = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-check"); 150 | 151 | component.handleSearch('test 10'); // Filter 152 | _reactAddonsTestUtils2['default'].Simulate.click(node); // Select All 153 | component.handleSearch(null); // Back to default 154 | 155 | def.done(function (data, selection) { 156 | expect(selection.length).toBe(12); 157 | expect(data.length).toBe(12); 158 | }).always(done); 159 | }); 160 | 161 | it('select/unselect all on filtered data && multiple operations', function (done) { 162 | var def = (0, _jquery.Deferred)(), 163 | component = null, 164 | nodeCheckAll = null, 165 | nodeUnCheck = null, 166 | nodeElements = null, 167 | props = null, 168 | promise = null; 169 | props = getPropsBigData(); 170 | promise = { done: function done() { 171 | return; 172 | } }; 173 | 174 | spyOn(promise, 'done'); 175 | 176 | props.multiSelect = true; 177 | props.defaultSelection = null; 178 | props.afterSelect = function (data, selection) { 179 | if (promise.done.calls.any()) { 180 | def.resolve(data, selection); 181 | } 182 | }; 183 | 184 | component = prepare(props); 185 | nodeCheckAll = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-check"); 186 | nodeUnCheck = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-unCheck"); 187 | nodeElements = _reactAddonsTestUtils2['default'].scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 188 | 189 | _reactAddonsTestUtils2['default'].Simulate.click(nodeCheckAll); // Select All 1000 190 | component.handleSearch('test 10'); // Filter (12 elements 1000 110 109... 100 10) 191 | _reactAddonsTestUtils2['default'].Simulate.click(nodeUnCheck); // UnSelect All (1000 - 12 => 988) 192 | component.handleSearch(null); // Back to default 193 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[3]); // Unselect element 997 (988 - 1 => 987) 194 | component.handleSearch('test 11'); // Filter (11 elements 11 110 111... 116 117 118 119) 195 | _reactAddonsTestUtils2['default'].Simulate.click(nodeUnCheck); // UnSelect All (987 - 11 => 976) 196 | _reactAddonsTestUtils2['default'].Simulate.click(nodeCheckAll); // Select All (976 + 11 => 987) 197 | promise.done(); 198 | nodeElements = _reactAddonsTestUtils2['default'].scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); // update nodeElement to current in view 199 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[3]); // Click element 116 (unSelect) (987 - 1 => 986) 200 | 201 | def.done(function (data, selection) { 202 | var set = new Set(selection); 203 | 204 | expect(selection.length).toBe(986); 205 | expect(data.length).toBe(986); 206 | expect(set.has('item_997')).toBe(false); 207 | expect(set.has('item_996')).toBe(true); 208 | expect(set.has('item_116')).toBe(false); 209 | expect(set.has('item_100')).toBe(false); 210 | expect(promise.done).toHaveBeenCalled(); 211 | }).always(done); 212 | }); 213 | 214 | it('keeps selection after refreshing data && update props', function (done) { 215 | var def = (0, _jquery.Deferred)(), 216 | props = getPropsBigData(), 217 | promise = null, 218 | component = null, 219 | nodeCheckAll = null, 220 | nodeElements = null, 221 | newData = []; 222 | promise = { done: function done() { 223 | return; 224 | } }; 225 | 226 | spyOn(promise, 'done'); 227 | 228 | props.multiSelect = true; 229 | props.defaultSelection = null; 230 | props.afterSelect = function (data, selection) { 231 | if (promise.done.calls.any()) { 232 | def.resolve(data, selection); 233 | } 234 | }; 235 | 236 | component = _reactDom2['default'].render(_react2['default'].createElement(_search2['default'], props), wrapper); 237 | 238 | nodeCheckAll = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-check"); 239 | _reactAddonsTestUtils2['default'].Simulate.click(nodeCheckAll); // Select All 1000 240 | 241 | for (var i = 1000; i > 0; i--) { 242 | newData.push({ id: 'item_' + i, display2: 'Element_' + i, name2: 'Testing2 ' + i }); 243 | }; 244 | props.data = newData; 245 | props.idField = 'id'; 246 | props.displayField = 'display2'; 247 | props.filterField = 'name2'; 248 | 249 | component = _reactDom2['default'].render(_react2['default'].createElement(_search2['default'], props), wrapper); // Update props 250 | nodeElements = _reactAddonsTestUtils2['default'].scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 251 | 252 | promise.done(); 253 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[0]); // Unselect one element (Element 1000) to call afterSelect 254 | 255 | def.done(function (data, selection) { 256 | expect(selection.length).toBe(999); 257 | expect(data[0]).toEqual(newData[1]); 258 | expect(data[0].id).toBeDefined(); 259 | expect(data[0].display2).toBeDefined(); 260 | expect(data[0].name2).toBeDefined(); 261 | expect(data[0].moreFields).not.toBeDefined(); 262 | }).always(done); 263 | }); 264 | }); 265 | 266 | function prepare(props) { 267 | return _reactAddonsTestUtils2['default'].renderIntoDocument(_react2['default'].createElement(_search2['default'], props)); 268 | } 269 | 270 | function getProps() { 271 | return { 272 | data: [{ itemID: 1, display: 'Test1', toFilter: 'fee' }, { itemID: 2, display: 'Test2', toFilter: 'fuu' }, { itemID: 3, display: 'Test3', toFilter: 'foo' }], 273 | lang: 'SPA', 274 | defaultSelection: [1], 275 | multiSelect: true, 276 | idField: 'itemID', 277 | displayField: 'display', 278 | filterField: 'toFilter', 279 | listWidth: 100, 280 | listHeight: 100 281 | }; 282 | } 283 | 284 | function formater(listElement) { 285 | return _react2['default'].createElement( 286 | 'button', 287 | { className: 'btn btn-default' }, 288 | listElement.name 289 | ); 290 | } 291 | 292 | function getPropsBigData() { 293 | var length = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1000; 294 | 295 | var data = []; 296 | 297 | for (var i = length; i > 0; i--) { 298 | data.push({ itemID: 'item_' + i, display: formater, name: 'Tést ' + i, moreFields: 'moreFields values' }); 299 | }; 300 | 301 | return { 302 | data: data, 303 | idField: 'itemID', 304 | defaultSelection: ['item_1'], 305 | displayField: 'display', 306 | filterField: 'name', 307 | listWidth: 100, 308 | listHeight: 100 309 | }; 310 | } -------------------------------------------------------------------------------- /lib/components/__tests__/searchList-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _searchList = require('../searchList'); 4 | 5 | var _searchList2 = _interopRequireDefault(_searchList); 6 | 7 | var _immutable = require('immutable'); 8 | 9 | var _immutable2 = _interopRequireDefault(_immutable); 10 | 11 | var _reactAddonsTestUtils = require('react-addons-test-utils'); 12 | 13 | var _reactAddonsTestUtils2 = _interopRequireDefault(_reactAddonsTestUtils); 14 | 15 | var _react = require('react'); 16 | 17 | var _react2 = _interopRequireDefault(_react); 18 | 19 | var _reactDom = require('react-dom'); 20 | 21 | var _reactDom2 = _interopRequireDefault(_reactDom); 22 | 23 | var _underscore = require('underscore'); 24 | 25 | var _underscore2 = _interopRequireDefault(_underscore); 26 | 27 | var _jquery = require('jquery'); 28 | 29 | var _messages = require('../../lang/messages'); 30 | 31 | var _messages2 = _interopRequireDefault(_messages); 32 | 33 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 34 | 35 | var Set = require('es6-set'); 36 | 37 | describe('SearchList', function () { 38 | 39 | it('is available', function () { 40 | expect(_searchList2['default']).not.toBe(null); 41 | }); 42 | 43 | it('handleElementClick singleSelection', function (done) { 44 | var def = (0, _jquery.Deferred)(); 45 | var props = getPropsBigData(); 46 | var expected = 'item_89'; 47 | props.multiSelect = false; 48 | props.onSelectionChange = function (selection) { 49 | def.resolve(selection); 50 | }; 51 | 52 | var component = prepare(props); 53 | 54 | // Click elements 55 | var nodeElements = _reactAddonsTestUtils2['default'].scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 56 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[11]); 57 | 58 | def.done(function (selection) { 59 | expect(selection.has(expected)).toBe(true); 60 | expect(selection.size).toBe(1); 61 | }).always(done); 62 | }); 63 | 64 | it('handleElementClick multiselect', function (done) { 65 | var def = (0, _jquery.Deferred)(); 66 | var props = getPropsBigData(); 67 | var expected = new Set(['item_98', 'item_100', 'item_88', 'item_91']); 68 | 69 | props.onSelectionChange = function (selection) { 70 | if (selection.size >= 1) { 71 | def.resolve(selection); 72 | } 73 | }; 74 | 75 | var component = prepare(props); 76 | 77 | // Click elements 78 | var nodeElements = _reactAddonsTestUtils2['default'].scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 79 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[2]); 80 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[1]); // Old selected item (unselect now) 81 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[0]); 82 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[12]); 83 | _reactAddonsTestUtils2['default'].Simulate.click(nodeElements[9]); 84 | 85 | def.done(function (selection) { 86 | var result = true; 87 | selection.forEach(function (element) { 88 | if (!expected.has(element)) { 89 | result = false; 90 | return; 91 | } 92 | }); 93 | expect(result).toBe(true); 94 | expect(selection.size).toBe(4); 95 | }).always(done); 96 | }); 97 | 98 | it('handleSelectAll all', function (done) { 99 | var def = (0, _jquery.Deferred)(); 100 | var props = getPropsBigData(); 101 | 102 | props.onSelectionChange = function (selection) { 103 | def.resolve(selection); 104 | }; 105 | 106 | var component = prepare(props); 107 | 108 | // Click elements 109 | var node = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-check"); 110 | _reactAddonsTestUtils2['default'].Simulate.click(node); 111 | 112 | def.done(function (selection) { 113 | expect(selection.size).toBe(100); 114 | }).always(done); 115 | }); 116 | 117 | it('handleSelectAll nothing', function (done) { 118 | var def = (0, _jquery.Deferred)(); 119 | var props = getPropsBigData(); 120 | 121 | props.onSelectionChange = function (selection) { 122 | def.resolve(selection); 123 | }; 124 | 125 | var component = prepare(props); 126 | 127 | // Click elements 128 | var node = _reactAddonsTestUtils2['default'].findRenderedDOMComponentWithClass(component, "list-bar-unCheck"); 129 | _reactAddonsTestUtils2['default'].Simulate.click(node); 130 | 131 | def.done(function (selection) { 132 | expect(selection.size).toBe(0); 133 | }).always(done); 134 | }); 135 | }); 136 | 137 | function prepare(props) { 138 | return _reactAddonsTestUtils2['default'].renderIntoDocument(_react2['default'].createElement(_searchList2['default'], props)); 139 | } 140 | 141 | function formater(listElement) { 142 | return _react2['default'].createElement( 143 | 'button', 144 | { className: 'btn btn-default' }, 145 | listElement.name 146 | ); 147 | } 148 | 149 | function getPropsBigData() { 150 | var length = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; 151 | 152 | var data = [], 153 | preparedData = null; 154 | 155 | for (var i = length; i > 0; i--) { 156 | data.push({ itemID: 'item_' + i, display: formater, name: 'Tést ' + i, moreFields: 'moreFields values' }); 157 | }; 158 | 159 | preparedData = prepareData(data, 'itemID'); 160 | 161 | return { 162 | data: preparedData.data, 163 | indexedData: preparedData.indexed, 164 | idField: 'itemID', 165 | multiSelect: true, 166 | selection: new Set(['item_99']), 167 | displayField: 'display', 168 | uniqueID: 'test', 169 | messages: _messages2['default']['ENG'] 170 | }; 171 | } 172 | 173 | /* 174 | * Prepare the data received by the component for the internal working. 175 | */ 176 | function prepareData(newData, field) { 177 | // The data will be inmutable inside the component 178 | var data = _immutable2['default'].fromJS(newData), 179 | index = 0; 180 | var indexed = [], 181 | parsed = []; 182 | 183 | // Parsing data to add new fields (selected or not, field, rowIndex) 184 | parsed = data.map(function (row) { 185 | if (!row.get(field, false)) { 186 | row = row.set(field, _underscore2['default'].uniqueId()); 187 | } else { 188 | row = row.set(field, row.get(field).toString()); 189 | } 190 | 191 | if (!row.get('_selected', false)) { 192 | row = row.set('_selected', false); 193 | } 194 | 195 | row = row.set('_rowIndex', index++); 196 | 197 | return row; 198 | }); 199 | 200 | // Prepare indexed data. 201 | indexed = _underscore2['default'].indexBy(parsed.toJSON(), field); 202 | 203 | return { 204 | rawdata: data, 205 | data: parsed, 206 | indexed: indexed 207 | }; 208 | } -------------------------------------------------------------------------------- /lib/components/search.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; 8 | 9 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 10 | 11 | var _react = require('react'); 12 | 13 | var _react2 = _interopRequireDefault(_react); 14 | 15 | var _immutable = require('immutable'); 16 | 17 | var _immutable2 = _interopRequireDefault(_immutable); 18 | 19 | var _underscore = require('underscore'); 20 | 21 | var _underscore2 = _interopRequireDefault(_underscore); 22 | 23 | var _searchList = require('./searchList'); 24 | 25 | var _searchList2 = _interopRequireDefault(_searchList); 26 | 27 | var _reactPropersearchField = require('react-propersearch-field'); 28 | 29 | var _reactPropersearchField2 = _interopRequireDefault(_reactPropersearchField); 30 | 31 | var _messages2 = require('../lang/messages'); 32 | 33 | var _messages3 = _interopRequireDefault(_messages2); 34 | 35 | var _normalize = require('../utils/normalize'); 36 | 37 | var _normalize2 = _interopRequireDefault(_normalize); 38 | 39 | var _reactImmutableRenderMixin = require('react-immutable-render-mixin'); 40 | 41 | var _cache = require('../lib/cache'); 42 | 43 | var _cache2 = _interopRequireDefault(_cache); 44 | 45 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 46 | 47 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 48 | 49 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 50 | 51 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 52 | 53 | var Set = require('es6-set'); 54 | 55 | // For more info about this read ReadMe.md 56 | function getDefaultProps() { 57 | return { 58 | data: [], 59 | rawdata: null, // Case you want to use your own inmutable data. Read prepareData() method for more info. 60 | indexed: null, // Case you want to use your own inmutable data. Read prepareData() method for more info. 61 | messages: _messages3['default'], 62 | lang: 'ENG', 63 | rowFormater: null, // function to format values in render 64 | defaultSelection: null, 65 | hiddenSelection: null, 66 | multiSelect: false, 67 | listWidth: null, 68 | listHeight: 200, 69 | listRowHeight: 26, 70 | afterSelect: null, // Function Get selection and data 71 | afterSelectGetSelection: null, // Function Get just selection (no data) 72 | afterSearch: null, 73 | onEnter: null, // Optional - To do when key down Enter - SearchField 74 | fieldClass: null, 75 | listClass: null, 76 | listElementClass: null, 77 | className: null, 78 | placeholder: 'Search...', 79 | searchIcon: 'fa fa-search fa-fw', 80 | clearIcon: 'fa fa-times fa-fw', 81 | throttle: 160, // milliseconds 82 | minLength: 3, 83 | defaultSearch: null, 84 | autoComplete: 'off', 85 | idField: 'value', 86 | displayField: 'label', 87 | listShowIcon: true, 88 | filter: null, // Optional function (to be used when the displayField is an function too) 89 | filterField: null, // By default it will be the displayField 90 | allowsEmptySelection: false }; 91 | } 92 | 93 | /** 94 | * A proper search component for react. With a search field and a list of items allows the user to filter that list and select the items. 95 | * The component return the selected data when it's selected. Allows multi and single selection. The list is virtual rendered, was designed 96 | * to handle thousands of elements without sacrificing performance, just render the elements in the view. Used react-virtualized to render the list items. 97 | * 98 | * Simple example usage: 99 | * 100 | * let data = []; 101 | * data.push({ 102 | * value: 1, 103 | * label: 'Apple' 104 | * }); 105 | * 106 | * let afterSelect = (data, selection) => { 107 | * console.info(data); 108 | * console.info(selection); 109 | * } 110 | * 111 | * 116 | * ``` 117 | */ 118 | // Put this to true to get a diferent ToolBar that allows select empty 119 | 120 | var Search = function (_React$Component) { 121 | _inherits(Search, _React$Component); 122 | 123 | function Search(props) { 124 | _classCallCheck(this, Search); 125 | 126 | var _this = _possibleConstructorReturn(this, (Search.__proto__ || Object.getPrototypeOf(Search)).call(this, props)); 127 | 128 | var preparedData = _this.prepareData(null, props.idField, false, props.displayField); 129 | 130 | _this.state = { 131 | data: preparedData.data, // Data to work with (Inmutable) 132 | initialData: preparedData.data, // Same data as initial state.data but this data never changes. (Inmutable) 133 | rawData: preparedData.rawdata, // Received data without any modfication (Inmutable) 134 | indexedData: preparedData.indexed, // Received data indexed (No Inmutable) 135 | initialIndexed: preparedData.indexed, // When data get filtered keep the full indexed 136 | idField: props.idField, // To don't update the idField if that field doesn't exist in the fields of data array 137 | displayField: props.displayField, // same 138 | selection: new Set(), 139 | allSelected: false, 140 | selectionApplied: false, // If the selection has been aplied to the data (mostly for some cases of updating props data) 141 | ready: false 142 | }; 143 | return _this; 144 | } 145 | 146 | _createClass(Search, [{ 147 | key: 'componentDidMount', 148 | value: function componentDidMount() { 149 | this.setDefaultSelection(this.props.defaultSelection); 150 | 151 | this.setState({ 152 | ready: true 153 | }); 154 | } 155 | }, { 156 | key: 'shouldComponentUpdate', 157 | value: function shouldComponentUpdate(nextProps, nextState) { 158 | var _this2 = this; 159 | 160 | var stateChanged = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(this.state, nextState); 161 | var propsChanged = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(this.props, nextProps); 162 | var somethingChanged = propsChanged || stateChanged; 163 | 164 | // Update row indexes when data get filtered 165 | if (this.state.data.size != nextState.data.size) { 166 | var parsed = undefined, 167 | indexed = undefined, 168 | data = undefined; 169 | 170 | if (nextState.ready) { 171 | if (nextState.data.size === 0) { 172 | data = nextState.data; 173 | indexed = {}; 174 | } else { 175 | parsed = this.prepareData(nextState.data, this.state.idField, true, this.state.displayField); // Force rebuild indexes etc 176 | data = parsed.data; 177 | indexed = parsed.indexed; 178 | } 179 | 180 | this.setState({ 181 | data: data, 182 | indexedData: indexed, 183 | allSelected: this.isAllSelected(data, nextState.selection) 184 | }); 185 | } else { 186 | var selection = nextProps.defaultSelection; 187 | if (!nextProps.multiSelect) selection = nextState.selection.values().next().value || null; 188 | 189 | // props data has been changed in the last call to this method 190 | this.setDefaultSelection(selection); 191 | if (_underscore2['default'].isNull(selection) || selection.length === 0) this.setState({ ready: true }); // No def selection so then ready 192 | } 193 | 194 | return false; 195 | } 196 | 197 | if (propsChanged) { 198 | var _ret = function () { 199 | var dataChanged = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(_this2.props.data, nextProps.data); 200 | var idFieldChanged = _this2.props.idField != nextProps.idField, 201 | displayFieldChanged = _this2.props.displayField != nextProps.displayField; 202 | var selectionChanged = false, 203 | nextSelection = new Set(nextProps.defaultSelection), 204 | selection = null; 205 | 206 | if (_this2.state.selection.size != nextSelection.size) { 207 | selectionChanged = true; 208 | selection = nextProps.defaultSelection; 209 | } else { 210 | _this2.state.selection.forEach(function (element) { 211 | if (!nextSelection.has(element)) { 212 | selectionChanged = true; 213 | selection = nextProps.defaultSelection; 214 | return true; 215 | } 216 | }); 217 | } 218 | 219 | if (!nextProps.multiSelect && (nextSelection.size > 1 || _this2.state.selection.size > 1)) { 220 | selection = nextSelection.size > 1 ? nextProps.defaultSelection : _this2.state.selection.values().next().value; 221 | if (_underscore2['default'].isArray(selection)) selection = selection[0]; 222 | } 223 | 224 | if (idFieldChanged || displayFieldChanged) { 225 | var fieldsSet = new Set(_underscore2['default'].keys(nextProps.data[0])); 226 | var _messages = _this2.getTranslatedMessages(); 227 | 228 | // Change idField / displayField but that field doesn't exist in the data 229 | if (!fieldsSet.has(nextProps.idField) || !fieldsSet.has(nextProps.displayField)) { 230 | if (!fieldsSet.has(nextProps.idField)) console.error(_messages.errorIdField + ' ' + nextProps.idField + ' ' + _messages.errorData);else console.error(_messages.errorDisplayField + ' ' + nextProps.idField + ' ' + _messages.errorData); 231 | 232 | return { 233 | v: false 234 | }; 235 | } else { 236 | // New idField &&//|| displayField exist in data array fields 237 | if (dataChanged) { 238 | _cache2['default'].flush('search_list'); 239 | var preparedData = _this2.prepareData(nextProps.data, nextProps.idField, false, nextProps.displayField); 240 | 241 | _this2.setState({ 242 | data: preparedData.data, 243 | initialData: preparedData.data, 244 | rawData: preparedData.rawdata, 245 | indexedData: preparedData.indexed, 246 | initialIndexed: preparedData.indexed, 247 | idField: nextProps.idField, 248 | displayField: nextProps.displayField, 249 | ready: false, 250 | selectionApplied: false 251 | }, _this2.setDefaultSelection(selection)); 252 | } else { 253 | var initialIndexed = null, 254 | _indexed = null; 255 | 256 | // If the id field change then the indexed data has to be changed but not for display 257 | if (displayFieldChanged) { 258 | initialIndexed = _this2.state.initialIndexed; 259 | _indexed = _this2.state.indexedData; 260 | } else { 261 | initialIndexed = _underscore2['default'].indexBy(_this2.state.initialData.toJSON(), nextProps.idField); 262 | _indexed = _underscore2['default'].indexBy(_this2.state.data.toJSON(), nextProps.idField); 263 | } 264 | 265 | _this2.setState({ 266 | indexedData: _indexed, 267 | initialIndexed: initialIndexed, 268 | idField: nextProps.idField, 269 | displayField: nextProps.displayField, 270 | ready: false, 271 | selectionApplied: false 272 | }); 273 | } 274 | return { 275 | v: false 276 | }; 277 | } 278 | } 279 | 280 | if (dataChanged) { 281 | _cache2['default'].flush('search_list'); 282 | var _preparedData = _this2.prepareData(nextProps.data, nextProps.idField, false, nextProps.displayField); 283 | 284 | _this2.setState({ 285 | data: _preparedData.data, 286 | initialData: _preparedData.data, 287 | rawData: _preparedData.rawdata, 288 | indexedData: _preparedData.indexed, 289 | initialIndexed: _preparedData.indexed, 290 | ready: false, 291 | selectionApplied: false 292 | }, _this2.setDefaultSelection(selection)); 293 | 294 | return { 295 | v: false 296 | }; 297 | } 298 | 299 | if (selectionChanged) { 300 | // Default selection does nothing if the selection is null so in that case update the state to restart selection 301 | if (!_underscore2['default'].isNull(selection)) { 302 | _this2.setDefaultSelection(selection); 303 | } else { 304 | _this2.setState({ 305 | selection: new Set(), 306 | allSelected: false, 307 | ready: true 308 | }); 309 | } 310 | 311 | return { 312 | v: false 313 | }; 314 | } 315 | }(); 316 | 317 | if ((typeof _ret === 'undefined' ? 'undefined' : _typeof(_ret)) === "object") return _ret.v; 318 | } 319 | 320 | if (!nextState.ready) { 321 | this.setState({ 322 | ready: true 323 | }); 324 | 325 | return false; 326 | } 327 | 328 | return somethingChanged; 329 | } 330 | 331 | /** 332 | * Before the components update set the updated selection data to the components state. 333 | * 334 | * @param {object} nextProps The props that will be set for the updated component 335 | * @param {object} nextState The state that will be set for the updated component 336 | */ 337 | 338 | }, { 339 | key: 'componentWillUpdate', 340 | value: function componentWillUpdate(nextProps, nextState) { 341 | // Selection 342 | if (this.props.multiSelect) { 343 | if (nextState.selection.size !== this.state.selection.size || !nextState.selectionApplied && nextState.selection.size > 0) { 344 | this.updateSelectionData(nextState.selection, nextState.allSelected); 345 | } 346 | } else { 347 | var next = nextState.selection.values().next().value || null; 348 | var old = this.state.selection.values().next().value || null; 349 | var oldSize = !_underscore2['default'].isNull(this.state.selection) ? this.state.selection.size : 0; 350 | 351 | if (next !== old || oldSize > 1) { 352 | this.updateSelectionData(next); 353 | } 354 | } 355 | } 356 | 357 | /** 358 | * Method called before the components update to set the new selection to states component and update the data 359 | * 360 | * @param {array} newSelection The new selected rows (Set object) 361 | * @param {array} newAllSelected If the new state has all the rows selected 362 | */ 363 | 364 | }, { 365 | key: 'updateSelectionData', 366 | value: function updateSelectionData(newSelection) { 367 | var _this3 = this; 368 | 369 | var newAllSelected = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 370 | 371 | var newIndexed = _underscore2['default'].clone(this.state.indexedData); 372 | var oldSelection = this.state.selection; 373 | var rowid = null, 374 | selected = null, 375 | rdata = null, 376 | curIndex = null, 377 | newData = this.state.data, 378 | rowIndex = null; 379 | var newSelectionSize = !_underscore2['default'].isNull(newSelection) ? newSelection.size : 0; 380 | 381 | // If oldSelection size is bigger than 1 that mean's the props has changed from multiselect to single select so if there is some list items with the selected class 382 | // if should be reset. 383 | if (!this.props.multiSelect && oldSelection.size <= 1) { 384 | // Single select 385 | var oldId = oldSelection.values().next().value || null; 386 | var indexedKeys = new Set(_underscore2['default'].keys(newIndexed)); 387 | 388 | if (!_underscore2['default'].isNull(oldId) && indexedKeys.has(oldId)) { 389 | newIndexed[oldId]._selected = false; // Update indexed data 390 | rowIndex = newIndexed[oldId]._rowIndex; // Get data index 391 | if (newData.get(rowIndex)) { 392 | rdata = newData.get(rowIndex).set('_selected', false); // Change the row in that index 393 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 394 | } 395 | } 396 | 397 | if (!_underscore2['default'].isNull(newSelection) && indexedKeys.has(newSelection)) { 398 | newIndexed[newSelection]._selected = true; // Update indexed data 399 | rowIndex = newIndexed[newSelection]._rowIndex; // Get data index 400 | rdata = newData.get(rowIndex).set('_selected', true); // Change the row in that index 401 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 402 | } 403 | } else if (!newAllSelected && this.isSingleChange(newSelectionSize)) { 404 | // Change one row data at a time 405 | var changedId = null, 406 | _selected = null; 407 | 408 | // If the new selection has not one of the ids of the old selection that means an selected element has been unselected. 409 | oldSelection.forEach(function (id) { 410 | if (!newSelection.has(id)) { 411 | changedId = id; 412 | _selected = false; 413 | return false; 414 | } 415 | }); 416 | 417 | // Otherwise a new row has been selected. Look through the new selection for the new element. 418 | if (!changedId) { 419 | _selected = true; 420 | newSelection.forEach(function (id) { 421 | if (!oldSelection.has(id)) { 422 | changedId = id; 423 | return false; 424 | } 425 | }); 426 | } 427 | 428 | newIndexed[changedId]._selected = _selected; // Update indexed data 429 | rowIndex = newIndexed[changedId]._rowIndex; // Get data index 430 | rdata = newData.get(rowIndex).set('_selected', _selected); // Change the row in that index 431 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 432 | } else { 433 | // Change all data 434 | if (_underscore2['default'].isNull(newSelection)) newSelection = new Set();else if (!_underscore2['default'].isObject(newSelection)) newSelection = new Set([newSelection]); 435 | 436 | newData = newData.map(function (row) { 437 | rowid = row.get(_this3.state.idField); 438 | selected = newSelection.has(rowid.toString()); 439 | rdata = row.set('_selected', selected); 440 | curIndex = newIndexed[rowid]; 441 | 442 | if (curIndex._selected != selected) { 443 | // update indexed data 444 | curIndex._selected = selected; 445 | newIndexed[rowid] = curIndex; 446 | } 447 | 448 | return rdata; 449 | }); 450 | } 451 | 452 | this.setState({ 453 | data: newData, 454 | indexedData: newIndexed, 455 | selectionApplied: true 456 | }); 457 | } 458 | 459 | /** 460 | * Check if the selection has more than 1 change. 461 | * 462 | * @param {integer} newSize Size of the new selection 463 | */ 464 | 465 | }, { 466 | key: 'isSingleChange', 467 | value: function isSingleChange(newSize) { 468 | var oldSize = this.state.selection.size; 469 | 470 | if (oldSize - 1 == newSize || oldSize + 1 == newSize) return true;else return false; 471 | } 472 | 473 | /** 474 | * Get the translated messages for the component. 475 | * 476 | * @return object Messages of the selected language or in English if the translation for this lang doesn't exist. 477 | */ 478 | 479 | }, { 480 | key: 'getTranslatedMessages', 481 | value: function getTranslatedMessages() { 482 | if (!_underscore2['default'].isObject(this.props.messages)) { 483 | return {}; 484 | } 485 | 486 | if (this.props.messages[this.props.lang]) { 487 | return this.props.messages[this.props.lang]; 488 | } 489 | 490 | return this.props.messages['ENG']; 491 | } 492 | 493 | /** 494 | * In case that the new selection array be different than the selection array in the components state, then update 495 | * the components state with the new data. 496 | * 497 | * @param {array} newSelection The selected rows 498 | * @param {boolean} sendSelection If the selection must be sent or not 499 | */ 500 | 501 | }, { 502 | key: 'triggerSelection', 503 | value: function triggerSelection() { 504 | var newSelection = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new Set(); 505 | var sendSelection = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; 506 | 507 | if (sendSelection) { 508 | this.setState({ 509 | selection: newSelection, 510 | allSelected: this.isAllSelected(this.state.data, newSelection) 511 | }, this.sendSelection); 512 | } else { 513 | this.setState({ 514 | selection: newSelection, 515 | allSelected: this.isAllSelected(this.state.data, newSelection) 516 | }); 517 | } 518 | } 519 | 520 | /** 521 | * Check if all the current data are selected. 522 | * 523 | * @param {array} data The data to compare with selection 524 | * @param {object} selection The current selection Set of values (idField) 525 | */ 526 | 527 | }, { 528 | key: 'isAllSelected', 529 | value: function isAllSelected(data, selection) { 530 | var _this4 = this; 531 | 532 | var result = true; 533 | if (data.size > selection.size) return false; 534 | 535 | data.forEach(function (item, index) { 536 | if (!selection.has(item.get(_this4.state.idField, null))) { 537 | // Some data not in selection 538 | result = false; 539 | return false; 540 | } 541 | }); 542 | 543 | return result; 544 | } 545 | 546 | /** 547 | * Set up the default selection if exist 548 | * 549 | * @param {array || string ... number} defSelection Default selection to be applied to the list 550 | */ 551 | 552 | }, { 553 | key: 'setDefaultSelection', 554 | value: function setDefaultSelection(defSelection) { 555 | if (defSelection) { 556 | var selection = null; 557 | 558 | if (defSelection.length == 0) { 559 | selection = new Set(); 560 | } else { 561 | if (!_underscore2['default'].isArray(defSelection)) { 562 | selection = new Set([defSelection.toString()]); 563 | } else { 564 | selection = new Set(defSelection.toString().split(',')); 565 | } 566 | } 567 | 568 | selection['delete'](''); // Remove empty values 569 | 570 | this.triggerSelection(selection, false); 571 | } 572 | } 573 | 574 | /** 575 | * Prepare the data received by the component for the internal use. 576 | * 577 | * @param (object) newData New data for rebuild. (filtering || props changed) 578 | * @param (string) idField New idField if it has been changed. (props changed) 579 | * @param (boolean) rebuild Rebuild the data. NOTE: If newData its an Immutable you should put this param to true. 580 | * 581 | * @return (array) -rawdata: The same data as the props or the newData in case has been received. 582 | * -indexed: Same as rawdata but indexed by the idField 583 | * -data: Parsed data to add some fields necesary to internal working. 584 | */ 585 | 586 | }, { 587 | key: 'prepareData', 588 | value: function prepareData() { 589 | var newData = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; 590 | var idField = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; 591 | var rebuild = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; 592 | var displayfield = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; 593 | 594 | // The data will be inmutable inside the component 595 | var data = newData || this.props.data, 596 | index = 0, 597 | rdataIndex = 0, 598 | idSet = new Set(), 599 | field = idField || this.state.idField, 600 | fieldValue = undefined; 601 | var indexed = [], 602 | parsed = [], 603 | rawdata = undefined, 604 | hasNulls = false; 605 | 606 | // If not Immutable. 607 | // If an Immutable is received in props.data at the components first building the component will work with that data. In that case 608 | // the component should get indexed and rawdata in props. It's up to the developer if he / she wants to work with data from outside 609 | // but it's important to keep in mind that you need a similar data structure (_selected, _rowIndex, idField...) 610 | if (!_immutable2['default'].Iterable.isIterable(data) || rebuild) { 611 | data = _immutable2['default'].fromJS(data); // If data it's already Immutable the method .fromJS return the same object 612 | 613 | // Parsing data to add new fields (selected or not, field, rowIndex) 614 | parsed = data.map(function (row) { 615 | fieldValue = row.get(field, false); 616 | 617 | if (!fieldValue) { 618 | fieldValue = _underscore2['default'].uniqueId(); 619 | } 620 | 621 | // No rows with same idField. The idField must be unique and also don't render the empty values 622 | if (!idSet.has(fieldValue) && fieldValue !== '' && row.get(displayfield, '') !== '') { 623 | idSet.add(fieldValue); 624 | row = row.set(field, fieldValue.toString()); 625 | 626 | if (!row.get('_selected', false)) { 627 | row = row.set('_selected', false); 628 | } 629 | 630 | row = row.set('_rowIndex', index++); // data row index 631 | row = row.set('_rawDataIndex', rdataIndex++); // rawData row index 632 | 633 | return row; 634 | } 635 | 636 | rdataIndex++; // add 1 to jump over duplicate values 637 | hasNulls = true; 638 | return null; 639 | }); 640 | 641 | // Clear null values if exist 642 | if (hasNulls) { 643 | parsed = parsed.filter(function (element) { 644 | return !_underscore2['default'].isNull(element); 645 | }); 646 | } 647 | 648 | // Prepare indexed data. 649 | indexed = _underscore2['default'].indexBy(parsed.toJSON(), field); 650 | } else { 651 | // In case received Inmutable data, indexed data and raw data in props. 652 | data = this.props.rawdata; 653 | parsed = this.props.data; 654 | indexed = this.props.indexed; 655 | } 656 | 657 | return { 658 | rawdata: data, 659 | data: parsed, 660 | indexed: indexed 661 | }; 662 | } 663 | 664 | /** 665 | * Function called each time the selection has changed. Apply an update in the components state selection then render again an update the child 666 | * list. 667 | * 668 | * @param (Set object) selection The selected values using the values of the selected data. 669 | * @param (Boolean) emptySelection When allowsEmptySelection is true and someone wants the empty selection. 670 | */ 671 | 672 | }, { 673 | key: 'handleSelectionChange', 674 | value: function handleSelectionChange(selection) { 675 | var emptySelection = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 676 | 677 | if (!emptySelection) { 678 | this.triggerSelection(selection); 679 | } else { 680 | this.sendEmptySelection(); 681 | } 682 | } 683 | 684 | /** 685 | * Function called each time the search field has changed. Filter the data by using the received search field value. 686 | * 687 | * @param (String) value String written in the search field 688 | */ 689 | 690 | }, { 691 | key: 'handleSearch', 692 | value: function handleSearch(value) { 693 | var _this5 = this; 694 | 695 | var lValue = value ? value : null, 696 | filter = null; 697 | var data = this.state.initialData, 698 | filteredData = data, 699 | selection = this.state.selection; 700 | var displayField = this.state.displayField, 701 | idField = this.state.idField; 702 | var hasFilter = typeof this.props.filter == 'function'; 703 | 704 | // When the search field has been clear then the value will be null and the data will be the same as initialData, otherwise 705 | // the data will be filtered using the .filter() function of Inmutable lib. It return a Inmutable obj with the elements that 706 | // match the expresion in the parameter. 707 | if (value) { 708 | lValue = _normalize2['default'].normalize(lValue); 709 | 710 | // If the prop `filter´ has a function then use if to filter as an interator over the indexed data. 711 | if (hasFilter) { 712 | (function () { 713 | var filtered = null, 714 | filteredIndexes = new Set(); 715 | 716 | // Filter indexed data using the funtion 717 | _underscore2['default'].each(_this5.state.initialIndexed, function (element) { 718 | if (_this5.props.filter(element, lValue)) { 719 | filteredIndexes.add(element._rowIndex); 720 | } 721 | }); 722 | 723 | // Then get the data that match with that indexed data 724 | filteredData = data.filter(function (element) { 725 | return filteredIndexes.has(element.get('_rowIndex')); 726 | }); 727 | })(); 728 | } else { 729 | filteredData = data.filter(function (element) { 730 | filter = element.get(_this5.props.filterField, null) || element.get(displayField); 731 | 732 | // When it's a function then use the field in filterField to search, if this field doesn't exist then use the field name or then idField. 733 | if (typeof filter == 'function') { 734 | filter = element.get('name', null) || element.get(idField); 735 | } 736 | 737 | filter = _normalize2['default'].normalize(filter); 738 | return filter.indexOf(lValue) >= 0; 739 | }); 740 | } 741 | } 742 | 743 | // Apply selection 744 | filteredData = filteredData.map(function (element) { 745 | if (selection.has(element.get(idField, null))) { 746 | element = element.set('_selected', true); 747 | } 748 | 749 | return element; 750 | }); 751 | 752 | this.setState({ 753 | data: filteredData 754 | }, this.sendSearch(lValue)); 755 | } 756 | 757 | /** 758 | * Get the data that match with the selection in params and send the data and the selection to a function whichs name is afterSelect 759 | * if this function was set up in the component props 760 | */ 761 | 762 | }, { 763 | key: 'sendSelection', 764 | value: function sendSelection() { 765 | var _this6 = this; 766 | 767 | var hasAfterSelect = typeof this.props.afterSelect == 'function', 768 | hasGetSelection = typeof this.props.afterSelectGetSelection == 'function'; 769 | 770 | if (hasAfterSelect || hasGetSelection) { 771 | (function () { 772 | var selectionArray = [], 773 | selection = _this6.state.selection; 774 | 775 | // Parse the selection to return it as an array instead of a Set obj 776 | selection.forEach(function (item) { 777 | selectionArray.push(item.toString()); 778 | }); 779 | 780 | if (hasGetSelection) { 781 | // When you just need the selection but no data 782 | _this6.props.afterSelectGetSelection.call(_this6, selectionArray, selection); // selection array / selection Set() 783 | } 784 | 785 | if (hasAfterSelect) { 786 | (function () { 787 | var selectedData = [], 788 | properId = null, 789 | rowIndex = null, 790 | filteredData = null; 791 | var _state = _this6.state; 792 | var indexedData = _state.indexedData; 793 | var initialData = _state.initialData; 794 | var rawData = _state.rawData; 795 | var data = _state.data; 796 | 797 | var fields = new Set(_underscore2['default'].keys(rawData.get(0).toJSON())), 798 | hasIdField = fields.has(_this6.state.idField) ? true : false; 799 | 800 | if (hasIdField) { 801 | selectedData = rawData.filter(function (element) { 802 | return selection.has(element.get(_this6.state.idField).toString()); 803 | }); 804 | } else { 805 | // Get the data (initialData) that match with the selection 806 | filteredData = initialData.filter(function (element) { 807 | return selection.has(element.get(_this6.state.idField)); 808 | }); 809 | 810 | // Then from the filtered data get the raw data that match with the selection 811 | selectedData = filteredData.map(function (row) { 812 | properId = row.get(_this6.state.idField); 813 | rowIndex = _this6.state.initialIndexed[properId]._rawDataIndex; 814 | 815 | return rawData.get(rowIndex); 816 | }); 817 | } 818 | 819 | _this6.props.afterSelect.call(_this6, selectedData.toJSON(), selectionArray); 820 | })(); 821 | } 822 | })(); 823 | } 824 | } 825 | }, { 826 | key: 'sendEmptySelection', 827 | value: function sendEmptySelection() { 828 | var _this7 = this; 829 | 830 | var hasAfterSelect = typeof this.props.afterSelect == 'function', 831 | hasGetSelection = typeof this.props.afterSelectGetSelection == 'function'; 832 | 833 | if (hasAfterSelect || hasGetSelection) { 834 | if (hasGetSelection) { 835 | // When you just need the selection but no data 836 | this.props.afterSelectGetSelection.call(this, [''], new Set('')); 837 | } 838 | 839 | if (hasAfterSelect) { 840 | (function () { 841 | var filteredData = null, 842 | rawData = _this7.state.rawData, 843 | id = undefined, 844 | display = undefined; 845 | 846 | // Get the data (rawData) that have idField or displayfield equals to empty string 847 | filteredData = rawData.filter(function (element) { 848 | id = element.get(_this7.state.idField); 849 | display = element.get(_this7.state.displayField); 850 | return display === '' || display === null || id === '' || id === null; 851 | }); 852 | 853 | _this7.props.afterSelect.call(_this7, filteredData.toJSON(), ['']); 854 | })(); 855 | } 856 | } 857 | } 858 | 859 | /** 860 | * Send the written string in the search field to the afterSearch function if it was set up in the components props 861 | * 862 | * @param (String) searchValue String written in the search field 863 | */ 864 | 865 | }, { 866 | key: 'sendSearch', 867 | value: function sendSearch(searchValue) { 868 | if (typeof this.props.afterSearch == 'function') { 869 | this.props.afterSearch.call(this, searchValue); 870 | } 871 | } 872 | }, { 873 | key: 'render', 874 | value: function render() { 875 | var messages = this.getTranslatedMessages(), 876 | content = null, 877 | data = this.state.data, 878 | selection = new Set(), 879 | allSelected = this.state.allSelected, 880 | className = "proper-search"; 881 | 882 | if (this.props.className) { 883 | className += ' ' + this.props.className; 884 | } 885 | 886 | if (this.state.ready) { 887 | this.state.selection.forEach(function (element) { 888 | selection.add(element); 889 | }); 890 | 891 | content = _react2['default'].createElement( 892 | 'div', 893 | null, 894 | _react2['default'].createElement(_reactPropersearchField2['default'], { 895 | onSearch: this.handleSearch.bind(this), 896 | onEnter: this.props.onEnter, 897 | className: this.props.fieldClass, 898 | placeholder: this.props.placeholder, 899 | defaultValue: this.props.defaultSearch, 900 | searchIcon: this.props.searchIcon, 901 | clearIcon: this.props.clearIcon, 902 | throttle: this.props.throttle, 903 | minLength: this.props.minLength, 904 | autoComplete: this.props.autoComplete 905 | }), 906 | _react2['default'].createElement(_searchList2['default'], { 907 | data: data, 908 | rowFormater: this.props.rowFormater, 909 | indexedData: this.state.initialIndexed, 910 | className: this.props.listClass, 911 | idField: this.state.idField, 912 | displayField: this.state.displayField, 913 | onSelectionChange: this.handleSelectionChange.bind(this), 914 | messages: messages, 915 | selection: selection, 916 | allSelected: allSelected, 917 | multiSelect: this.props.multiSelect, 918 | listHeight: this.props.listHeight, 919 | listWidth: this.props.listWidth, 920 | listRowHeight: this.props.listRowHeight, 921 | listElementClass: this.props.listElementClass, 922 | showIcon: this.props.listShowIcon, 923 | cacheManager: this.props.cacheManager, 924 | allowsEmptySelection: this.props.allowsEmptySelection, 925 | hiddenSelection: this.props.hiddenSelection 926 | }) 927 | ); 928 | } else { 929 | content = _react2['default'].createElement( 930 | 'div', 931 | { className: 'proper-search-loading' }, 932 | messages.loading 933 | ); 934 | } 935 | 936 | return _react2['default'].createElement( 937 | 'div', 938 | { className: "proper-search " + className }, 939 | content 940 | ); 941 | } 942 | }]); 943 | 944 | return Search; 945 | }(_react2['default'].Component); 946 | 947 | ; 948 | 949 | Search.defaultProps = getDefaultProps(); 950 | exports['default'] = Search; 951 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/components/searchList.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | var _underscore = require('underscore'); 14 | 15 | var _underscore2 = _interopRequireDefault(_underscore); 16 | 17 | var _reactImmutableRenderMixin = require('react-immutable-render-mixin'); 18 | 19 | var _reactVirtualized = require('react-virtualized'); 20 | 21 | var _reactDimensions = require('react-dimensions'); 22 | 23 | var _reactDimensions2 = _interopRequireDefault(_reactDimensions); 24 | 25 | var _cache = require('../lib/cache'); 26 | 27 | var _cache2 = _interopRequireDefault(_cache); 28 | 29 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 30 | 31 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 32 | 33 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 34 | 35 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 36 | 37 | var Set = require('es6-set'); 38 | 39 | // For more info about this read ReadMe.md 40 | function getDefaultProps() { 41 | return { 42 | data: null, 43 | indexedData: null, // Just when you use a function as a display field. (array) (Full indexed data not filted) 44 | onSelectionChange: null, 45 | rowFormater: null, // function 46 | multiSelect: false, 47 | messages: null, 48 | selection: new Set(), 49 | allSelected: false, 50 | listRowHeight: 26, 51 | listHeight: 200, 52 | listWidth: 100, // Container width by default 53 | idField: 'value', 54 | displayField: 'label', 55 | showIcon: true, 56 | listElementClass: null, 57 | allowsEmptySelection: false, 58 | hiddenSelection: null, 59 | uniqueID: null 60 | }; 61 | } 62 | 63 | /** 64 | * A Component that render a list of selectable items, with single or multiple selection and return the selected items each time a new item be selected. 65 | * 66 | * Simple example usage: 67 | * let handleSelection = function(selection){ 68 | * console.log('Selected values: ' + selection) // The selection is a Set obj 69 | * } 70 | * 71 | * 77 | * ``` 78 | */ 79 | 80 | var SearchList = function (_React$Component) { 81 | _inherits(SearchList, _React$Component); 82 | 83 | function SearchList(props) { 84 | _classCallCheck(this, SearchList); 85 | 86 | var _this = _possibleConstructorReturn(this, (SearchList.__proto__ || Object.getPrototypeOf(SearchList)).call(this, props)); 87 | 88 | _this.state = { 89 | allSelected: props.allSelected, 90 | nothingSelected: props.selection.size == 0, 91 | hiddenSelection: new Set() 92 | }; 93 | return _this; 94 | } 95 | 96 | _createClass(SearchList, [{ 97 | key: 'componentWillMount', 98 | value: function componentWillMount() { 99 | this.uniqueId = this.props.uniqueID ? this.props.uniqueID : _underscore2['default'].uniqueId('search_list_'); 100 | if (this.props.hiddenSelection) { 101 | this.setState({ 102 | hiddenSelection: this.parseHiddenSelection(this.props) 103 | }); 104 | } 105 | } 106 | }, { 107 | key: 'shouldComponentUpdate', 108 | value: function shouldComponentUpdate(nextProps, nextState) { 109 | var propschanged = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(this.props, nextProps); 110 | var stateChanged = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(this.state, nextState); 111 | var somethingChanged = propschanged || stateChanged; 112 | 113 | if (propschanged) { 114 | var nothingSelected = false; 115 | 116 | if (!nextProps.allSelected) nothingSelected = this.isNothingSelected(nextProps.data, nextProps.selection); 117 | 118 | // When the props change update the state. 119 | if (nextProps.allSelected != this.state.allSelected || nothingSelected != this.state.nothingSelected) { 120 | this.setState({ 121 | allSelected: nextProps.allSelected, 122 | nothingSelected: nothingSelected 123 | }); 124 | } 125 | } 126 | 127 | return somethingChanged; 128 | } 129 | }, { 130 | key: 'componentWillReceiveProps', 131 | value: function componentWillReceiveProps(newProps) { 132 | var hiddenChange = !(0, _reactImmutableRenderMixin.shallowEqualImmutable)(this.props.hiddenSelection, newProps.hiddenSelection); 133 | var hiddenSelection = undefined; 134 | 135 | if (hiddenChange) { 136 | if (this._virtualScroll) this._virtualScroll.recomputeRowHeights(0); 137 | hiddenSelection = this.parseHiddenSelection(newProps); 138 | this.setState({ 139 | hiddenSelection: hiddenSelection 140 | }); 141 | } 142 | } 143 | 144 | /** 145 | * Function called each time an element of the list is selected. Get the value (value of the idField) of the 146 | * element that was selected, them change the selection and call to onSelectionChange function in the props sending 147 | * the new selection. 148 | * 149 | * @param (String) itemValue Value of the idField of the selected element 150 | * @param (Array) e Element which call the function 151 | */ 152 | 153 | }, { 154 | key: 'handleElementClick', 155 | value: function handleElementClick(itemValue, e) { 156 | e.preventDefault(); 157 | var data = this.props.data, 158 | selection = this.props.selection, 159 | nothingSelected = false, 160 | allSelected = false; 161 | 162 | if (this.props.multiSelect) { 163 | if (selection.has(itemValue)) { 164 | selection['delete'](itemValue); 165 | } else { 166 | selection.add(itemValue); 167 | } 168 | } else { 169 | if (selection.has(itemValue)) selection = new Set();else selection = new Set([itemValue]); 170 | } 171 | 172 | allSelected = this.isAllSelected(data, selection); 173 | if (!allSelected) nothingSelected = this.isNothingSelected(data, selection); 174 | 175 | // If the state has changed update it 176 | if (allSelected != this.state.allSelected || nothingSelected != this.state.nothingSelected) { 177 | this.setState({ 178 | allSelected: allSelected, 179 | nothingSelected: nothingSelected 180 | }); 181 | } 182 | 183 | if (typeof this.props.onSelectionChange == 'function') { 184 | this.props.onSelectionChange.call(this, selection); 185 | } 186 | } 187 | 188 | /** 189 | * Check if all the current data are not selected 190 | * 191 | * @param (array) data The data to compare with selection 192 | * @param (object) selection The current selection Set of values (idField) 193 | */ 194 | 195 | }, { 196 | key: 'isNothingSelected', 197 | value: function isNothingSelected(data, selection) { 198 | var _this2 = this; 199 | 200 | var result = true; 201 | if (selection.size == 0) return true; 202 | 203 | data.forEach(function (element) { 204 | if (selection.has(element.get(_this2.props.idField, null))) { 205 | // Some data not in selection 206 | result = false; 207 | return false; 208 | } 209 | }); 210 | 211 | return result; 212 | } 213 | 214 | /** 215 | * Check if all the current data are selected. 216 | * 217 | * @param (array) data The data to compare with selection 218 | * @param (object) selection The current selection Set of values (idField) 219 | */ 220 | 221 | }, { 222 | key: 'isAllSelected', 223 | value: function isAllSelected(data, selection) { 224 | var _this3 = this; 225 | 226 | var result = true; 227 | if (data.size > selection.size) return false; 228 | 229 | data.forEach(function (element) { 230 | if (!selection.has(element.get(_this3.props.idField, null))) { 231 | // Some data not in selection 232 | result = false; 233 | return false; 234 | } 235 | }); 236 | 237 | return result; 238 | } 239 | 240 | /** 241 | * Function called each time the buttons in the bar of the list has been clicked. Delete or add all the data elements into the selection, just if it has changed. 242 | * 243 | * @param (Boolean) selectAll If its a select all action or an unselect all. 244 | * @param (Array) e Element which call the function 245 | */ 246 | 247 | }, { 248 | key: 'handleSelectAll', 249 | value: function handleSelectAll(selectAll, e) { 250 | e.preventDefault(); 251 | 252 | var newData = [], 253 | data = this.props.data, 254 | field = this.props.idField; 255 | var selection = this.props.selection; 256 | var hasChanged = selectAll != this.state.allSelected || !selectAll && !this.state.nothingSelected; // nothingSelected = false then something is selected 257 | 258 | if (selectAll && hasChanged) { 259 | data.forEach(function (element) { 260 | selection.add(element.get(field, null)); 261 | }); 262 | } else { 263 | data.forEach(function (element) { 264 | selection['delete'](element.get(field, null)); 265 | }); 266 | } 267 | 268 | if (hasChanged) { 269 | this.setState({ 270 | allSelected: selectAll, 271 | nothingSelected: !selectAll 272 | }); 273 | } 274 | 275 | if (typeof this.props.onSelectionChange == 'function' && hasChanged) { 276 | this.props.onSelectionChange.call(this, selection); 277 | } 278 | } 279 | 280 | /** 281 | * Function called each time the buttons (select empty) in the bar of the list has been clicked (In case empty selection allowed). 282 | * 283 | * @param (Array) e Element which call the function 284 | */ 285 | 286 | }, { 287 | key: 'handleSelectEmpty', 288 | value: function handleSelectEmpty(e) { 289 | if (typeof this.props.onSelectionChange == 'function') { 290 | this.props.onSelectionChange.call(this, null, true); 291 | } 292 | } 293 | 294 | /** 295 | * Parse the hidden selection if that property contains somethings. 296 | * 297 | * @param (array) props Component props (or nextProps) 298 | * @return (Set) hiddenSelection The hidden rows. 299 | */ 300 | 301 | }, { 302 | key: 'parseHiddenSelection', 303 | value: function parseHiddenSelection() { 304 | var props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.props; 305 | 306 | var hidden = [], 307 | isArray = _underscore2['default'].isArray(props.hiddenSelection), 308 | isObject = _underscore2['default'].isObject(props.hiddenSelection); 309 | 310 | if (!isArray && isObject) return props.hiddenSelection; // Is Set 311 | 312 | if (!isArray) { 313 | // Is String or number 314 | hidden = [props.hiddenSelection.toString()]; 315 | } else if (props.hiddenSelection.length > 0) { 316 | // Is Array 317 | hidden = props.hiddenSelection.toString().split(','); 318 | } 319 | 320 | return new Set(hidden); 321 | } 322 | 323 | /** 324 | * Return the tool bar for the top of the list. It will be displayed only when the selection can be multiple. 325 | * 326 | * @return (html) The toolbar code 327 | */ 328 | 329 | }, { 330 | key: 'getToolbar', 331 | value: function getToolbar() { 332 | var maxWidth = this.props.containerWidth ? this.props.containerWidth / 2 - 1 : 100; 333 | 334 | return _react2['default'].createElement( 335 | 'div', 336 | { className: 'proper-search-list-bar' }, 337 | _react2['default'].createElement( 338 | 'div', 339 | { className: 'btn-group form-inline' }, 340 | _react2['default'].createElement( 341 | 'a', 342 | { 343 | id: 'proper-search-list-bar-check', 344 | className: 'btn-select list-bar-check', role: 'button', 345 | onClick: this.handleSelectAll.bind(this, true), 346 | style: { maxWidth: maxWidth, boxSizing: 'border-box' } }, 347 | _react2['default'].createElement( 348 | 'label', 349 | null, 350 | this.props.messages.all 351 | ) 352 | ), 353 | _react2['default'].createElement( 354 | 'a', 355 | { 356 | id: 'proper-search-list-bar-unCheck', 357 | className: 'btn-select list-bar-unCheck', 358 | role: 'button', 359 | onClick: this.handleSelectAll.bind(this, false), 360 | style: { maxWidth: maxWidth, boxSizing: 'border-box' } }, 361 | _react2['default'].createElement( 362 | 'label', 363 | null, 364 | this.props.messages.none 365 | ) 366 | ) 367 | ) 368 | ); 369 | } 370 | 371 | /** 372 | * Return the tool bar for the top of the list in case Empty Selection allowed 373 | * 374 | * @return (html) The toolbar code 375 | */ 376 | 377 | }, { 378 | key: 'getToolbarForEmpty', 379 | value: function getToolbarForEmpty() { 380 | var allSelected = this.state.allSelected, 381 | selectMessage = undefined, 382 | maxWidth = this.props.containerWidth / 2 - 1; 383 | selectMessage = allSelected ? this.props.messages.none : this.props.messages.all; 384 | 385 | return _react2['default'].createElement( 386 | 'div', 387 | { className: 'proper-search-list-bar' }, 388 | _react2['default'].createElement( 389 | 'div', 390 | { className: 'btn-group form-inline' }, 391 | _react2['default'].createElement( 392 | 'a', 393 | { 394 | id: 'proper-search-list-bar-select', 395 | className: 'btn-select list-bar-select', 396 | role: 'button', 397 | onClick: this.handleSelectAll.bind(this, !allSelected), 398 | style: { maxWidth: maxWidth, boxSizing: 'border-box' } }, 399 | _react2['default'].createElement( 400 | 'label', 401 | null, 402 | selectMessage 403 | ) 404 | ), 405 | _react2['default'].createElement( 406 | 'a', 407 | { 408 | id: 'proper-search-list-bar-empty', 409 | className: 'btn-select list-bar-empty', 410 | role: 'button', 411 | onClick: this.handleSelectEmpty.bind(this), 412 | style: { maxWidth: maxWidth, boxSizing: 'border-box' } }, 413 | _react2['default'].createElement( 414 | 'label', 415 | null, 416 | this.props.messages.empty 417 | ) 418 | ) 419 | ) 420 | ); 421 | } 422 | 423 | /** 424 | * Build and return the content of the list. 425 | * 426 | * @param (object) contentData 427 | * - index (integer) Index of the data to be rendered 428 | * - isScrolling (bool) If grid is scrollings 429 | * @return (html) list-row A row of the list 430 | */ 431 | 432 | }, { 433 | key: 'getContent', 434 | value: function getContent(contentData) { 435 | var index = contentData.index; 436 | var icon = null, 437 | selectedClass = null, 438 | className = null, 439 | element = null, 440 | listElementClass = this.props.listElementClass; 441 | var data = this.props.data, 442 | rowdata = undefined, 443 | id = undefined, 444 | displayField = this.props.displayField, 445 | showIcon = this.props.showIcon; 446 | 447 | rowdata = data.get(index); 448 | element = rowdata.get(displayField); 449 | className = "proper-search-list-element"; 450 | id = rowdata.get(this.props.idField); 451 | 452 | if (this.props.multiSelect) { 453 | if (showIcon) { 454 | if (rowdata.get('_selected', false)) { 455 | icon = _react2['default'].createElement('i', { className: 'fa fa-check-square-o' }); 456 | selectedClass = ' proper-search-selected'; 457 | } else { 458 | icon = _react2['default'].createElement('i', { className: 'fa fa-square-o' }); 459 | selectedClass = null; 460 | } 461 | } 462 | } else { 463 | if (rowdata.get('_selected')) selectedClass = ' proper-search-single-selected';else selectedClass = null; 464 | } 465 | 466 | if (listElementClass) { 467 | className += ' ' + listElementClass; 468 | } 469 | 470 | if (selectedClass) { 471 | className += ' ' + selectedClass; 472 | } 473 | 474 | if (typeof element == 'function') { 475 | element = element(this.props.indexedData[id]); 476 | } else if (this.props.rowFormater) { 477 | var ckey = ['search_list', 'list_' + this.uniqueId, 'row__' + rowdata.get(this.props.idField), displayField]; 478 | element = _cache2['default'].read(ckey); 479 | 480 | if (element === undefined) { 481 | element = this.props.rowFormater(rowdata.get(displayField)); 482 | _cache2['default'].write(ckey, element); 483 | } 484 | } 485 | 486 | return _react2['default'].createElement( 487 | 'div', 488 | { key: 'element-' + index, className: className, onClick: this.handleElementClick.bind(this, id) }, 489 | icon, 490 | element 491 | ); 492 | } 493 | /** 494 | * To be rendered when the data has no data (Ex. filtered data) 495 | * 496 | * @return (node) An div with a message 497 | */ 498 | 499 | }, { 500 | key: 'noRowsRenderer', 501 | value: function noRowsRenderer() { 502 | return _react2['default'].createElement( 503 | 'div', 504 | { key: 'element-0', className: "proper-search-list search-list-no-data" }, 505 | this.props.messages.noData 506 | ); 507 | } 508 | 509 | /** 510 | * Function called to get the content of each element of the list. 511 | * 512 | * @param (object) contentData 513 | * - index (integer) Index of the data to be rendered 514 | * - isScrolling (bool) If grid is scrollings 515 | * @return (node) element The element on the index position 516 | */ 517 | 518 | }, { 519 | key: 'rowRenderer', 520 | value: function rowRenderer(contentData) { 521 | return this.getContent(contentData); 522 | } 523 | 524 | /** 525 | * Function that gets the height for the current row of the list. 526 | * 527 | * @param (object) rowData It's an object that contains the index of the current row 528 | * @return (integer) rowHeight The height of each row. 529 | */ 530 | 531 | }, { 532 | key: 'getRowHeight', 533 | value: function getRowHeight(rowData) { 534 | var id = this.props.data.get(rowData.index).get(this.props.idField); 535 | return this.state.hiddenSelection.has(id) ? 0 : this.props.listRowHeight; 536 | } 537 | }, { 538 | key: 'render', 539 | value: function render() { 540 | var _this4 = this; 541 | 542 | var toolbar = null, 543 | rowHeight = this.props.listRowHeight, 544 | className = "proper-search-list"; 545 | 546 | if (this.props.multiSelect) { 547 | toolbar = this.props.allowsEmptySelection ? this.getToolbarForEmpty() : this.getToolbar(); 548 | } 549 | 550 | if (this.props.className) { 551 | className += ' ' + this.props.className; 552 | } 553 | 554 | if (this.state.hiddenSelection.size > 0) { 555 | rowHeight = this.getRowHeight.bind(this); 556 | } 557 | 558 | return _react2['default'].createElement( 559 | 'div', 560 | { className: className }, 561 | toolbar, 562 | _react2['default'].createElement(_reactVirtualized.VirtualScroll, { 563 | ref: function ref(_ref) { 564 | _this4._virtualScroll = _ref; 565 | }, 566 | className: "proper-search-list-virtual", 567 | width: this.props.listWidth || this.props.containerWidth, 568 | height: this.props.listHeight, 569 | rowRenderer: this.rowRenderer.bind(this), 570 | rowHeight: rowHeight, 571 | noRowsRenderer: this.noRowsRenderer.bind(this), 572 | rowCount: this.props.data.size, 573 | overscanRowsCount: 5 574 | }) 575 | ); 576 | } 577 | }]); 578 | 579 | return SearchList; 580 | }(_react2['default'].Component); 581 | 582 | SearchList.defaultProps = getDefaultProps(); 583 | var toExport = process.env.NODE_ENV === 'Test' ? SearchList : (0, _reactDimensions2['default'])()(SearchList); 584 | exports['default'] = toExport; 585 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/lang/messages.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports['default'] = { 7 | 'SPA': { 8 | all: 'Seleccionar Todo', 9 | none: 'Deseleccionar Todo', 10 | empty: 'Seleccionar Vacios', 11 | notEmpty: 'Deseleccionar Vacios', 12 | loading: 'Cargando...', 13 | noData: 'No se encontró ningún elemento', 14 | errorIdField: 'No se pudo cambiar el `idField´, el campo', 15 | errorDisplayField: 'No se pudo cambiar el `displayField´, el campo', 16 | errorData: 'no existe en el array de datos o no ha cambiado' 17 | }, 18 | 'ENG': { 19 | all: 'Select All', 20 | none: 'Unselect All', 21 | empty: 'Select Empty', 22 | notEmpty: 'Unselect Empty', 23 | loading: 'Loading...', 24 | noData: 'No data found', 25 | errorIdField: "Couldn\'t change the `idField´, the field", 26 | errorDisplayField: "Couldn\'t change the `displayField´, the field", 27 | errorData: 'doesn\'t exist in the data array or has no changes' 28 | } 29 | }; 30 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/lib/cache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _dotObject = require('dot-object'); 10 | 11 | var _dotObject2 = _interopRequireDefault(_dotObject); 12 | 13 | var _underscore = require('underscore'); 14 | 15 | var _deepmerge = require('deepmerge'); 16 | 17 | var _deepmerge2 = _interopRequireDefault(_deepmerge); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } 20 | 21 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 22 | 23 | var cache = {}; 24 | 25 | function parseKey(key) { 26 | return (0, _underscore.map)(key, function (k) { 27 | return k.toString().replace('.', '_'); 28 | }).join('.'); 29 | } 30 | 31 | var RowCache = function () { 32 | function RowCache() { 33 | var base = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 34 | 35 | _classCallCheck(this, RowCache); 36 | 37 | this.init(base); 38 | } 39 | 40 | _createClass(RowCache, [{ 41 | key: 'init', 42 | value: function init() { 43 | var base = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; 44 | 45 | cache = base; 46 | 47 | return this; 48 | } 49 | }, { 50 | key: 'read', 51 | value: function read(key) { 52 | var k = parseKey(key); 53 | return _dotObject2['default'].pick(k, cache); 54 | } 55 | }, { 56 | key: 'write', 57 | value: function write(key, value) { 58 | var k = parseKey(key); 59 | var writable = {}; 60 | 61 | writable[k] = value; 62 | writable = _dotObject2['default'].object(writable); 63 | 64 | cache = (0, _deepmerge2['default'])(cache, writable); 65 | 66 | return this; 67 | } 68 | }, { 69 | key: 'flush', 70 | value: function flush() { 71 | var key = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; 72 | 73 | if (key) { 74 | var k = parseKey(key); 75 | _dotObject2['default'].remove(k, cache); 76 | } else { 77 | this.init(); 78 | } 79 | 80 | return this; 81 | } 82 | }]); 83 | 84 | return RowCache; 85 | }(); 86 | 87 | var rowcache = new RowCache(); 88 | 89 | exports['default'] = rowcache; 90 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/propersearch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _search = require("./components/search"); 8 | 9 | var _search2 = _interopRequireDefault(_search); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; } 12 | 13 | if (process.env.APP_ENV === 'browser') { 14 | require("../css/style.scss"); 15 | } 16 | 17 | exports["default"] = _search2["default"]; 18 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/utils/normalize.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | var charMap = { 7 | 'a': ['á', 'Á', 'à', 'À', 'ã', 'Ã', 'â', 'Â', 'ä', 'Ä', 'å', 'Å', 'ā', 'Ā', 'ą', 'Ą'], 8 | 'e': ['é', 'É', 'è', 'È', 'ê', 'Ê', 'ë', 'Ë', 'ē', 'Ē', 'ė', 'Ė', 'ę', 'Ę'], 9 | 'i': ['î', 'Î', 'í', 'Í', 'ì', 'Ì', 'ï', 'Ï', 'ī', 'Ī', 'į', 'Į'], 10 | 'l': ['ł', 'Ł'], 11 | 'o': ['ô', 'Ô', 'ò', 'Ò', 'ø', 'Ø', 'ō', 'Ō', 'ó', 'Ó', 'õ', 'Õ', 'ö', 'Ö'], 12 | 'u': ['û', 'Û', 'ú', 'Ú', 'ù', 'Ù', 'ü', 'Ü', 'ū', 'Ū'], 13 | 'c': ['ç', 'Ç', 'č', 'Č', 'ć', 'Ć'], 14 | 's': ['ś', 'Ś', 'š', 'Š'], 15 | 'z': ['ź', 'Ź', 'ż', 'Ż'], 16 | '': ['@', '#', '~', '$', '!', 'º', '|', '"', '·', '%', '&', '¬', '/', '(', ')', '=', '?', '¿', '¡', '*', '+', '^', '`', '-', '´', '{', '}', 'ç', ';', ':', '.'] 17 | }; 18 | 19 | exports['default'] = { 20 | normalize: function normalize(value) { 21 | var parseToLower = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; 22 | 23 | var rex = null; 24 | 25 | for (var element in charMap) { 26 | rex = new RegExp('[' + charMap[element].toString() + ']', 'g'); 27 | 28 | try { 29 | value = value.replace(rex, element); 30 | } catch (e) { 31 | console.log('error', value); 32 | } 33 | } 34 | return parseToLower ? value.toLowerCase() : value; 35 | } 36 | }; 37 | module.exports = exports['default']; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-propersearch", 3 | "version": "0.2.15", 4 | "description": "A react component that render a search field with a list of items, allows the user to filter that list and select the items. The component return the selected data when it get selected", 5 | "main": "lib/propersearch.js", 6 | "scripts": { 7 | "test": "webpack && karma start --single-run --no-auto-watch karma.conf.js", 8 | "clean": "rm -Rvf css && rm -Rvf dist && rm -Rvf lib && mkdir dist && mkdir css && mkdir lib", 9 | "watch": "karma start --auto-watch --no-single-run karma.conf.js", 10 | "start": "webpack-dev-server --hot --inline --config webpack.config.example.js", 11 | "build": "babel src/jsx --out-dir lib && webpack && cp -rvf src/css/* css/", 12 | "release": "npm run build && webpack --config webpack.config.min.js", 13 | "build-example": "webpack --config webpack.config.example.dist.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/CBIConsulting/ProperSearch.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "react-component", 22 | "search", 23 | "filter", 24 | "ui" 25 | ], 26 | "author": "cbi", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/CBIConsulting/ProperSearch/issues" 30 | }, 31 | "homepage": "https://github.com/CBIConsulting/ProperSearch#readme", 32 | "devDependencies": { 33 | "autoprefixer-loader": "^2.0.0", 34 | "babel-core": "^6.7.4", 35 | "babel-loader": "^6.2.4", 36 | "babel-plugin-add-module-exports": "^0.1.2", 37 | "babel-plugin-transform-es3-member-expression-literals": "^6.5.0", 38 | "babel-plugin-transform-es3-property-literals": "^6.5.0", 39 | "babel-preset-es2015": "^6.6.0", 40 | "babel-preset-react": "^6.5.0", 41 | "compass-mixins": "^0.12.7", 42 | "core-js": "^2.2.1", 43 | "css-loader": "^0.23.1", 44 | "eslint": "^2.5.3", 45 | "eslint-plugin-react": "^4.2.3", 46 | "extract-text-webpack-plugin": "^1.0.1", 47 | "file-loader": "^0.8.5", 48 | "imports-loader": "^0.6.5", 49 | "jasmine": "^2.4.1", 50 | "jasmine-core": "^2.4.1", 51 | "jquery": "^3.0.0", 52 | "karma": "^0.13.22", 53 | "karma-chrome-launcher": "^0.2.3", 54 | "karma-cli": "^0.1.2", 55 | "karma-coverage": "^0.5.5", 56 | "karma-jasmine": "^0.3.8", 57 | "karma-notification-reporter": "0.0.2", 58 | "karma-phantomjs-launcher": "^1.0.0", 59 | "karma-sourcemap-loader": "^0.3.7", 60 | "karma-webpack": "^1.7.0", 61 | "node-sass": "^3.4.2", 62 | "phantomjs-prebuilt": "^2.1.7", 63 | "react-addons-test-utils": "^0.14.7", 64 | "sass-loader": "^3.2.0", 65 | "style-loader": "^0.13.1", 66 | "webpack": "^1.13.1", 67 | "webpack-dev-server": "^1.14.1" 68 | }, 69 | "dependencies": { 70 | "es6-set": "^0.1.4", 71 | "immutable": "^3.7.6", 72 | "react": "^0.14.7", 73 | "deepmerge": "^0.2.10", 74 | "dot-object": "^1.4.1", 75 | "react-propersearch-field": "^0.1.0", 76 | "react-dimensions": "~1.0.2", 77 | "react-dom": "^0.14.7", 78 | "react-addons-shallow-compare": "^0.14.7", 79 | "react-immutable-render-mixin": "^0.9.5", 80 | "react-virtualized": "^7.12.3", 81 | "underscore": "^1.8.3" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/css/_react-virtualized-styles.scss: -------------------------------------------------------------------------------- 1 | /* Grid default theme */ 2 | 3 | .Grid { 4 | position: relative; 5 | overflow: auto; 6 | -webkit-overflow-scrolling: touch; 7 | 8 | /* Without this property, Chrome repaints the entire Grid any time a new row or column is added. 9 | Firefox only repaints the new row or column (regardless of this property). 10 | Safari and IE don't support the property at all. */ 11 | will-change: transform; 12 | } 13 | 14 | .Grid__innerScrollContainer { 15 | box-sizing: border-box; 16 | overflow: hidden; 17 | } 18 | 19 | .Grid__cell { 20 | position: absolute; 21 | } 22 | 23 | /* FlexTable default theme */ 24 | 25 | .FlexTable { 26 | } 27 | 28 | .FlexTable__Grid { 29 | overflow-x: hidden; 30 | } 31 | 32 | .FlexTable__headerRow { 33 | font-weight: 700; 34 | text-transform: uppercase; 35 | display: flex; 36 | flex-direction: row; 37 | align-items: center; 38 | overflow: hidden; 39 | } 40 | .FlexTable__headerTruncatedText { 41 | white-space: nowrap; 42 | text-overflow: ellipsis; 43 | overflow: hidden; 44 | } 45 | .FlexTable__row { 46 | display: flex; 47 | flex-direction: row; 48 | align-items: center; 49 | overflow: hidden; 50 | } 51 | 52 | .FlexTable__headerColumn, 53 | .FlexTable__rowColumn { 54 | margin-right: 10px; 55 | min-width: 0px; 56 | } 57 | 58 | .FlexTable__headerColumn:first-of-type, 59 | .FlexTable__rowColumn:first-of-type { 60 | margin-left: 10px; 61 | } 62 | .FlexTable__headerColumn { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: row; 66 | overflow: hidden; 67 | } 68 | .FlexTable__sortableHeaderColumn { 69 | cursor: pointer; 70 | } 71 | .FlexTable__rowColumn { 72 | justify-content: center; 73 | flex-direction: column; 74 | display: flex; 75 | overflow: hidden; 76 | height: 100%; 77 | } 78 | 79 | .FlexTable__sortableHeaderIconContainer { 80 | display: flex; 81 | align-items: center; 82 | } 83 | .FlexTable__sortableHeaderIcon { 84 | flex: 0 0 24px; 85 | height: 1em; 86 | width: 1em; 87 | fill: currentColor; 88 | } 89 | 90 | .FlexTable__truncatedColumnText { 91 | text-overflow: ellipsis; 92 | overflow: hidden; 93 | } 94 | 95 | /* VirtualScroll default theme */ 96 | 97 | .VirtualScroll { 98 | position: relative; 99 | overflow-y: auto; 100 | overflow-x: hidden; 101 | -webkit-overflow-scrolling: touch; 102 | } 103 | 104 | .VirtualScroll__innerScrollContainer { 105 | box-sizing: border-box; 106 | overflow: hidden; 107 | } 108 | 109 | .VirtualScroll__row { 110 | position: absolute; 111 | } -------------------------------------------------------------------------------- /src/css/style.scss: -------------------------------------------------------------------------------- 1 | @import "compass"; 2 | @import "react-virtualized-styles"; 3 | 4 | .proper-search { 5 | background-color: #fff; 6 | 7 | .proper-search-field { 8 | font-size: 14px; 9 | margin: 0; 10 | line-height: 22px; 11 | border: none; 12 | 13 | .proper-search-input { 14 | background: #fff; 15 | padding: 3px 2px; 16 | background-color: #ccc; 17 | @include border-radius(3px 2px, 2px 3px); 18 | 19 | input[type="text"] { 20 | width: 100%; 21 | height: 1.8em; 22 | margin-bottom: 0; 23 | color: #777777; 24 | padding-left: 30px; 25 | padding-right: 30px; 26 | border: none; 27 | outline: none; 28 | box-shadow: none; 29 | webkit-box-shadow: none; 30 | } 31 | 32 | i.proper-search-field-icon { 33 | color: #777777; 34 | overflow: visible; 35 | margin-right: -20px; 36 | position: absolute; 37 | border: 0; 38 | padding: 4px; 39 | } 40 | 41 | .btn-clear { 42 | i { 43 | color: #777777; 44 | } 45 | background: none; 46 | overflow: visible; 47 | position: absolute; 48 | border: 0; 49 | padding-top: 0.2em; 50 | margin-left: -26px; 51 | outline: none; 52 | 53 | &:active, &:focus { 54 | box-shadow: none; 55 | webkit-box-shadow: none; 56 | } 57 | } 58 | } 59 | } 60 | 61 | .proper-search-list { 62 | .proper-search-list-bar { 63 | background-color: #f6f7f8; 64 | background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjAuNSIgeTE9IjEuMCIgeDI9IjAuNSIgeTI9IjAuMCI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2U2ZTZlNiIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); 65 | background-size: 100%; 66 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, rgba(230, 230, 230, 0.61)), color-stop(100%, #ffffff)); 67 | background-image: -moz-linear-gradient(bottom, rgba(230,230,230,0.61), #ffffff); 68 | background-image: -webkit-linear-gradient(#fff,#efefef); 69 | background-image: linear-gradient(#fff,#efefef); 70 | padding: 1px 0; 71 | width: 100%; 72 | border: 0; 73 | line-height: 5px; 74 | 75 | .btn-group { 76 | width: 100%; 77 | } 78 | 79 | .btn-group label { 80 | position: relative; 81 | cursor: pointer; 82 | margin: 0; 83 | font-weight: 100; 84 | font-family: Helvetica; 85 | font-size: x-small; 86 | } 87 | 88 | .btn-group .btn-select { 89 | display: inline-block; 90 | line-height: 1.4; 91 | text-align: center; 92 | width: 100%; 93 | cursor: pointer !important; 94 | border: none; 95 | margin: 0; 96 | padding: 5px 8px 5px 8px !important; 97 | border-radius: 0; 98 | color: #333; 99 | } 100 | 101 | .btn-group .btn-select:hover, 102 | .btn-group .btn-select:focus, 103 | .btn-group .btn-select:active, 104 | .btn-group .btn-select.disabled, 105 | .btn-group .btn-select[disabled] { 106 | background-color: #CECECE; 107 | text-decoration: none; 108 | background-image: -webkit-gradient(linear, 50% 100%, 50% 0%, color-stop(0%, #CECECE), color-stop(100%, #ffffff)); 109 | background-image: -moz-linear-gradient(bottom, #CECECE, #ffffff); 110 | background-image: -webkit-linear-gradient(#fff,#CECECE); 111 | background-image: linear-gradient(#fff,#CECECE); 112 | } 113 | } 114 | 115 | .proper-search-list-virtual { 116 | &:focus,&:active { 117 | border: none; 118 | outline: none; 119 | } 120 | 121 | .proper-search-list-element { 122 | height: inherit; 123 | width: inherit; 124 | padding-left: 0.3em; 125 | margin-bottom: 0.1em; 126 | padding-top: 0.1em; 127 | cursor: pointer; 128 | white-space: nowrap; 129 | overflow: hidden; 130 | text-overflow: ellipsis; 131 | box-sizing: border-box; 132 | 133 | &.proper-search-selected { 134 | i { 135 | color: #10AB10; 136 | } 137 | } 138 | 139 | &.proper-search-single-selected { 140 | background-color: #C7EBFF; 141 | } 142 | 143 | i { 144 | width: 14.5px; 145 | margin-right: 8px; 146 | } 147 | } 148 | } 149 | } 150 | 151 | .proper-search-loading { 152 | text-align: center; 153 | font-size: 14px; 154 | margin: auto; 155 | } 156 | } -------------------------------------------------------------------------------- /src/jsx/components/__tests__/search-test.js: -------------------------------------------------------------------------------- 1 | import Search from '../search'; 2 | import TestUtils from "react-addons-test-utils"; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import {Deferred} from 'jquery'; 6 | const Set = require('es6-set'); 7 | 8 | describe('Search', () => { 9 | let wrapper = null; 10 | 11 | beforeEach(function() { 12 | wrapper = document.createElement('div'); 13 | }); 14 | 15 | it('is available', () => { 16 | expect(Search).not.toBe(null); 17 | }); 18 | 19 | it('filter', (done) => { 20 | let def = Deferred(), component = null, node = null, props = getProps(); 21 | props.afterSearch = (searchValue) => { 22 | if (searchValue) def.resolve(searchValue); 23 | } 24 | 25 | component = prepare(props); 26 | 27 | // Check filter 28 | component.handleSearch('foo'); 29 | 30 | def.done((value) => { 31 | expect(component.state.data.toJSON()[0].itemID).toBe('3'); // Testing filter 32 | expect(value).toBe('foo'); 33 | }).always(done); 34 | }); 35 | 36 | it('selection multiselect', (done) => { 37 | let def = Deferred(), component = null, props = getProps(); 38 | props.afterSelect = (data, selection) => { 39 | def.resolve(data, selection); 40 | } 41 | component = prepare(props); 42 | 43 | // Check selection 44 | component.triggerSelection(new Set(['1','2'])); 45 | 46 | def.done((data, selection) => { 47 | expect(data.length).toBe(2); 48 | expect(selection.length).toBe(2); 49 | expect(data).toContain({itemID: 1, display: 'Test1', toFilter: 'fee'}); 50 | expect(data[2]).not.toBeDefined(); 51 | expect(selection).toContain('1'); 52 | expect(selection).toContain('2'); 53 | }).always(done); 54 | }); 55 | 56 | it('selection multiselect display-function', (done) => { 57 | let def = Deferred(), component = null, props = getPropsBigData(); 58 | 59 | props.multiSelect = true; 60 | props.afterSelect = (data, selection) => { 61 | def.resolve(data, selection); 62 | } 63 | component = prepare(props); 64 | 65 | // Check selection 66 | component.triggerSelection(new Set(['1','item_2','item_5','item_6', 'item_8'])); 67 | 68 | def.done((data, selection) => { 69 | expect(data.length).toBe(4); 70 | expect(selection.length).toBe(5); 71 | expect(selection).toContain('1'); 72 | expect(selection).toContain('item_2'); 73 | expect(selection).toContain('item_6'); 74 | expect(selection).not.toContain('item_4'); 75 | expect(data).toContain({itemID: 'item_8', display: formater, name: 'Tést 8',moreFields: 'moreFields values'}) 76 | }).always(done); 77 | }); 78 | 79 | it('selection singleselection', (done) => { 80 | let def = Deferred(), def2 = Deferred(), component = null, props = getPropsBigData(); 81 | 82 | props.defaultSelection = null; 83 | props.afterSelect = (data, selection) => { 84 | if (data.length >= 1) { 85 | def.resolve(data, selection); 86 | } else { 87 | def2.resolve(data, selection) 88 | } 89 | } 90 | component = prepare(props); 91 | component.triggerSelection(new Set(['item_190'])); 92 | 93 | def.done((data, selection) => { 94 | expect(data.length).toBe(1); 95 | expect(selection.length).toBe(1); 96 | expect(selection[0]).toBe('item_190'); 97 | }); 98 | 99 | component.triggerSelection(new Set([])); 100 | def2.done((data, selection) => { 101 | expect(data.length).toBe(0); 102 | expect(selection.length).toBe(0); 103 | }).always(done); 104 | }); 105 | 106 | it('selectAll on filtered data', (done) => { 107 | let def = Deferred(), component = null, node = null, props = null; 108 | props = getPropsBigData(); 109 | 110 | props.multiSelect = true; 111 | props.defaultSelection = null; 112 | props.afterSelect = (data, selection) => { 113 | if (data.length > 1) { 114 | def.resolve(data, selection); 115 | } 116 | } 117 | 118 | component = prepare(props); 119 | node = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-check"); 120 | 121 | component.handleSearch('test 10'); // Filter 122 | TestUtils.Simulate.click(node); // Select All 123 | component.handleSearch(null); // Back to default 124 | 125 | def.done((data, selection) => { 126 | expect(selection.length).toBe(12); 127 | expect(data.length).toBe(12); 128 | }).always(done); 129 | }); 130 | 131 | it('select/unselect all on filtered data && multiple operations', (done) => { 132 | let def = Deferred(), component = null, nodeCheckAll = null, nodeUnCheck = null, nodeElements = null, props = null, promise = null; 133 | props = getPropsBigData(); 134 | promise = { done: () => {return;} } 135 | 136 | spyOn(promise, 'done'); 137 | 138 | props.multiSelect = true; 139 | props.defaultSelection = null; 140 | props.afterSelect = (data, selection) => { 141 | if (promise.done.calls.any()) { 142 | def.resolve(data, selection); 143 | } 144 | } 145 | 146 | component = prepare(props); 147 | nodeCheckAll = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-check"); 148 | nodeUnCheck = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-unCheck"); 149 | nodeElements = TestUtils.scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 150 | 151 | TestUtils.Simulate.click(nodeCheckAll); // Select All 1000 152 | component.handleSearch('test 10'); // Filter (12 elements 1000 110 109... 100 10) 153 | TestUtils.Simulate.click(nodeUnCheck); // UnSelect All (1000 - 12 => 988) 154 | component.handleSearch(null); // Back to default 155 | TestUtils.Simulate.click(nodeElements[3]); // Unselect element 997 (988 - 1 => 987) 156 | component.handleSearch('test 11'); // Filter (11 elements 11 110 111... 116 117 118 119) 157 | TestUtils.Simulate.click(nodeUnCheck); // UnSelect All (987 - 11 => 976) 158 | TestUtils.Simulate.click(nodeCheckAll); // Select All (976 + 11 => 987) 159 | promise.done(); 160 | nodeElements = TestUtils.scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); // update nodeElement to current in view 161 | TestUtils.Simulate.click(nodeElements[3]); // Click element 116 (unSelect) (987 - 1 => 986) 162 | 163 | def.done((data, selection) => { 164 | let set = new Set(selection); 165 | 166 | expect(selection.length).toBe(986); 167 | expect(data.length).toBe(986); 168 | expect(set.has('item_997')).toBe(false); 169 | expect(set.has('item_996')).toBe(true); 170 | expect(set.has('item_116')).toBe(false); 171 | expect(set.has('item_100')).toBe(false); 172 | expect(promise.done).toHaveBeenCalled(); 173 | 174 | }).always(done); 175 | }); 176 | 177 | it('keeps selection after refreshing data && update props', (done) => { 178 | let def = Deferred(), props = getPropsBigData(), promise = null, component = null, nodeCheckAll = null, nodeElements = null, newData = []; 179 | promise = { done: () => {return;} } 180 | 181 | spyOn(promise, 'done'); 182 | 183 | props.multiSelect = true; 184 | props.defaultSelection = null; 185 | props.afterSelect = (data, selection) => { 186 | if (promise.done.calls.any()) { 187 | def.resolve(data, selection); 188 | } 189 | } 190 | 191 | component = ReactDOM.render(, wrapper); 192 | 193 | nodeCheckAll = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-check"); 194 | TestUtils.Simulate.click(nodeCheckAll); // Select All 1000 195 | 196 | for (let i = 1000; i > 0; i--) { 197 | newData.push({id: 'item_' + i, display2: 'Element_' + i, name2: 'Testing2 ' + i}); 198 | }; 199 | props.data = newData; 200 | props.idField = 'id'; 201 | props.displayField = 'display2'; 202 | props.filterField = 'name2'; 203 | 204 | component = ReactDOM.render(, wrapper); // Update props 205 | nodeElements = TestUtils.scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 206 | 207 | promise.done(); 208 | TestUtils.Simulate.click(nodeElements[0]); // Unselect one element (Element 1000) to call afterSelect 209 | 210 | def.done((data, selection) => { 211 | expect(selection.length).toBe(999); 212 | expect(data[0]).toEqual(newData[1]) 213 | expect(data[0].id).toBeDefined(); 214 | expect(data[0].display2).toBeDefined(); 215 | expect(data[0].name2).toBeDefined(); 216 | expect(data[0].moreFields).not.toBeDefined(); 217 | }).always(done); 218 | }); 219 | 220 | }); 221 | 222 | function prepare(props) { 223 | return TestUtils.renderIntoDocument(); 224 | } 225 | 226 | function getProps() { 227 | return { 228 | data: [{itemID:1, display: 'Test1', toFilter: 'fee'}, {itemID:2, display: 'Test2', toFilter: 'fuu'}, {itemID:3, display: 'Test3', toFilter: 'foo'}], 229 | lang: 'SPA', 230 | defaultSelection: [1], 231 | multiSelect: true, 232 | idField: 'itemID', 233 | displayField: 'display', 234 | filterField: 'toFilter', 235 | listWidth: 100, 236 | listHeight: 100 237 | } 238 | } 239 | 240 | 241 | function formater(listElement) { 242 | return ; 243 | } 244 | 245 | function getPropsBigData(length = 1000) { 246 | let data = []; 247 | 248 | for (let i = length; i > 0; i--) { 249 | data.push({itemID: 'item_' + i, display: formater, name: 'Tést ' + i, moreFields: 'moreFields values'}); 250 | }; 251 | 252 | return { 253 | data: data, 254 | idField: 'itemID', 255 | defaultSelection: ['item_1'], 256 | displayField: 'display', 257 | filterField: 'name', 258 | listWidth: 100, 259 | listHeight: 100 260 | } 261 | } -------------------------------------------------------------------------------- /src/jsx/components/__tests__/searchList-test.js: -------------------------------------------------------------------------------- 1 | import SearchList from '../searchList'; 2 | import Immutable from 'immutable'; 3 | import TestUtils from "react-addons-test-utils"; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import _ from 'underscore'; 7 | import {Deferred} from 'jquery'; 8 | import messages from "../../lang/messages"; 9 | const Set = require('es6-set'); 10 | 11 | describe('SearchList', function() { 12 | 13 | it('is available', () => { 14 | expect(SearchList).not.toBe(null); 15 | }); 16 | 17 | it('handleElementClick singleSelection', (done) => { 18 | let def = Deferred(); 19 | let props = getPropsBigData(); 20 | let expected = 'item_89'; 21 | props.multiSelect = false; 22 | props.onSelectionChange = (selection) => { 23 | def.resolve(selection); 24 | } 25 | 26 | let component = prepare(props); 27 | 28 | // Click elements 29 | let nodeElements = TestUtils.scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 30 | TestUtils.Simulate.click(nodeElements[11]); 31 | 32 | def.done((selection) => { 33 | expect(selection.has(expected)).toBe(true); 34 | expect(selection.size).toBe(1); 35 | }).always(done); 36 | }); 37 | 38 | it('handleElementClick multiselect', (done) => { 39 | let def = Deferred(); 40 | let props = getPropsBigData(); 41 | let expected = new Set(['item_98', 'item_100', 'item_88', 'item_91']); 42 | 43 | props.onSelectionChange = (selection) => { 44 | if (selection.size >= 1) { 45 | def.resolve(selection); 46 | } 47 | } 48 | 49 | let component = prepare(props); 50 | 51 | // Click elements 52 | let nodeElements = TestUtils.scryRenderedDOMComponentsWithClass(component, "proper-search-list-element"); 53 | TestUtils.Simulate.click(nodeElements[2]); 54 | TestUtils.Simulate.click(nodeElements[1]); // Old selected item (unselect now) 55 | TestUtils.Simulate.click(nodeElements[0]); 56 | TestUtils.Simulate.click(nodeElements[12]); 57 | TestUtils.Simulate.click(nodeElements[9]); 58 | 59 | def.done((selection) => { 60 | let result = true; 61 | selection.forEach( element => { 62 | if (!expected.has(element)) { 63 | result = false; 64 | return; 65 | } 66 | }); 67 | expect(result).toBe(true); 68 | expect(selection.size).toBe(4); 69 | }).always(done); 70 | }); 71 | 72 | it('handleSelectAll all', (done) => { 73 | let def = Deferred(); 74 | let props = getPropsBigData(); 75 | 76 | props.onSelectionChange = (selection) => { 77 | def.resolve(selection); 78 | } 79 | 80 | let component = prepare(props); 81 | 82 | // Click elements 83 | let node = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-check"); 84 | TestUtils.Simulate.click(node); 85 | 86 | def.done((selection) => { 87 | expect(selection.size).toBe(100); 88 | }).always(done); 89 | }); 90 | 91 | it('handleSelectAll nothing', (done) => { 92 | let def = Deferred(); 93 | let props = getPropsBigData(); 94 | 95 | props.onSelectionChange = (selection) => { 96 | def.resolve(selection); 97 | } 98 | 99 | let component = prepare(props); 100 | 101 | // Click elements 102 | let node = TestUtils.findRenderedDOMComponentWithClass(component, "list-bar-unCheck"); 103 | TestUtils.Simulate.click(node); 104 | 105 | def.done((selection) => { 106 | expect(selection.size).toBe(0); 107 | }).always(done); 108 | }); 109 | }); 110 | 111 | function prepare(props) { 112 | return TestUtils.renderIntoDocument(); 113 | } 114 | 115 | function formater(listElement) { 116 | return ; 117 | } 118 | 119 | function getPropsBigData(length = 100) { 120 | let data = [], preparedData = null; 121 | 122 | for (let i = length; i > 0; i--) { 123 | data.push({itemID: 'item_' + i, display: formater, name: 'Tést ' + i,moreFields: 'moreFields values'}); 124 | }; 125 | 126 | preparedData = prepareData(data, 'itemID'); 127 | 128 | return { 129 | data: preparedData.data, 130 | indexedData: preparedData.indexed, 131 | idField: 'itemID', 132 | multiSelect: true, 133 | selection: new Set(['item_99']), 134 | displayField: 'display', 135 | uniqueID: 'test', 136 | messages: messages['ENG'] 137 | } 138 | } 139 | 140 | /* 141 | * Prepare the data received by the component for the internal working. 142 | */ 143 | function prepareData(newData, field) { 144 | // The data will be inmutable inside the component 145 | let data = Immutable.fromJS(newData), index = 0; 146 | let indexed = [], parsed = []; 147 | 148 | // Parsing data to add new fields (selected or not, field, rowIndex) 149 | parsed = data.map(row => { 150 | if (!row.get(field, false)) { 151 | row = row.set(field, _.uniqueId()); 152 | } else { 153 | row = row.set(field, row.get(field).toString()); 154 | } 155 | 156 | if (!row.get('_selected', false)) { 157 | row = row.set('_selected', false); 158 | } 159 | 160 | row = row.set('_rowIndex', index++); 161 | 162 | return row; 163 | }); 164 | 165 | // Prepare indexed data. 166 | indexed = _.indexBy(parsed.toJSON(), field); 167 | 168 | return { 169 | rawdata: data, 170 | data: parsed, 171 | indexed: indexed 172 | }; 173 | } -------------------------------------------------------------------------------- /src/jsx/components/search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Immutable from 'immutable'; 3 | import _ from 'underscore'; 4 | import SearchList from './searchList'; 5 | import SeachField from 'react-propersearch-field'; 6 | import messages from "../lang/messages"; 7 | import Normalizer from "../utils/normalize"; 8 | import {shallowEqualImmutable} from 'react-immutable-render-mixin'; 9 | import cache from '../lib/cache'; 10 | const Set = require('es6-set'); 11 | 12 | // For more info about this read ReadMe.md 13 | function getDefaultProps() { 14 | return { 15 | data: [], 16 | rawdata: null, // Case you want to use your own inmutable data. Read prepareData() method for more info. 17 | indexed: null, // Case you want to use your own inmutable data. Read prepareData() method for more info. 18 | messages: messages, 19 | lang: 'ENG', 20 | rowFormater: null, // function to format values in render 21 | defaultSelection: null, 22 | hiddenSelection: null, 23 | multiSelect: false, 24 | listWidth: null, 25 | listHeight: 200, 26 | listRowHeight: 26, 27 | afterSelect: null, // Function Get selection and data 28 | afterSelectGetSelection: null, // Function Get just selection (no data) 29 | afterSearch: null, 30 | onEnter: null, // Optional - To do when key down Enter - SearchField 31 | fieldClass: null, 32 | listClass: null, 33 | listElementClass: null, 34 | className: null, 35 | placeholder: 'Search...', 36 | searchIcon: 'fa fa-search fa-fw', 37 | clearIcon: 'fa fa-times fa-fw', 38 | throttle: 160, // milliseconds 39 | minLength: 3, 40 | defaultSearch: null, 41 | autoComplete: 'off', 42 | idField: 'value', 43 | displayField: 'label', 44 | listShowIcon: true, 45 | filter: null, // Optional function (to be used when the displayField is an function too) 46 | filterField: null, // By default it will be the displayField 47 | allowsEmptySelection: false, // Put this to true to get a diferent ToolBar that allows select empty 48 | } 49 | } 50 | 51 | 52 | /** 53 | * A proper search component for react. With a search field and a list of items allows the user to filter that list and select the items. 54 | * The component return the selected data when it's selected. Allows multi and single selection. The list is virtual rendered, was designed 55 | * to handle thousands of elements without sacrificing performance, just render the elements in the view. Used react-virtualized to render the list items. 56 | * 57 | * Simple example usage: 58 | * 59 | * let data = []; 60 | * data.push({ 61 | * value: 1, 62 | * label: 'Apple' 63 | * }); 64 | * 65 | * let afterSelect = (data, selection) => { 66 | * console.info(data); 67 | * console.info(selection); 68 | * } 69 | * 70 | * 75 | * ``` 76 | */ 77 | class Search extends React.Component { 78 | constructor(props) { 79 | super(props); 80 | 81 | let preparedData = this.prepareData(null, props.idField, false, props.displayField); 82 | 83 | this.state = { 84 | data: preparedData.data, // Data to work with (Inmutable) 85 | initialData: preparedData.data, // Same data as initial state.data but this data never changes. (Inmutable) 86 | rawData: preparedData.rawdata, // Received data without any modfication (Inmutable) 87 | indexedData: preparedData.indexed, // Received data indexed (No Inmutable) 88 | initialIndexed: preparedData.indexed, // When data get filtered keep the full indexed 89 | idField: props.idField, // To don't update the idField if that field doesn't exist in the fields of data array 90 | displayField: props.displayField, // same 91 | selection: new Set(), 92 | allSelected: false, 93 | selectionApplied: false, // If the selection has been aplied to the data (mostly for some cases of updating props data) 94 | ready: false 95 | } 96 | } 97 | 98 | componentDidMount() { 99 | this.setDefaultSelection(this.props.defaultSelection); 100 | 101 | this.setState({ 102 | ready: true 103 | }); 104 | } 105 | 106 | shouldComponentUpdate(nextProps, nextState){ 107 | let stateChanged = !shallowEqualImmutable(this.state, nextState); 108 | let propsChanged = !shallowEqualImmutable(this.props, nextProps); 109 | let somethingChanged = propsChanged || stateChanged; 110 | 111 | // Update row indexes when data get filtered 112 | if (this.state.data.size != nextState.data.size) { 113 | let parsed, indexed, data; 114 | 115 | if (nextState.ready) { 116 | if (nextState.data.size === 0) { 117 | data = nextState.data; 118 | indexed = {}; 119 | } else { 120 | parsed = this.prepareData(nextState.data, this.state.idField, true, this.state.displayField); // Force rebuild indexes etc 121 | data = parsed.data; 122 | indexed = parsed.indexed; 123 | } 124 | 125 | this.setState({ 126 | data: data, 127 | indexedData: indexed, 128 | allSelected: this.isAllSelected(data, nextState.selection) 129 | }); 130 | } else { 131 | let selection = nextProps.defaultSelection; 132 | if (!nextProps.multiSelect) selection = nextState.selection.values().next().value || null; 133 | 134 | // props data has been changed in the last call to this method 135 | this.setDefaultSelection(selection); 136 | if (_.isNull(selection) || selection.length === 0) this.setState({ready: true}); // No def selection so then ready 137 | } 138 | 139 | return false; 140 | } 141 | 142 | if (propsChanged) { 143 | let dataChanged = !shallowEqualImmutable(this.props.data, nextProps.data); 144 | let idFieldChanged = this.props.idField != nextProps.idField, displayFieldChanged = this.props.displayField != nextProps.displayField; 145 | let selectionChanged = false, nextSelection = new Set(nextProps.defaultSelection), selection = null; 146 | 147 | if (this.state.selection.size != nextSelection.size) { 148 | selectionChanged = true; 149 | selection = nextProps.defaultSelection; 150 | } else { 151 | this.state.selection.forEach(element => { 152 | if (!nextSelection.has(element)) { 153 | selectionChanged = true; 154 | selection = nextProps.defaultSelection; 155 | return true; 156 | } 157 | }); 158 | } 159 | 160 | if (!nextProps.multiSelect && (nextSelection.size > 1 || this.state.selection.size > 1)) { 161 | selection = nextSelection.size > 1 ? nextProps.defaultSelection : this.state.selection.values().next().value; 162 | if (_.isArray(selection)) selection = selection[0]; 163 | } 164 | 165 | if (idFieldChanged || displayFieldChanged) { 166 | let fieldsSet = new Set(_.keys(nextProps.data[0])); 167 | let messages = this.getTranslatedMessages(); 168 | 169 | // Change idField / displayField but that field doesn't exist in the data 170 | if (!fieldsSet.has(nextProps.idField) || !fieldsSet.has(nextProps.displayField)) { 171 | if (!fieldsSet.has(nextProps.idField)) console.error(messages.errorIdField + ' ' + nextProps.idField + ' ' + messages.errorData); 172 | else console.error(messages.errorDisplayField + ' ' + nextProps.idField + ' ' + messages.errorData); 173 | 174 | return false; 175 | } else { // New idField &&//|| displayField exist in data array fields 176 | if (dataChanged){ 177 | cache.flush('search_list'); 178 | let preparedData = this.prepareData(nextProps.data, nextProps.idField, false, nextProps.displayField); 179 | 180 | this.setState({ 181 | data: preparedData.data, 182 | initialData: preparedData.data, 183 | rawData: preparedData.rawdata, 184 | indexedData: preparedData.indexed, 185 | initialIndexed: preparedData.indexed, 186 | idField: nextProps.idField, 187 | displayField: nextProps.displayField, 188 | ready: false, 189 | selectionApplied: false 190 | }, this.setDefaultSelection(selection)); 191 | 192 | } else { 193 | let initialIndexed = null, indexed = null; 194 | 195 | // If the id field change then the indexed data has to be changed but not for display 196 | if (displayFieldChanged) { 197 | initialIndexed = this.state.initialIndexed; 198 | indexed = this.state.indexedData; 199 | } else { 200 | initialIndexed = _.indexBy(this.state.initialData.toJSON(), nextProps.idField); 201 | indexed = _.indexBy(this.state.data.toJSON(), nextProps.idField); 202 | } 203 | 204 | this.setState({ 205 | indexedData: indexed, 206 | initialIndexed: initialIndexed, 207 | idField: nextProps.idField, 208 | displayField: nextProps.displayField, 209 | ready: false, 210 | selectionApplied: false 211 | }); 212 | } 213 | return false; 214 | } 215 | } 216 | 217 | if (dataChanged){ 218 | cache.flush('search_list'); 219 | let preparedData = this.prepareData(nextProps.data, nextProps.idField, false, nextProps.displayField); 220 | 221 | this.setState({ 222 | data: preparedData.data, 223 | initialData: preparedData.data, 224 | rawData: preparedData.rawdata, 225 | indexedData: preparedData.indexed, 226 | initialIndexed: preparedData.indexed, 227 | ready: false, 228 | selectionApplied: false 229 | }, this.setDefaultSelection(selection)); 230 | 231 | return false; 232 | } 233 | 234 | if (selectionChanged) { 235 | // Default selection does nothing if the selection is null so in that case update the state to restart selection 236 | if (!_.isNull(selection)) { 237 | this.setDefaultSelection(selection); 238 | } else { 239 | this.setState({ 240 | selection: new Set(), 241 | allSelected: false, 242 | ready: true 243 | }); 244 | } 245 | 246 | return false; 247 | } 248 | } 249 | 250 | if (!nextState.ready) { 251 | this.setState({ 252 | ready: true 253 | }); 254 | 255 | return false; 256 | } 257 | 258 | return somethingChanged; 259 | } 260 | 261 | /** 262 | * Before the components update set the updated selection data to the components state. 263 | * 264 | * @param {object} nextProps The props that will be set for the updated component 265 | * @param {object} nextState The state that will be set for the updated component 266 | */ 267 | componentWillUpdate(nextProps, nextState) { 268 | // Selection 269 | if (this.props.multiSelect) { 270 | if (nextState.selection.size !== this.state.selection.size || (!nextState.selectionApplied && nextState.selection.size > 0)){ 271 | this.updateSelectionData(nextState.selection, nextState.allSelected); 272 | } 273 | } else { 274 | let next = nextState.selection.values().next().value || null; 275 | let old = this.state.selection.values().next().value || null; 276 | let oldSize = !_.isNull(this.state.selection) ? this.state.selection.size : 0; 277 | 278 | if (next !== old || oldSize > 1){ 279 | this.updateSelectionData(next); 280 | } 281 | } 282 | 283 | } 284 | 285 | /** 286 | * Method called before the components update to set the new selection to states component and update the data 287 | * 288 | * @param {array} newSelection The new selected rows (Set object) 289 | * @param {array} newAllSelected If the new state has all the rows selected 290 | */ 291 | updateSelectionData(newSelection, newAllSelected = false) { 292 | let newIndexed = _.clone(this.state.indexedData); 293 | let oldSelection = this.state.selection; 294 | let rowid = null, selected = null, rdata = null, curIndex = null, newData = this.state.data, rowIndex = null; 295 | let newSelectionSize = !_.isNull(newSelection) ? newSelection.size : 0; 296 | 297 | // If oldSelection size is bigger than 1 that mean's the props has changed from multiselect to single select so if there is some list items with the selected class 298 | // if should be reset. 299 | if (!this.props.multiSelect && oldSelection.size <= 1) { // Single select 300 | let oldId = oldSelection.values().next().value || null; 301 | let indexedKeys = new Set(_.keys(newIndexed)); 302 | 303 | if (!_.isNull(oldId) && indexedKeys.has(oldId)) { 304 | newIndexed[oldId]._selected = false; // Update indexed data 305 | rowIndex = newIndexed[oldId]._rowIndex; // Get data index 306 | if (newData.get(rowIndex)) { 307 | rdata = newData.get(rowIndex).set('_selected', false); // Change the row in that index 308 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 309 | } 310 | } 311 | 312 | if (!_.isNull(newSelection) && indexedKeys.has(newSelection)) { 313 | newIndexed[newSelection]._selected = true; // Update indexed data 314 | rowIndex = newIndexed[newSelection]._rowIndex; // Get data index 315 | rdata = newData.get(rowIndex).set('_selected', true); // Change the row in that index 316 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 317 | } 318 | 319 | } else if (!newAllSelected && this.isSingleChange(newSelectionSize)) { // Change one row data at a time 320 | let changedId = null, selected = null; 321 | 322 | // If the new selection has not one of the ids of the old selection that means an selected element has been unselected. 323 | oldSelection.forEach(id => { 324 | if (!newSelection.has(id)) { 325 | changedId = id; 326 | selected = false; 327 | return false; 328 | } 329 | }); 330 | 331 | // Otherwise a new row has been selected. Look through the new selection for the new element. 332 | if (!changedId) { 333 | selected = true; 334 | newSelection.forEach(id => { 335 | if (!oldSelection.has(id)) { 336 | changedId = id; 337 | return false; 338 | } 339 | }); 340 | } 341 | 342 | newIndexed[changedId]._selected = selected; // Update indexed data 343 | rowIndex = newIndexed[changedId]._rowIndex; // Get data index 344 | rdata = newData.get(rowIndex).set('_selected', selected); // Change the row in that index 345 | newData = newData.set(rowIndex, rdata); // Set that row in the data object 346 | } else { // Change all data 347 | if (_.isNull(newSelection)) newSelection = new Set(); 348 | else if (!_.isObject(newSelection)) newSelection = new Set([newSelection]); 349 | 350 | newData = newData.map((row) => { 351 | rowid = row.get(this.state.idField); 352 | selected = newSelection.has(rowid.toString()); 353 | rdata = row.set('_selected', selected); 354 | curIndex = newIndexed[rowid]; 355 | 356 | if (curIndex._selected != selected) { // update indexed data 357 | curIndex._selected = selected; 358 | newIndexed[rowid] = curIndex; 359 | } 360 | 361 | return rdata; 362 | }); 363 | } 364 | 365 | this.setState({ 366 | data: newData, 367 | indexedData: newIndexed, 368 | selectionApplied: true 369 | }); 370 | } 371 | 372 | /** 373 | * Check if the selection has more than 1 change. 374 | * 375 | * @param {integer} newSize Size of the new selection 376 | */ 377 | isSingleChange(newSize) { 378 | let oldSize = this.state.selection.size; 379 | 380 | if (oldSize - 1 == newSize || oldSize + 1 == newSize) return true; 381 | else return false; 382 | } 383 | 384 | /** 385 | * Get the translated messages for the component. 386 | * 387 | * @return object Messages of the selected language or in English if the translation for this lang doesn't exist. 388 | */ 389 | getTranslatedMessages() { 390 | if (!_.isObject(this.props.messages)) { 391 | return {}; 392 | } 393 | 394 | if (this.props.messages[this.props.lang]) { 395 | return this.props.messages[this.props.lang]; 396 | } 397 | 398 | return this.props.messages['ENG']; 399 | } 400 | 401 | /** 402 | * In case that the new selection array be different than the selection array in the components state, then update 403 | * the components state with the new data. 404 | * 405 | * @param {array} newSelection The selected rows 406 | * @param {boolean} sendSelection If the selection must be sent or not 407 | */ 408 | triggerSelection(newSelection = new Set(), sendSelection = true) { 409 | if (sendSelection) { 410 | this.setState({ 411 | selection: newSelection, 412 | allSelected: this.isAllSelected(this.state.data, newSelection) 413 | }, this.sendSelection); 414 | } else { 415 | this.setState({ 416 | selection: newSelection, 417 | allSelected: this.isAllSelected(this.state.data, newSelection) 418 | }); 419 | } 420 | } 421 | 422 | /** 423 | * Check if all the current data are selected. 424 | * 425 | * @param {array} data The data to compare with selection 426 | * @param {object} selection The current selection Set of values (idField) 427 | */ 428 | isAllSelected(data, selection) { 429 | let result = true; 430 | if (data.size > selection.size) return false; 431 | 432 | data.forEach((item, index) => { 433 | if (!selection.has(item.get(this.state.idField, null))) { // Some data not in selection 434 | result = false; 435 | return false; 436 | } 437 | }); 438 | 439 | return result; 440 | } 441 | 442 | /** 443 | * Set up the default selection if exist 444 | * 445 | * @param {array || string ... number} defSelection Default selection to be applied to the list 446 | */ 447 | setDefaultSelection(defSelection) { 448 | if (defSelection) { 449 | let selection = null; 450 | 451 | if (defSelection.length == 0) { 452 | selection = new Set(); 453 | } else { 454 | if (!_.isArray(defSelection)) { 455 | selection = new Set([defSelection.toString()]); 456 | } else { 457 | selection = new Set(defSelection.toString().split(',')); 458 | } 459 | } 460 | 461 | selection.delete(''); // Remove empty values 462 | 463 | this.triggerSelection(selection, false); 464 | } 465 | } 466 | 467 | /** 468 | * Prepare the data received by the component for the internal use. 469 | * 470 | * @param (object) newData New data for rebuild. (filtering || props changed) 471 | * @param (string) idField New idField if it has been changed. (props changed) 472 | * @param (boolean) rebuild Rebuild the data. NOTE: If newData its an Immutable you should put this param to true. 473 | * 474 | * @return (array) -rawdata: The same data as the props or the newData in case has been received. 475 | * -indexed: Same as rawdata but indexed by the idField 476 | * -data: Parsed data to add some fields necesary to internal working. 477 | */ 478 | prepareData(newData = null, idField = null, rebuild = false, displayfield = null) { 479 | // The data will be inmutable inside the component 480 | let data = newData || this.props.data, index = 0, rdataIndex = 0, idSet = new Set(), field = idField || this.state.idField, fieldValue; 481 | let indexed = [], parsed = [], rawdata, hasNulls = false; 482 | 483 | // If not Immutable. 484 | // If an Immutable is received in props.data at the components first building the component will work with that data. In that case 485 | // the component should get indexed and rawdata in props. It's up to the developer if he / she wants to work with data from outside 486 | // but it's important to keep in mind that you need a similar data structure (_selected, _rowIndex, idField...) 487 | if (!Immutable.Iterable.isIterable(data) || rebuild) { 488 | data = Immutable.fromJS(data); // If data it's already Immutable the method .fromJS return the same object 489 | 490 | // Parsing data to add new fields (selected or not, field, rowIndex) 491 | parsed = data.map(row => { 492 | fieldValue = row.get(field, false); 493 | 494 | if (!fieldValue) { 495 | fieldValue = _.uniqueId(); 496 | } 497 | 498 | // No rows with same idField. The idField must be unique and also don't render the empty values 499 | if (!idSet.has(fieldValue) && fieldValue !== '' && row.get(displayfield, '') !== '') { 500 | idSet.add(fieldValue); 501 | row = row.set(field, fieldValue.toString()); 502 | 503 | if (!row.get('_selected', false)) { 504 | row = row.set('_selected', false); 505 | } 506 | 507 | row = row.set('_rowIndex', index++); // data row index 508 | row = row.set('_rawDataIndex', rdataIndex++); // rawData row index 509 | 510 | return row; 511 | } 512 | 513 | rdataIndex++; // add 1 to jump over duplicate values 514 | hasNulls = true; 515 | return null; 516 | }); 517 | 518 | // Clear null values if exist 519 | if (hasNulls) { 520 | parsed = parsed.filter(element => !_.isNull(element)); 521 | } 522 | 523 | // Prepare indexed data. 524 | indexed = _.indexBy(parsed.toJSON(), field); 525 | 526 | } else { // In case received Inmutable data, indexed data and raw data in props. 527 | data = this.props.rawdata; 528 | parsed = this.props.data; 529 | indexed = this.props.indexed; 530 | } 531 | 532 | 533 | return { 534 | rawdata: data, 535 | data: parsed, 536 | indexed: indexed 537 | }; 538 | } 539 | 540 | /** 541 | * Function called each time the selection has changed. Apply an update in the components state selection then render again an update the child 542 | * list. 543 | * 544 | * @param (Set object) selection The selected values using the values of the selected data. 545 | * @param (Boolean) emptySelection When allowsEmptySelection is true and someone wants the empty selection. 546 | */ 547 | handleSelectionChange(selection, emptySelection = false) { 548 | if (!emptySelection) { 549 | this.triggerSelection(selection); 550 | } else { 551 | this.sendEmptySelection(); 552 | } 553 | } 554 | 555 | /** 556 | * Function called each time the search field has changed. Filter the data by using the received search field value. 557 | * 558 | * @param (String) value String written in the search field 559 | */ 560 | handleSearch(value) { 561 | let lValue = value ? value : null, filter = null; 562 | let data = this.state.initialData, filteredData = data, selection = this.state.selection; 563 | let displayField = this.state.displayField, idField = this.state.idField; 564 | let hasFilter = (typeof this.props.filter == 'function'); 565 | 566 | // When the search field has been clear then the value will be null and the data will be the same as initialData, otherwise 567 | // the data will be filtered using the .filter() function of Inmutable lib. It return a Inmutable obj with the elements that 568 | // match the expresion in the parameter. 569 | if (value) { 570 | lValue = Normalizer.normalize(lValue); 571 | 572 | // If the prop `filter´ has a function then use if to filter as an interator over the indexed data. 573 | if (hasFilter) { 574 | let filtered = null, filteredIndexes = new Set(); 575 | 576 | // Filter indexed data using the funtion 577 | _.each(this.state.initialIndexed, element => { 578 | if (this.props.filter(element, lValue)) { 579 | filteredIndexes.add(element._rowIndex); 580 | } 581 | }); 582 | 583 | // Then get the data that match with that indexed data 584 | filteredData = data.filter(element => { 585 | return filteredIndexes.has(element.get('_rowIndex')); 586 | }); 587 | } else { 588 | filteredData = data.filter(element => { 589 | filter = element.get(this.props.filterField, null) || element.get(displayField); 590 | 591 | // When it's a function then use the field in filterField to search, if this field doesn't exist then use the field name or then idField. 592 | if (typeof filter == 'function') { 593 | filter = element.get('name', null) || element.get(idField); 594 | } 595 | 596 | filter = Normalizer.normalize(filter); 597 | return filter.indexOf(lValue) >= 0; 598 | }); 599 | } 600 | } 601 | 602 | // Apply selection 603 | filteredData = filteredData.map(element => { 604 | if (selection.has(element.get(idField, null))) { 605 | element = element.set('_selected', true); 606 | } 607 | 608 | return element; 609 | }); 610 | 611 | this.setState({ 612 | data: filteredData 613 | }, this.sendSearch(lValue)); 614 | } 615 | 616 | /** 617 | * Get the data that match with the selection in params and send the data and the selection to a function whichs name is afterSelect 618 | * if this function was set up in the component props 619 | */ 620 | sendSelection() { 621 | let hasAfterSelect = typeof this.props.afterSelect == 'function', hasGetSelection = typeof this.props.afterSelectGetSelection == 'function'; 622 | 623 | if (hasAfterSelect || hasGetSelection) { 624 | let selectionArray = [], selection = this.state.selection; 625 | 626 | // Parse the selection to return it as an array instead of a Set obj 627 | selection.forEach(item => { 628 | selectionArray.push(item.toString()); 629 | }); 630 | 631 | if (hasGetSelection) { // When you just need the selection but no data 632 | this.props.afterSelectGetSelection.call(this, selectionArray, selection); // selection array / selection Set() 633 | } 634 | 635 | if (hasAfterSelect) { 636 | let selectedData = [], properId = null, rowIndex = null, filteredData = null; 637 | let {indexedData, initialData, rawData, data} = this.state; 638 | let fields = new Set(_.keys(rawData.get(0).toJSON())), hasIdField = fields.has(this.state.idField) ? true : false; 639 | 640 | if (hasIdField) { 641 | selectedData = rawData.filter(element => { 642 | return selection.has(element.get(this.state.idField).toString()); 643 | }); 644 | } else { 645 | // Get the data (initialData) that match with the selection 646 | filteredData = initialData.filter(element => selection.has(element.get(this.state.idField))); 647 | 648 | // Then from the filtered data get the raw data that match with the selection 649 | selectedData = filteredData.map(row => { 650 | properId = row.get(this.state.idField); 651 | rowIndex = this.state.initialIndexed[properId]._rawDataIndex; 652 | 653 | return rawData.get(rowIndex); 654 | }); 655 | } 656 | 657 | this.props.afterSelect.call(this, selectedData.toJSON(), selectionArray); 658 | } 659 | } 660 | } 661 | 662 | sendEmptySelection() { 663 | let hasAfterSelect = typeof this.props.afterSelect == 'function', hasGetSelection = typeof this.props.afterSelectGetSelection == 'function'; 664 | 665 | if (hasAfterSelect || hasGetSelection) { 666 | if (hasGetSelection) { // When you just need the selection but no data 667 | this.props.afterSelectGetSelection.call(this, [''], new Set('')); 668 | } 669 | 670 | if (hasAfterSelect) { 671 | let filteredData = null, rawData = this.state.rawData, id, display; 672 | 673 | // Get the data (rawData) that have idField or displayfield equals to empty string 674 | filteredData = rawData.filter(element => { 675 | id = element.get(this.state.idField); 676 | display = element.get(this.state.displayField); 677 | return display === '' || display === null || id === '' || id === null; 678 | }); 679 | 680 | this.props.afterSelect.call(this, filteredData.toJSON(), ['']); 681 | } 682 | } 683 | } 684 | 685 | /** 686 | * Send the written string in the search field to the afterSearch function if it was set up in the components props 687 | * 688 | * @param (String) searchValue String written in the search field 689 | */ 690 | sendSearch(searchValue) { 691 | if (typeof this.props.afterSearch == 'function') { 692 | this.props.afterSearch.call(this, searchValue); 693 | } 694 | } 695 | 696 | render() { 697 | let messages = this.getTranslatedMessages(), 698 | content = null, 699 | data = this.state.data, 700 | selection = new Set(), 701 | allSelected = this.state.allSelected, 702 | className = "proper-search"; 703 | 704 | if (this.props.className) { 705 | className += ' '+this.props.className; 706 | } 707 | 708 | if (this.state.ready) { 709 | this.state.selection.forEach( element => { 710 | selection.add(element); 711 | }); 712 | 713 | content = ( 714 |
715 | 727 | 748 |
749 | ); 750 | 751 | } else { 752 | content =
{messages.loading}
753 | } 754 | 755 | return ( 756 |
757 | {content} 758 |
759 | ); 760 | } 761 | }; 762 | 763 | Search.defaultProps = getDefaultProps(); 764 | export default Search; -------------------------------------------------------------------------------- /src/jsx/components/searchList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'underscore'; 3 | import {shallowEqualImmutable} from 'react-immutable-render-mixin'; 4 | import { VirtualScroll } from 'react-virtualized'; 5 | import Dimensions from 'react-dimensions'; 6 | import cache from '../lib/cache'; 7 | const Set = require('es6-set'); 8 | 9 | // For more info about this read ReadMe.md 10 | function getDefaultProps() { 11 | return { 12 | data: null, 13 | indexedData: null, // Just when you use a function as a display field. (array) (Full indexed data not filted) 14 | onSelectionChange: null, 15 | rowFormater: null, // function 16 | multiSelect: false, 17 | messages: null, 18 | selection: new Set(), 19 | allSelected: false, 20 | listRowHeight: 26, 21 | listHeight: 200, 22 | listWidth: 100, // Container width by default 23 | idField: 'value', 24 | displayField: 'label', 25 | showIcon: true, 26 | listElementClass: null, 27 | allowsEmptySelection: false, 28 | hiddenSelection: null, 29 | uniqueID: null, 30 | } 31 | } 32 | 33 | /** 34 | * A Component that render a list of selectable items, with single or multiple selection and return the selected items each time a new item be selected. 35 | * 36 | * Simple example usage: 37 | * let handleSelection = function(selection){ 38 | * console.log('Selected values: ' + selection) // The selection is a Set obj 39 | * } 40 | * 41 | * 47 | * ``` 48 | */ 49 | class SearchList extends React.Component { 50 | constructor(props){ 51 | super(props); 52 | 53 | this.state = { 54 | allSelected: props.allSelected, 55 | nothingSelected: props.selection.size == 0, 56 | hiddenSelection: new Set() 57 | } 58 | } 59 | 60 | componentWillMount() { 61 | this.uniqueId = this.props.uniqueID ? this.props.uniqueID : _.uniqueId('search_list_'); 62 | if (this.props.hiddenSelection) { 63 | this.setState({ 64 | hiddenSelection: this.parseHiddenSelection(this.props) 65 | }); 66 | } 67 | } 68 | 69 | shouldComponentUpdate(nextProps, nextState){ 70 | let propschanged = !shallowEqualImmutable(this.props, nextProps); 71 | let stateChanged = !shallowEqualImmutable(this.state, nextState); 72 | let somethingChanged = propschanged || stateChanged; 73 | 74 | if (propschanged) { 75 | let nothingSelected = false; 76 | 77 | if (!nextProps.allSelected) nothingSelected = this.isNothingSelected(nextProps.data, nextProps.selection); 78 | 79 | // When the props change update the state. 80 | if (nextProps.allSelected != this.state.allSelected || nothingSelected != this.state.nothingSelected) { 81 | this.setState({ 82 | allSelected: nextProps.allSelected, 83 | nothingSelected: nothingSelected 84 | }); 85 | } 86 | } 87 | 88 | return somethingChanged; 89 | } 90 | 91 | componentWillReceiveProps(newProps) { 92 | let hiddenChange = !shallowEqualImmutable(this.props.hiddenSelection, newProps.hiddenSelection); 93 | let hiddenSelection; 94 | 95 | if (hiddenChange) { 96 | if (this._virtualScroll) this._virtualScroll.recomputeRowHeights(0); 97 | hiddenSelection = this.parseHiddenSelection(newProps); 98 | this.setState({ 99 | hiddenSelection: hiddenSelection 100 | }); 101 | } 102 | } 103 | 104 | /** 105 | * Function called each time an element of the list is selected. Get the value (value of the idField) of the 106 | * element that was selected, them change the selection and call to onSelectionChange function in the props sending 107 | * the new selection. 108 | * 109 | * @param (String) itemValue Value of the idField of the selected element 110 | * @param (Array) e Element which call the function 111 | */ 112 | handleElementClick(itemValue, e) { 113 | e.preventDefault(); 114 | let data = this.props.data, selection = this.props.selection, nothingSelected = false, allSelected = false; 115 | 116 | if (this.props.multiSelect) { 117 | if (selection.has(itemValue)) { 118 | selection.delete(itemValue); 119 | } else { 120 | selection.add(itemValue); 121 | } 122 | } else { 123 | if (selection.has(itemValue)) selection = new Set(); 124 | else selection = new Set([itemValue]); 125 | } 126 | 127 | allSelected = this.isAllSelected(data, selection); 128 | if (!allSelected) nothingSelected = this.isNothingSelected(data, selection); 129 | 130 | // If the state has changed update it 131 | if (allSelected != this.state.allSelected || nothingSelected != this.state.nothingSelected){ 132 | this.setState({ 133 | allSelected: allSelected, 134 | nothingSelected: nothingSelected 135 | }); 136 | } 137 | 138 | if (typeof this.props.onSelectionChange == 'function') { 139 | this.props.onSelectionChange.call(this, selection); 140 | } 141 | } 142 | 143 | /** 144 | * Check if all the current data are not selected 145 | * 146 | * @param (array) data The data to compare with selection 147 | * @param (object) selection The current selection Set of values (idField) 148 | */ 149 | isNothingSelected(data, selection) { 150 | let result = true; 151 | if (selection.size == 0) return true; 152 | 153 | data.forEach(element => { 154 | if (selection.has(element.get(this.props.idField, null))) { // Some data not in selection 155 | result = false; 156 | return false; 157 | } 158 | }); 159 | 160 | return result; 161 | } 162 | 163 | /** 164 | * Check if all the current data are selected. 165 | * 166 | * @param (array) data The data to compare with selection 167 | * @param (object) selection The current selection Set of values (idField) 168 | */ 169 | isAllSelected(data, selection) { 170 | let result = true; 171 | if (data.size > selection.size) return false; 172 | 173 | data.forEach(element => { 174 | if (!selection.has(element.get(this.props.idField, null))) { // Some data not in selection 175 | result = false; 176 | return false; 177 | } 178 | }); 179 | 180 | return result; 181 | } 182 | 183 | /** 184 | * Function called each time the buttons in the bar of the list has been clicked. Delete or add all the data elements into the selection, just if it has changed. 185 | * 186 | * @param (Boolean) selectAll If its a select all action or an unselect all. 187 | * @param (Array) e Element which call the function 188 | */ 189 | handleSelectAll(selectAll, e) { 190 | e.preventDefault(); 191 | 192 | let newData = [], data = this.props.data, field = this.props.idField; 193 | let selection = this.props.selection; 194 | let hasChanged = (selectAll != this.state.allSelected || (!selectAll && !this.state.nothingSelected)); // nothingSelected = false then something is selected 195 | 196 | if (selectAll && hasChanged) { 197 | data.forEach(element => { 198 | selection.add(element.get(field, null)); 199 | }); 200 | } else { 201 | data.forEach(element => { 202 | selection.delete(element.get(field, null)); 203 | }); 204 | } 205 | 206 | if (hasChanged) { 207 | this.setState({ 208 | allSelected: selectAll, 209 | nothingSelected: !selectAll 210 | }); 211 | } 212 | 213 | if (typeof this.props.onSelectionChange == 'function' && hasChanged) { 214 | this.props.onSelectionChange.call(this, selection); 215 | } 216 | } 217 | 218 | /** 219 | * Function called each time the buttons (select empty) in the bar of the list has been clicked (In case empty selection allowed). 220 | * 221 | * @param (Array) e Element which call the function 222 | */ 223 | handleSelectEmpty( e) { 224 | if (typeof this.props.onSelectionChange == 'function') { 225 | this.props.onSelectionChange.call(this, null, true); 226 | } 227 | } 228 | 229 | /** 230 | * Parse the hidden selection if that property contains somethings. 231 | * 232 | * @param (array) props Component props (or nextProps) 233 | * @return (Set) hiddenSelection The hidden rows. 234 | */ 235 | parseHiddenSelection(props = this.props) { 236 | let hidden = [], isArray = _.isArray(props.hiddenSelection), isObject = _.isObject(props.hiddenSelection); 237 | 238 | if (!isArray && isObject) return props.hiddenSelection; // Is Set 239 | 240 | if (!isArray) { // Is String or number 241 | hidden = [props.hiddenSelection.toString()]; 242 | } else if (props.hiddenSelection.length > 0) { // Is Array 243 | hidden = props.hiddenSelection.toString().split(','); 244 | } 245 | 246 | return new Set(hidden); 247 | } 248 | 249 | /** 250 | * Return the tool bar for the top of the list. It will be displayed only when the selection can be multiple. 251 | * 252 | * @return (html) The toolbar code 253 | */ 254 | getToolbar() { 255 | let maxWidth = this.props.containerWidth ? (this.props.containerWidth / 2) - 1 : 100; 256 | 257 | return ( 258 | 277 | ); 278 | } 279 | 280 | /** 281 | * Return the tool bar for the top of the list in case Empty Selection allowed 282 | * 283 | * @return (html) The toolbar code 284 | */ 285 | getToolbarForEmpty() { 286 | let allSelected = this.state.allSelected, selectMessage, maxWidth = (this.props.containerWidth / 2) - 1; 287 | selectMessage = allSelected ? this.props.messages.none : this.props.messages.all; 288 | 289 | return ( 290 | 310 | ); 311 | } 312 | 313 | /** 314 | * Build and return the content of the list. 315 | * 316 | * @param (object) contentData 317 | * - index (integer) Index of the data to be rendered 318 | * - isScrolling (bool) If grid is scrollings 319 | * @return (html) list-row A row of the list 320 | */ 321 | getContent(contentData) { 322 | let index = contentData.index; 323 | let icon = null, selectedClass = null, className = null, element = null, listElementClass = this.props.listElementClass; 324 | let data = this.props.data, rowdata, id, displayField = this.props.displayField, showIcon = this.props.showIcon; 325 | 326 | rowdata = data.get(index); 327 | element = rowdata.get(displayField); 328 | className = "proper-search-list-element"; 329 | id = rowdata.get(this.props.idField); 330 | 331 | if (this.props.multiSelect) { 332 | if (showIcon) { 333 | if (rowdata.get('_selected', false)) { 334 | icon = ; 335 | selectedClass = ' proper-search-selected' 336 | } else { 337 | icon = ; 338 | selectedClass = null; 339 | } 340 | } 341 | } else { 342 | if (rowdata.get('_selected')) selectedClass = ' proper-search-single-selected'; 343 | else selectedClass = null; 344 | } 345 | 346 | if (listElementClass) { 347 | className += ' ' + listElementClass; 348 | } 349 | 350 | if (selectedClass) { 351 | className += ' ' + selectedClass; 352 | } 353 | 354 | if (typeof element == 'function') { 355 | element = element(this.props.indexedData[id]); 356 | } else if (this.props.rowFormater) { 357 | let ckey = ['search_list', 'list_'+ this.uniqueId, 'row__'+rowdata.get(this.props.idField), displayField]; 358 | element = cache.read(ckey); 359 | 360 | if (element === undefined) { 361 | element = this.props.rowFormater(rowdata.get(displayField)); 362 | cache.write(ckey, element); 363 | } 364 | } 365 | 366 | return ( 367 |
368 | {icon} 369 | {element} 370 |
371 | ); 372 | } 373 | /** 374 | * To be rendered when the data has no data (Ex. filtered data) 375 | * 376 | * @return (node) An div with a message 377 | */ 378 | noRowsRenderer () { 379 | return
{this.props.messages.noData}
; 380 | } 381 | 382 | /** 383 | * Function called to get the content of each element of the list. 384 | * 385 | * @param (object) contentData 386 | * - index (integer) Index of the data to be rendered 387 | * - isScrolling (bool) If grid is scrollings 388 | * @return (node) element The element on the index position 389 | */ 390 | rowRenderer(contentData) { 391 | return this.getContent(contentData); 392 | } 393 | 394 | /** 395 | * Function that gets the height for the current row of the list. 396 | * 397 | * @param (object) rowData It's an object that contains the index of the current row 398 | * @return (integer) rowHeight The height of each row. 399 | */ 400 | getRowHeight(rowData) { 401 | let id = this.props.data.get(rowData.index).get(this.props.idField); 402 | return this.state.hiddenSelection.has(id) ? 0 : this.props.listRowHeight; 403 | } 404 | 405 | render(){ 406 | let toolbar = null, rowHeight = this.props.listRowHeight, className = "proper-search-list"; 407 | 408 | if (this.props.multiSelect) { 409 | toolbar = this.props.allowsEmptySelection ? this.getToolbarForEmpty() : this.getToolbar(); 410 | } 411 | 412 | if (this.props.className) { 413 | className += ' '+this.props.className; 414 | } 415 | 416 | if (this.state.hiddenSelection.size > 0) { 417 | rowHeight = this.getRowHeight.bind(this); 418 | } 419 | 420 | return ( 421 |
422 | {toolbar} 423 | { 425 | this._virtualScroll = ref 426 | }} 427 | className={"proper-search-list-virtual"} 428 | width={this.props.listWidth || this.props.containerWidth} 429 | height={this.props.listHeight} 430 | rowRenderer={this.rowRenderer.bind(this)} 431 | rowHeight={rowHeight} 432 | noRowsRenderer={this.noRowsRenderer.bind(this)} 433 | rowCount={this.props.data.size} 434 | overscanRowsCount={5} 435 | /> 436 |
437 | ) 438 | } 439 | } 440 | 441 | SearchList.defaultProps = getDefaultProps(); 442 | let toExport = process.env.NODE_ENV === 'Test' ? SearchList : Dimensions()(SearchList) 443 | export default toExport; -------------------------------------------------------------------------------- /src/jsx/lang/messages.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'SPA': { 3 | all: 'Seleccionar Todo', 4 | none: 'Deseleccionar Todo', 5 | empty: 'Seleccionar Vacios', 6 | notEmpty: 'Deseleccionar Vacios', 7 | loading: 'Cargando...', 8 | noData: 'No se encontró ningún elemento', 9 | errorIdField: 'No se pudo cambiar el `idField´, el campo', 10 | errorDisplayField: 'No se pudo cambiar el `displayField´, el campo', 11 | errorData: 'no existe en el array de datos o no ha cambiado', 12 | }, 13 | 'ENG': { 14 | all: 'Select All', 15 | none: 'Unselect All', 16 | empty: 'Select Empty', 17 | notEmpty: 'Unselect Empty', 18 | loading: 'Loading...', 19 | noData:'No data found', 20 | errorIdField: "Couldn\'t change the `idField´, the field", 21 | errorDisplayField: "Couldn\'t change the `displayField´, the field", 22 | errorData: 'doesn\'t exist in the data array or has no changes', 23 | } 24 | } -------------------------------------------------------------------------------- /src/jsx/lib/cache.js: -------------------------------------------------------------------------------- 1 | import dot from 'dot-object'; 2 | import {map} from 'underscore'; 3 | import merge from 'deepmerge'; 4 | 5 | let cache = {}; 6 | 7 | function parseKey(key) { 8 | return map(key, (k) => { 9 | return k.toString().replace('.', '_'); 10 | }).join('.'); 11 | } 12 | 13 | class RowCache { 14 | constructor(base = {}) { 15 | this.init(base); 16 | } 17 | 18 | init(base = null) { 19 | cache = base; 20 | 21 | return this; 22 | } 23 | 24 | read(key) { 25 | let k = parseKey(key); 26 | return dot.pick(k, cache); 27 | } 28 | 29 | write(key, value) { 30 | let k = parseKey(key); 31 | let writable = {}; 32 | 33 | writable[k] = value; 34 | writable = dot.object(writable); 35 | 36 | cache = merge(cache, writable); 37 | 38 | return this; 39 | } 40 | 41 | flush(key = null) { 42 | if (key) { 43 | let k = parseKey(key); 44 | dot.remove(k, cache); 45 | } else { 46 | this.init(); 47 | } 48 | 49 | return this; 50 | } 51 | } 52 | 53 | const rowcache = new RowCache(); 54 | 55 | export default rowcache; 56 | -------------------------------------------------------------------------------- /src/jsx/propersearch.js: -------------------------------------------------------------------------------- 1 | import Search from "./components/search"; 2 | 3 | if (process.env.APP_ENV === 'browser') { 4 | require("../css/style.scss"); 5 | } 6 | 7 | export default Search; 8 | -------------------------------------------------------------------------------- /src/jsx/utils/normalize.js: -------------------------------------------------------------------------------- 1 | const charMap = { 2 | 'a': ['á','Á','à','À','ã','Ã','â','Â','ä','Ä','å','Å','ā','Ā','ą','Ą'], 3 | 'e': ['é','É','è','È','ê','Ê','ë','Ë','ē','Ē','ė','Ė','ę','Ę'], 4 | 'i': ['î','Î','í','Í','ì','Ì','ï','Ï','ī','Ī','į','Į'], 5 | 'l': ['ł', 'Ł'], 6 | 'o': ['ô','Ô','ò','Ò','ø','Ø','ō','Ō','ó','Ó','õ','Õ','ö','Ö'], 7 | 'u': ['û','Û','ú','Ú','ù','Ù','ü','Ü','ū','Ū'], 8 | 'c': ['ç','Ç','č','Č','ć','Ć'], 9 | 's': ['ś','Ś','š','Š'], 10 | 'z': ['ź','Ź','ż','Ż'], 11 | '' : ['@','#','~','$','!','º','|','"','·','%','&','¬','/','(',')','=','?','¿','¡','*','+','^','`','-','´','{','}','ç',';',':','.'] 12 | } 13 | 14 | export default { 15 | normalize: function (value, parseToLower = true) { 16 | let rex = null; 17 | 18 | for(let element in charMap){ 19 | rex = new RegExp('[' + charMap[element].toString() + ']', 'g'); 20 | 21 | try{ 22 | value = value.replace(rex, element); 23 | } catch(e) { 24 | console.log('error', value); 25 | } 26 | } 27 | return parseToLower ? value.toLowerCase() : value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | // ES5 shims for Function.prototype.bind, Object.prototype.keys, etc. 2 | require('core-js/es5'); 3 | // Replace ./src/js with the directory of your application code and 4 | // make sure the file name regexp matches your test files. 5 | var context = require.context('./src/jsx', true, /-test\.js$/); 6 | context.keys().forEach(context); 7 | -------------------------------------------------------------------------------- /webpack.config.example.dist.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | context: path.join(__dirname, 'examples'), 6 | entry: { 7 | javascript: "./jsx/example.js" 8 | }, 9 | module: { 10 | loaders: [ 11 | { 12 | test: /\.js$/, 13 | exclude: /node_modules/, 14 | loaders: ["babel-loader"], 15 | }, 16 | { 17 | test: /\.jsx$/, 18 | exclude: /node_modules/, 19 | loaders: ["babel-loader"], 20 | } 21 | ], 22 | }, 23 | externals: { 24 | 'react': 'React', 25 | 'react-dom': 'ReactDOM', 26 | 'underscore': '_' 27 | }, 28 | output: { 29 | libraryTarget: "var", 30 | library: "App", 31 | filename: "app.js", 32 | path: __dirname + "/examples/dist" 33 | }, 34 | plugins: [ 35 | new webpack.DefinePlugin({ 36 | 'process.env': { 37 | NODE_ENV: JSON.stringify('production'), 38 | APP_ENV: JSON.stringify('example') 39 | }, 40 | }), 41 | new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}}) 42 | ] 43 | } -------------------------------------------------------------------------------- /webpack.config.example.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | 5 | const examplePath = path.join(__dirname, '/examples'); 6 | 7 | module.exports = { 8 | context: path.join(__dirname, '/src'), 9 | entry: { 10 | javascript: path.join(examplePath, 'jsx/example.js'), 11 | html: path.join(examplePath, '/index.html') 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | exclude: /node_modules/, 18 | loaders: ["babel-loader"], 19 | }, 20 | { 21 | test: /\.jsx$/, 22 | exclude: /node_modules/, 23 | loaders: ["babel-loader"], 24 | }, 25 | { 26 | test: /\.html$/, 27 | loader: "file?name=[name].[ext]", 28 | exclude: /node_modules/ 29 | }, 30 | { 31 | test: /\.scss$/, 32 | loader: ExtractTextPlugin.extract('css!sass?includePaths[]='+path.resolve(__dirname, "./node_modules/compass-mixins/lib")), 33 | exclude: /node_modules/ 34 | } 35 | ], 36 | }, 37 | externals: { 38 | 'react': 'React', 39 | 'react-dom': 'ReactDOM', 40 | 'underscore': '_' 41 | }, 42 | devtool: 'eval', 43 | output: { 44 | libraryTarget: "var", 45 | library: "ProperSearch", 46 | filename: "example.js", 47 | path: path.join(__dirname, "/dist") 48 | }, 49 | plugins: [ 50 | new ExtractTextPlugin('propersearch.css', { 51 | allChunks: true 52 | }), 53 | new webpack.optimize.DedupePlugin(), 54 | new webpack.DefinePlugin({ 55 | 'process.env': { 56 | NODE_ENV: JSON.stringify('production'), 57 | APP_ENV: JSON.stringify('browser') 58 | }, 59 | }) 60 | ] 61 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | 5 | module.exports = { 6 | context: path.join(__dirname, 'src'), 7 | entry: { 8 | javascript: "./jsx/propersearch.js" 9 | }, 10 | module: { 11 | loaders: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | loaders: ["babel-loader"], 16 | }, 17 | { 18 | test: /\.jsx$/, 19 | exclude: /node_modules/, 20 | loaders: ["babel-loader"], 21 | }, 22 | { 23 | test: /\.html$/, 24 | loader: "file?name=[name].[ext]", 25 | }, 26 | { 27 | test: /\.scss$/, 28 | loader: ExtractTextPlugin.extract('css!sass?includePaths[]='+path.resolve(__dirname, "./node_modules/compass-mixins/lib")) 29 | } 30 | ], 31 | }, 32 | externals: { 33 | 'react': 'React', 34 | 'react-dom': 'ReactDOM', 35 | 'underscore': '_' 36 | }, 37 | output: { 38 | libraryTarget: "var", 39 | library: "ProperSearch", 40 | filename: "propersearch.js", 41 | path: __dirname + "/dist" 42 | }, 43 | plugins: [ 44 | new ExtractTextPlugin('propersearch.css', { 45 | allChunks: true 46 | }), 47 | new webpack.optimize.DedupePlugin(), 48 | new webpack.DefinePlugin({ 49 | 'process.env': { 50 | NODE_ENV: JSON.stringify('production'), 51 | APP_ENV: JSON.stringify('browser') 52 | }, 53 | }) 54 | ] 55 | } -------------------------------------------------------------------------------- /webpack.config.min.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | 5 | module.exports = { 6 | context: __dirname + '/src', 7 | entry: { 8 | javascript: "./jsx/propersearch.js" 9 | }, 10 | module: { 11 | loaders: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | loaders: ["babel-loader"], 16 | }, 17 | { 18 | test: /\.jsx$/, 19 | exclude: /node_modules/, 20 | loaders: ["babel-loader"], 21 | }, 22 | { 23 | test: /\.html$/, 24 | loader: "file?name=[name].[ext]", 25 | }, 26 | { 27 | test: /\.scss$/, 28 | loader: ExtractTextPlugin.extract('css!sass?includePaths[]='+path.resolve(__dirname, "./node_modules/compass-mixins/lib")) 29 | } 30 | ], 31 | }, 32 | externals: { 33 | 'react': 'React', 34 | 'react-dom': 'ReactDOM', 35 | 'underscore': '_' 36 | }, 37 | output: { 38 | libraryTarget: "var", 39 | library: "ProperSearch", 40 | filename: "propersearch.min.js", 41 | path: __dirname + "/dist" 42 | }, 43 | plugins: [ 44 | new ExtractTextPlugin('propersearch.min.css', { 45 | allChunks: true 46 | }), 47 | new webpack.optimize.DedupePlugin(), 48 | new webpack.optimize.UglifyJsPlugin({compress: {warnings: false}}), 49 | new webpack.DefinePlugin({ 50 | 'process.env': { 51 | NODE_ENV: JSON.stringify('production'), 52 | APP_ENV: JSON.stringify('browser') 53 | }, 54 | }) 55 | ] 56 | } --------------------------------------------------------------------------------