├── .eslintrc ├── .gitignore ├── Gulpfile.js ├── LICENSE ├── README.md ├── bower.json ├── package.json ├── src ├── jade │ └── index.jade ├── js │ ├── head.js │ ├── main.jsx │ ├── search │ │ ├── FilterList.jsx │ │ ├── Pagination.jsx │ │ ├── SearchForm.jsx │ │ ├── SearchModule.jsx │ │ ├── SearchResults.jsx │ │ ├── api.js │ │ └── observables.factory.js │ └── utils.jsx └── scss │ └── bootstrap.scss ├── test └── js │ ├── index.html │ ├── search │ └── PaginationSpec.js │ └── webpack.config.js └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | // I write for browser 5 | "browser": true, 6 | // in CommonJS 7 | "node": true 8 | }, 9 | // To give you an idea how to override rule options: 10 | "rules": { 11 | "quotes": [2, "single"], 12 | "strict": [2, "never"], 13 | "eol-last": [0], 14 | "no-mixed-requires": [0], 15 | "no-underscore-dangle": [0], 16 | "camelcase": [2, {properties: "never"}], 17 | "react/display-name": 0, 18 | "react/jsx-boolean-value": 1, 19 | "react/jsx-no-undef": 1, 20 | "react/jsx-quotes": 1, 21 | "react/jsx-sort-prop-types": 0, 22 | "react/jsx-sort-props": 0, 23 | "react/jsx-uses-react": 1, 24 | "react/jsx-uses-vars": 1, 25 | "react/no-did-mount-set-state": 1, 26 | "react/no-did-update-set-state": 1, 27 | "react/no-multi-comp": 1, 28 | "react/no-unknown-property": 1, 29 | "react/prop-types": 1, 30 | "react/react-in-jsx-scope": 1, 31 | "react/self-closing-comp": 1, 32 | "react/sort-comp": 1, 33 | "react/wrap-multilines": 1 34 | }, 35 | "plugins": [ 36 | "react" 37 | ], 38 | "ecmaFeatures": { 39 | "jsx": true, 40 | "modules": true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | dist 4 | test/js/specs.js 5 | -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var jade = require('gulp-jade'); 3 | var gutil = require('gulp-util'); 4 | var watch = require('gulp-watch'); 5 | var browserSync = require('browser-sync').create(); 6 | var webpack = require('webpack'); 7 | var webpackConfig = require('./webpack.config.js'); 8 | var webpackDevServer = require('webpack-dev-server'); 9 | 10 | gulp.task('jade', function() { 11 | watch('src/jade/**/*.jade', function (file) { 12 | // brute force - rebuild all on change - because I just needed 13 | // this to work quickly and I couldn't find a incremental solution 14 | // that worked for me that took into account jade deps. 15 | 16 | gutil.log(gutil.colors.magenta("JADE:"), file.path); 17 | 18 | gulp.src('src/jade/*.jade'). 19 | pipe(jade()). 20 | pipe(gulp.dest('dist')); 21 | }); 22 | }); 23 | 24 | gulp.task('browser-sync', function() { 25 | browserSync.init({ 26 | open: true, 27 | files: [ 28 | 'dist/*.html' 29 | ], 30 | watchOptions: { 31 | ignoreInitial: true 32 | }, 33 | server: { 34 | baseDir: 'dist' 35 | }, 36 | ghostMode: false 37 | }); 38 | }); 39 | 40 | gulp.task('webpack-dev-server', function() { 41 | new webpackDevServer(webpack(webpackConfig), { 42 | hot: true, 43 | stats: { 44 | colors: true, 45 | errorDetails: true, 46 | module: false, 47 | chunks: false, 48 | cached: false, 49 | cachedAssets: false 50 | } 51 | }).listen(8080, 'localhost', function (err) { 52 | if (err) throw new gutil.PluginError('webpack-dev-server', err); 53 | gutil.log('[webpack-dev-server]', 'http://localhost:8080/') 54 | }); 55 | }); 56 | 57 | gulp.task('default', ['browser-sync' ,'jade', 'webpack-dev-server']); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [year] [fullname] 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 | # Example of using React and RxJS to build a faceted search interface for Elasticsearch 2 | 3 | Start with https://github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example/blob/master/src/js/main.jsx if you want to look at code - the react components are in the search directory and their names start with a capital, the rxjs stuff is inside https://github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example/blob/master/src/js/search/observables.factory.js the elasticsearch bit is in https://github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example/blob/master/src/js/search/api.js and the actions which are just ways to trigger events from the UI are in https://github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example/blob/master/src/js/search/actions.js. 4 | 5 | I've been experimenting with Elasticsearch for a while and also with React, I wanted to experiment with RxJS after using Reflux with React for a project and finding myself unsatisfied so I decided to create a faceted search interface to experiment. You can see my progression a bit in the commit history, and it's got a lot of comments showing my working out and general thought processes. 6 | 7 | Summary of this is I'm pretty happy with RxJS and React as a combo and I wouldn't want to go back towards Flux or related variations because those feel a bit to complicated to me and it feels like with RxJS there is plenty of room to grow/mature my approach. 8 | 9 | The thing that's lacking at the moment that most people at SocratesUK will probably notice are automated tests, I'm not sure what tooling there is available for writing tests against RxJS but it's something for me to look at now. 10 | 11 | --- 12 | 13 | Ah and to build this the missing bit that will stop you running it is an instance of elasticsearch accessible at localhost:9200 and a collection with the meta data utilized in https://github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example/blob/master/src/js/api.js! 14 | 15 | Build tool for this is webpack + gulp, you should be able to get it working - served up in your browser with react hot reloading by running `gulp`. 16 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elastic-react-rxjs", 3 | "private": true, 4 | "main": "dist/index.html", 5 | "ignore": [], 6 | "devDependencies": { 7 | "modernizr": "~2.8.3", 8 | "respond": "~1.4.2" 9 | } 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elastic-react-rxjs", 3 | "private": true, 4 | "devDependencies": { 5 | "autoprefixer-loader": "^1.2.0", 6 | "babel-core": "^5.2.13", 7 | "babel-eslint": "^3.1.14", 8 | "babel-loader": "^5.0.0", 9 | "babel-runtime": "^5.4.7", 10 | "bootstrap-sass": "^3.3.4", 11 | "browser-sync": "^2.7.1", 12 | "classnames": "^2.1.1", 13 | "css-loader": "^0.12.0", 14 | "elasticsearch": "^4.0.2", 15 | "eslint": "^0.22.1", 16 | "eslint-plugin-react": "^2.5.0", 17 | "exports-loader": "^0.6.2", 18 | "expose-loader": "^0.6.0", 19 | "extract-text-webpack-plugin": "^0.7.0", 20 | "file-loader": "^0.8.1", 21 | "gulp": "^3.8.11", 22 | "gulp-jade": "^1.0.0", 23 | "gulp-jade-find-affected": "^0.2.1", 24 | "gulp-util": "^3.0.4", 25 | "gulp-watch": "^4.2.4", 26 | "imports-loader": "^0.6.3", 27 | "jade": "^1.9.2", 28 | "jade-loader": "^0.7.1", 29 | "jquery": "^2.1.4", 30 | "lodash": "^3.8.0", 31 | "node-sass": "^2.1.1", 32 | "react": "^0.13.3", 33 | "react-hot-loader": "^1.2.7", 34 | "reqwest": "^1.1.5", 35 | "rx": "^2.5.2", 36 | "rx-react": "^0.2.0", 37 | "sass-loader": "^0.4.2", 38 | "style-loader": "^0.12.1", 39 | "url-loader": "^0.5.5", 40 | "webpack": "^1.8.11", 41 | "webpack-dev-server": "^1.8.2" 42 | }, 43 | "dependencies": {}, 44 | "scripts": { 45 | "test": "cd test/js && ../../node_modules/.bin/webpack --watch & node_modules/.bin/browser-sync start --server test/js --files test/js/specs.js", 46 | "lint": "eslint src --ext .jsx --ext .js" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/jade/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html.no-js(lang="en") 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge') 6 | title Elastic React RxJS 7 | meta(name='description', content='') 8 | meta(name='viewport', content='width=device-width, initial-scale=1') 9 | script(type='text/javascript', src='http://localhost:8080/head.js') 10 | body 11 | div.container 12 | div#app 13 | script(type='text/javascript', src='http://localhost:8080/main.js') -------------------------------------------------------------------------------- /src/js/head.js: -------------------------------------------------------------------------------- 1 | require('imports?this=>window!modernizr/modernizr.js'); 2 | require('imports?this=>window!respond'); -------------------------------------------------------------------------------- /src/js/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SearchModule from 'search/SearchModule'; 3 | import utils from 'utils'; 4 | import uri from 'URIjs'; 5 | 6 | // get initial state from URL and load observables from it 7 | var queryParams = uri().search(true); 8 | 9 | var { observables, actions: { changeQuery, changePage } } = require('search/observables.factory')({ 10 | query: queryParams.q || '', 11 | page: queryParams.page || 1, 12 | selectedTags: queryParams.tags || [], 13 | selectedTypes: queryParams.types || [] 14 | }); 15 | 16 | // watch for changes to props and rerender, and override the changePage action to include a confirmation dialog as an example 17 | // small throttle to cut down the number of rerenders - is this helpful, harmful, or neither? 18 | observables.props.subscribe(props => { 19 | // example of how to override an action: 20 | // React.render( confirm("Are you sure?") && changePage(page) } />, document.getElementById("app")); 21 | 22 | React.render(, document.getElementById('app')); 23 | }); 24 | 25 | // when someone clicks the back button we need to reload our state from URL, guess this should be done in the rx way! 26 | window.addEventListener('popstate', () => { 27 | var newQueryParams = uri().search(true); 28 | changeQuery(newQueryParams.q || ''); 29 | observables.selectedTags.onNext([].concat(newQueryParams.tags || [])); 30 | observables.selectedTypes.onNext([].concat(newQueryParams.types || [])); 31 | changePage(newQueryParams.page || 1); 32 | }); 33 | 34 | // when the state changes update the URL (unless the URL is the same, which means someone clicked the back button) 35 | utils. 36 | combineLatestAsObject({ 37 | q: observables.query, 38 | tags: observables.selectedTags, 39 | types: observables.selectedTypes, 40 | page: observables.currentPage 41 | }). 42 | // TODO this replaces all params not just the named ones 43 | map(params => uri().setSearch(params).toString()). 44 | distinctUntilChanged(). 45 | debounce(200). 46 | filter(nextURL => nextURL !== window.location.toString()). 47 | subscribe(nextURL => window.history[uri().search() ? 'pushState' : 'replaceState'](null, null, nextURL)); 48 | -------------------------------------------------------------------------------- /src/js/search/FilterList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default React.createClass({ 4 | propTypes: { 5 | selected: React.PropTypes.array, 6 | possible: React.PropTypes.arrayOf(React.PropTypes.shape({ 7 | term: React.PropTypes.string, 8 | count: React.PropTypes.number 9 | })), 10 | onChange: React.PropTypes.func 11 | }, 12 | 13 | render() { 14 | var { selected, possible, onChange } = this.props; 15 | 16 | return ( 17 | 24 | ); 25 | } 26 | }); 27 | 28 | var TermCheckbox = React.createClass({ 29 | propTypes: { 30 | term: React.PropTypes.string, 31 | count: React.PropTypes.number, 32 | checked: React.PropTypes.bool, 33 | onChange: React.PropTypes.func 34 | }, 35 | 36 | render() { 37 | var { term, count, checked, onChange } = this.props; 38 | 39 | return ( 40 |
  • 41 | ); 42 | } 43 | }); -------------------------------------------------------------------------------- /src/js/search/Pagination.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from 'classnames'; 3 | 4 | export default React.createClass({ 5 | propTypes: { 6 | changePage: React.PropTypes.func.isRequired, 7 | totalPages: React.PropTypes.number.isRequired, 8 | currentPage: React.PropTypes.number.isRequired 9 | }, 10 | 11 | changePage(page) { 12 | return (e) => { 13 | e.preventDefault(); 14 | this.props.changePage(page); 15 | }; 16 | }, 17 | 18 | render() { 19 | var { totalPages, currentPage } = this.props; 20 | 21 | return ( 22 | 39 | ); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /src/js/search/SearchForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default React.createClass({ 4 | propTypes: { 5 | query: React.PropTypes.string, 6 | onChange: React.PropTypes.func 7 | }, 8 | 9 | render() { 10 | var { query, onChange } = this.props; 11 | 12 | return ( 13 | 14 | ); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/js/search/SearchModule.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SearchForm from './SearchForm'; 3 | import FilterList from './FilterList'; 4 | import SearchResults from './SearchResults'; 5 | import Pagination from './Pagination'; 6 | 7 | export default React.createClass({ 8 | propTypes: { 9 | query: React.PropTypes.string, 10 | totalPages: React.PropTypes.number, 11 | currentPage: React.PropTypes.number, 12 | selectedTags: React.PropTypes.array, 13 | possibleTags: React.PropTypes.arrayOf(React.PropTypes.shape({ 14 | term: React.PropTypes.string, 15 | count: React.PropTypes.number 16 | })), 17 | selectedTypes: React.PropTypes.array, 18 | possibleTypes: React.PropTypes.arrayOf(React.PropTypes.shape({ 19 | term: React.PropTypes.string, 20 | count: React.PropTypes.number 21 | })), 22 | results: React.PropTypes.object, 23 | searchInProgress: React.PropTypes.bool, 24 | changeQuery: React.PropTypes.func, 25 | changePage: React.PropTypes.func, 26 | toggleFilter: React.PropTypes.func 27 | }, 28 | 29 | render(){ 30 | var { 31 | // state 32 | query, 33 | totalPages, 34 | currentPage, 35 | selectedTags, 36 | possibleTags, 37 | selectedTypes, 38 | possibleTypes, 39 | results, 40 | searchInProgress, 41 | // actions 42 | changeQuery, 43 | changePage, 44 | toggleFilter, 45 | } = this.props; 46 | 47 | return ( 48 |
    49 |
    50 |
    51 |

    Elastic React RxJS Faceted Search

    52 | changeQuery(event.target.value)} /> 53 |
    54 |
    55 |
    56 | {results 57 | ? (
    58 |
    59 | Tags 60 | toggleFilter('selectedTags', event.target.value)} /> 61 |
    62 |
    63 | Types 64 | toggleFilter('selectedTypes', event.target.value)} /> 65 |
    66 |
    67 | ) : false} 68 |
    69 | {searchInProgress && !results 70 | ?

    Loading, please wait...

    71 | : ( 72 |
    73 | 74 | 75 | {results 76 | ? 77 | : false} 78 |
    79 | )} 80 |
    81 |
    82 |
    83 | ); 84 | } 85 | }); -------------------------------------------------------------------------------- /src/js/search/SearchResults.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default React.createClass({ 4 | propTypes: { 5 | results: React.PropTypes.any 6 | }, 7 | 8 | render() { 9 | var { results, ...props } = this.props; 10 | 11 | return results ? ( 12 |
    13 |

    Total: {results.hits.total}

    14 | 15 | {results.hits.hits.map(hit => )} 16 |
    17 | ) : ( 18 |
    19 |

    No results found

    20 |
    21 | ); 22 | } 23 | }); 24 | 25 | var SearchResult = React.createClass({ 26 | propTypes: { 27 | hit: React.PropTypes.shape({ 28 | _score: React.PropTypes.number, 29 | _source: React.PropTypes.shape({ 30 | url: React.PropTypes.string, 31 | title: React.PropTypes.string 32 | }) 33 | }) 34 | }, 35 | 36 | render() { 37 | var { title, url } = this.props.hit._source; 38 | 39 | return ( 40 |
    41 |

    {title}

    42 |

    Score: {this.props.hit._score}

    43 |
    44 | ); 45 | } 46 | }); -------------------------------------------------------------------------------- /src/js/search/api.js: -------------------------------------------------------------------------------- 1 | import elasticsearch from 'elasticsearch'; 2 | 3 | // TODO is this better? So in observables I just do api = require(api)({host: "localhost:9200"}) // config 4 | // feel like at this point it's better to not use closures state and just pass the host, type, index to the 5 | // search function! Then these can be defined in observables, defined as observables, or whatever. And it 6 | // would make this easier to test I think not having that static state. 7 | /* 8 | export default function(host) { 9 | var client = new elasticsearch.Client({ host: host }); 10 | 11 | return { 12 | search({query="*", tags=[], types=[], from=0, size=10}) { 13 | ... 14 | } 15 | } 16 | } 17 | 18 | TODO move these here 19 | 20 | // results must have each tag 21 | var tagsTermFilters = selectedTags. 22 | map(tags => tags.map(tag => { 23 | return {term: {tags: tag}} 24 | })); 25 | 26 | // results can have any of the types 27 | var typesTermsFilter = selectedTypes. 28 | map(types => { 29 | return types.length ? {terms: {typeAndSubType: types}} : []; 30 | }); 31 | 32 | 33 | var filters = Rx.Observable. 34 | combineLatest( 35 | typesTermsFilter, 36 | tagsTermFilters, 37 | (typesTermsFilter, tagsTermFilters) => { 38 | return tagsTermFilters.concat(typesTermsFilter); 39 | } 40 | ); 41 | 42 | */ 43 | 44 | var client = new elasticsearch.Client({ 45 | host: 'localhost:9200' 46 | }); 47 | 48 | // TODO replace elasticsearch client with something else, it's like 400kb 49 | // minified and I'm not really using it, could just use jquery since I've 50 | // bundled it for bootstrap 51 | 52 | export var search = function({query='*', selectedTags=[], selectedTypes=[], resultsFrom=0, resultsPerPage=5}) { 53 | 54 | query = { 55 | query_string: { 56 | query: query || '*', 57 | fields: ['title', 'summary', 'body', 'tags'] 58 | } 59 | }; 60 | 61 | var tagsFilters = selectedTags. 62 | map(tag => { 63 | return {term: {tags: tag}}; 64 | }); 65 | 66 | var typesFilter = selectedTypes.length ? {terms: {typeAndSubType: selectedTypes}} : []; 67 | 68 | var filter = {}; 69 | 70 | var combinedFilters = tagsFilters.concat(typesFilter); 71 | 72 | if (combinedFilters.length) { 73 | filter.and = combinedFilters; 74 | } 75 | 76 | return client.search({ 77 | index: 'elastic', 78 | type: 'muraContent', 79 | from: resultsFrom, 80 | size: resultsPerPage, 81 | body: { 82 | query: { 83 | filtered: { 84 | query: query, 85 | filter: filter 86 | } 87 | }, 88 | aggs: { 89 | tags: { 90 | terms: { 91 | field: 'tags', 92 | size: 10 93 | } 94 | }, 95 | all: { 96 | global: {}, 97 | aggs: { 98 | query: { 99 | filter: { 100 | and: [ 101 | { query: query } 102 | ].concat(tagsFilters) 103 | }, 104 | aggs: { 105 | typeAndSubType: { 106 | terms: { 107 | field: 'typeAndSubType' 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } 116 | }); 117 | }; -------------------------------------------------------------------------------- /src/js/search/observables.factory.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | import { search } from './api'; 3 | import utils from 'utils'; 4 | import update from 'react/lib/update'; 5 | import pluck from 'lodash/collection/pluck'; 6 | import merge from 'lodash/object/merge'; 7 | 8 | var ObservablesFactory = function({ 9 | query='', 10 | selectedTags=[], 11 | selectedTypes=[], 12 | resultsPerPage=5, 13 | page=1, 14 | }) { 15 | var subjects = { 16 | query: new Rx.BehaviorSubject(query), 17 | selectedTags: new Rx.BehaviorSubject(selectedTags), 18 | selectedTypes: new Rx.BehaviorSubject(selectedTypes), 19 | resultsPerPage: new Rx.BehaviorSubject(resultsPerPage), 20 | resultsFrom: new Rx.BehaviorSubject(resultsPerPage * (page - 1)), 21 | searchInProgress: new Rx.BehaviorSubject(false) 22 | }; 23 | 24 | var actions = { 25 | changeQuery(nextQuery) { 26 | return subjects.query.onNext(nextQuery); 27 | }, 28 | 29 | changePage(nextPage) { 30 | return subjects.resultsFrom.onNext((nextPage - 1) * subjects.resultsPerPage.value); 31 | }, 32 | 33 | toggleFilter(filter, term) { 34 | var currentState = subjects[filter].value; 35 | 36 | return subjects[filter].onNext( 37 | currentState.indexOf(term) === -1 38 | ? currentState.concat(term) 39 | : update(currentState, {$splice: [[currentState.indexOf(term), 1]]}) 40 | ); 41 | } 42 | }; 43 | 44 | var searches = utils. 45 | combineLatestAsObject(subjects). 46 | distinctUntilChanged(); 47 | 48 | var results = searches. 49 | debounce(200). 50 | flatMapLatest(options => search(options)). 51 | share(); 52 | 53 | var possibleTags = results. 54 | pluck('aggregations', 'tags', 'buckets'). 55 | distinctUntilChanged(). 56 | map(terms => { 57 | return terms.map(term => { 58 | return { 59 | term: term.key, 60 | count: term.doc_count 61 | }; 62 | }); 63 | }). 64 | share(); 65 | 66 | var possibleTypes = results. 67 | pluck('aggregations', 'all', 'query', 'typeAndSubType', 'buckets'). 68 | distinctUntilChanged(). 69 | map(terms => { 70 | return terms.map(term => { 71 | return { 72 | term: term.key, 73 | count: term.doc_count 74 | }; 75 | }); 76 | }); 77 | 78 | var totalResults = results. 79 | pluck('hits', 'total'). 80 | distinctUntilChanged(); 81 | 82 | var totalPages = totalResults. 83 | withLatestFrom( 84 | subjects.resultsPerPage, 85 | (total, perPage) => Math.ceil(total / perPage) 86 | ); 87 | 88 | var currentPage = subjects.resultsFrom. 89 | withLatestFrom( 90 | subjects.resultsPerPage, 91 | (from, perPage) => Math.ceil((from + 1) / perPage) 92 | ); 93 | 94 | var state = utils. 95 | combineLatestAsObject({ 96 | results, 97 | totalResults, 98 | totalPages, 99 | currentPage, 100 | possibleTags, 101 | possibleTypes, 102 | ...subjects 103 | }). 104 | distinctUntilChanged(); 105 | 106 | var props = state. 107 | map(currentState => merge({}, currentState, actions)); 108 | 109 | // reset results to start from 0 when selected tags / types or query changes 110 | Rx.Observable. 111 | merge( 112 | subjects.query, 113 | subjects.selectedTags, 114 | subjects.selectedTypes 115 | ). 116 | map(() => 0). 117 | subscribe(subjects.resultsFrom); 118 | 119 | // clear selected tags and types. and search in progress on new search 120 | subjects.query. 121 | distinctUntilChanged(). 122 | subscribe(() => { 123 | subjects.selectedTags.onNext([]); 124 | subjects.selectedTypes.onNext([]); 125 | subjects.searchInProgress.onNext(true); 126 | }); 127 | 128 | // set searchInProgress to false when we get some results 129 | results. 130 | map(() => false). 131 | subscribe(subjects.searchInProgress); 132 | 133 | // untoggle selected types when they are no longer possible selections 134 | possibleTypes. 135 | withLatestFrom( 136 | subjects.selectedTypes.distinctUntilChanged(), 137 | (possible, selected) => { 138 | return {possible: pluck(possible, 'term'), selected}; 139 | } 140 | ). 141 | subscribe(({possible, selected}) => { 142 | selected. 143 | filter(type => possible.indexOf(type) === -1). 144 | map(type => { 145 | subjects.selectedTypes.onNext( 146 | update(selected, {$splice: [[selected.indexOf(type), 1]]}) 147 | ); 148 | }); 149 | }); 150 | 151 | 152 | return { 153 | actions: actions, 154 | observables: { 155 | props, 156 | state, 157 | results, 158 | totalResults, 159 | totalPages, 160 | currentPage, 161 | possibleTags, 162 | possibleTypes, 163 | ...subjects 164 | }, 165 | dispose() { 166 | subjects.map(subject => subject.dispose()); 167 | 168 | // do I need to connect to and dispose of hot observables too / any other cleanup? 169 | } 170 | }; 171 | }; 172 | 173 | export default ObservablesFactory; -------------------------------------------------------------------------------- /src/js/utils.jsx: -------------------------------------------------------------------------------- 1 | import Rx from 'rx'; 2 | import mapValues from 'lodash/object/mapValues'; 3 | 4 | export default { 5 | combineLatestAsObject(keysAndStreams) { 6 | var keys = Object.keys(keysAndStreams); 7 | var streams = keys.map(key => keysAndStreams[key]); 8 | 9 | return Rx.Observable.combineLatest(...streams, (...kwargs) => { 10 | return mapValues(keysAndStreams, (value, key) => { 11 | return kwargs[keys.indexOf(key)]; 12 | }); 13 | }); 14 | } 15 | }; -------------------------------------------------------------------------------- /src/scss/bootstrap.scss: -------------------------------------------------------------------------------- 1 | $icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/" !default; 2 | $font-size-base: 16px !default; 3 | $font-family-sans-serif: arial, sans-serif !default; 4 | @import "bootstrap-sass/assets/stylesheets/bootstrap"; -------------------------------------------------------------------------------- /test/js/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Jasmine Spec Runner 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/js/search/PaginationSpec.js: -------------------------------------------------------------------------------- 1 | import React from 'react/addons'; 2 | import Pagination from '../../../src/js/search/Pagination.jsx'; 3 | 4 | var TestUtils = React.addons.TestUtils; 5 | 6 | describe('Pagination', () => { 7 | var component; 8 | 9 | beforeEach(() => { 10 | component = TestUtils.renderIntoDocument(); 11 | }); 12 | 13 | it('should display a link for each page', () => { 14 | expect(TestUtils.scryRenderedDOMComponentsWithClass(component, 'pagination__page').length).toBe(3); 15 | }); 16 | 17 | }); -------------------------------------------------------------------------------- /test/js/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | 6 | entry: [ 7 | './search/PaginationSpec.js', 8 | ], 9 | 10 | output: { 11 | path: './', 12 | filename: 'specs.js', 13 | publicPath: '/' 14 | }, 15 | 16 | module: { 17 | loaders: [{ 18 | test: /\.jsx?$/, 19 | exclude: /node_modules/, 20 | loader: 'babel-loader' 21 | }] 22 | }, 23 | 24 | resolve: { 25 | extensions: ["", ".js", ".jsx"], 26 | }, 27 | 28 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | context: __dirname, 7 | entry: { 8 | main: [ 9 | 'webpack-dev-server/client?http://localhost:8080', 10 | 'webpack/hot/only-dev-server', 11 | "./src/js/main.jsx", 12 | "./src/scss/bootstrap.scss" 13 | ], 14 | head: "./src/js/head.js", 15 | }, 16 | module: { 17 | loaders: [ 18 | { test: /\.js$/, loader: "babel?optional[]=runtime&stage=1", exclude: /node_modules|bower_components/}, 19 | { test: /\.jsx$/, loader: "react-hot!babel?optional[]=runtime&stage=1", exclude: /node_modules|bower_components/}, 20 | { test: /\.s?css$/, loader: "style!css!autoprefixer?browsers=last 2 version!sass?includePaths[]=" + path.join(__dirname, "node_modules")}, 21 | { test: /\.woff2?$/, loader: "url-loader?limit=10000&minetype=application/font-woff" }, 22 | { test: /\.ttf$/, loader: "file-loader" }, 23 | { test: /\.eot$/, loader: "file-loader" }, 24 | { test: /\.svg$/, loader: "file-loader" }, 25 | ] 26 | }, 27 | output: { 28 | filename: "[name].js", 29 | path: path.join(__dirname, "dist"), 30 | pathinfo: true, 31 | publicPath: "http://localhost:8080/" 32 | }, 33 | plugins: [ 34 | new webpack.HotModuleReplacementPlugin(), 35 | new webpack.NoErrorsPlugin(), 36 | new webpack.ProvidePlugin({ 37 | $: "jquery", 38 | jQuery: "jquery", 39 | "window.jQuery": "jquery" 40 | }), 41 | new webpack.ResolverPlugin( 42 | new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin("bower.json", ["main"]) 43 | ) 44 | ], 45 | resolve: { 46 | extensions: ["", ".js", ".jsx"], 47 | root: [ 48 | path.join(__dirname, 'src', 'js'), 49 | path.join(__dirname, 'src', 'scss'), 50 | path.join(__dirname, 'bower_components'), 51 | ] 52 | }, 53 | }; --------------------------------------------------------------------------------