├── FilterWidget.js ├── README.md ├── charts.js ├── css └── search-widget.css ├── profile ├── profileDashboard.js └── resultsWidget.js ├── readme_images ├── dashboard.png ├── fullRangeFilter.png └── reducedRangeFilter.png ├── searchDashboard.js ├── searchUtils.js └── widgetUtils /FilterWidget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var pd = require('pretty-data').pd; 4 | var $ = require('jquery'); 5 | var m = require('mithril'); 6 | var $osf = require('js/osfHelpers'); 7 | var searchUtils = require('js/search_dashboard/searchUtils'); 8 | var widgetUtils = require('js/search_dashboard/widgetUtils'); 9 | require('truncate'); 10 | 11 | var FilterWidget = { 12 | /** 13 | * View function of ActiveFilters component 14 | * 15 | * @param {Object} ctrl: passed by mithril, emtpy controller for object 16 | * @param {Object} params: mithril passed arg with vm, and widget information 17 | * @return {Object} div containing all (non-locked) filters 18 | */ 19 | view : function(ctrl, params) { 20 | var vm = params.vm; 21 | var widget = params.widget; 22 | var ANDFilters = vm.requests[widget.display.reqRequests[0]].userDefinedANDFilters; 23 | var ORFilters = vm.requests[widget.display.reqRequests[0]].userDefinedORFilters; 24 | 25 | var numFilters = ANDFilters.length + ORFilters.length; 26 | if (numFilters <= 0){ 27 | return m('p', {class: 'text-muted'}, 'No filters applied'); 28 | } 29 | 30 | var ANDFilterViews = $.map(ANDFilters, function(searchFilter, i) { 31 | var isLastFilter = (i + 1 === numFilters); 32 | return m.component(Filter, $.extend({},params,{key: searchFilter, filter: searchFilter, isLastFilter: isLastFilter, required: true})); 33 | }); 34 | 35 | var ORFilterViews = $.map(ORFilters, function(searchFilter, i) { 36 | var isLastFilter = (i + 1 + ANDFilters.length === numFilters); 37 | return m.component(Filter, $.extend({},params,{key: searchFilter, filter: searchFilter, isLastFilter: isLastFilter, required: false})); 38 | }); 39 | return m('div', {}, ANDFilterViews.concat(ORFilterViews)); 40 | } 41 | }; 42 | 43 | var Filter = { 44 | /** 45 | * View function of Filter component 46 | * 47 | * @param {Object} ctrl: passed by mithril, emtpy controller for object 48 | * @param {Object} params: mithril passed arg with vm, filter and widget information 49 | * @return {Object} div for display of filter 50 | */ 51 | view : function(ctrl, params) { 52 | var vm = params.vm; 53 | var searchFilter = params.filter; 54 | var required = params.required; 55 | var isLastFilter = params.isLastFilter; 56 | var widget = params.widget; 57 | var filterParts = searchFilter.split(':'); 58 | var fields = filterParts[1].split('.'); 59 | var values = filterParts.slice(2); 60 | 61 | return m('render-filter.m-t-xs.m-b-xs', {}, [ 62 | m('a.m-r-xs.m-l-xs', { 63 | onclick: function(event){ 64 | widgetUtils.signalWidgetsToUpdate(vm, params.widget.display.callbacksUpdate); 65 | searchUtils.removeFilter(vm,[], searchFilter); 66 | } 67 | }, widget.display.filterParsers[filterParts[0]](fields, values, vm, widget), m('i.fa.fa-close.m-r-xs.m-l-xs')), 68 | m('.badge.pointer', isLastFilter ? ('') : (required ? 'AND' : 'OR')) 69 | ]); 70 | } 71 | }; 72 | 73 | module.exports = FilterWidget; 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #The Search Dashboard 2 | 3 | ##By Ben Yetton 4 | 5 | ![Alt Dashboard](https://github.com/bdyetton/elasticSearchDashboard/blob/master/readme_images/dashboard.png?raw=true) 6 | 7 | **Version 0.1.0** 8 | 9 | **August 21, 2015** 10 | 11 | ***[0] Technologies Involved:*** 12 | - Mithril - Client side framework for patterns, DOM management and requests 13 | - C3.js - Charting librarary for interactive charts 14 | - Elasticsearch - database and search library for index and collating documents (nodes) 15 | - All code is in Javascript 16 | 17 | ***[1] Basic Specification:*** 18 | - Can be added to any page to display search results and statistics from an elasticsearch database via ‘search widgets’ 19 | - A search dashboard component contains basic information on what requests to send elastic and how/when to run them 20 | - Results from requests are piped to widgets, which then parse this information and display it. 21 | - Widgets can be of any type (charts, display results, searchbar, etc), and can easily be created and laid out by developers 22 | - Interaction with widgets is possible, and via callbacks to the dashboard, elastic search requests can be changed. In practice this means filters can be applied and removed from requests to update the result data that each widget displays. 23 | 24 | ***[2] Technical Specifications:*** 25 | - Add to any page via .mount call 26 | - Search requests are asynchronous and can run in serial or parallel 27 | - Search requests fully customizable and open to developer 28 | - Widgets are fully customizable and easy to create and layout 29 | - Full widget communication via spoke and wheel style pattern (widgets communicate with dashboard, and dashboard back to widgets) 30 | - Mithril c3 and elasticsearch can be abstracted away from developer 31 | 32 | ***[3] Outstanding Bugs:*** 33 | - None visible 34 | 35 | ***[4] TODOs:*** 36 | - The time series widget pulls time range data from the main request, and filtering the date range then updates this main request. This means that when a range filter is applied, the data points outside of the filter do not display on the subgraph. The desired behavior is to have the subgraph always display the full range of data, regardless of the date filters applied. In the image below, all data points show on subchart: 37 | 38 | ![Alt Full Range](https://github.com/bdyetton/elasticSearchDashboard/blob/master/readme_images/fullRangeFilter.png?raw=true) 39 | 40 | But when filter is applied (grey box), the other data points disappear: 41 | 42 | ![Alt Reduced Range](https://github.com/bdyetton/elasticSearchDashboard/blob/master/readme_images/reducedRangeFilter.png?raw=true) 43 | 44 | **FIX:** Create a new parallel request which feeds aggregation data to the timechart, but does not have range filters applied to it. All other filters such as project type, contributor, etc would be applied as normal. Range filters would continue to be applied to the main request. This functionality already exists, a new request just needs to be written. 45 | - Error handling of elastic errors. Elastic errors will only return the same error every time (‘invalid query’) but other errors such as bad aggregation are possible. 46 | 47 | ***[5] Future Features:*** 48 | - Tag cloud widget: Top tags are displayed in a word cloud: the more mentions, the bigger the cloud. Clicking a tag, filters the results by that tag 49 | - Contributions over time: In github style, personal contributions over time could be plotted from log information. Currently log information is not indexed by elasticsearch, but that it a simple fix. A separate serial request (after getting contributor names) would be necessary to get the correct logs. 50 | ‘You Activity points over time’ widget (again requires log information) 51 | - Contributors chart could be populated from contribution amount over all projects (via logs and activity points) instead of the current ‘number of projects involved in’. 52 | - On a single project page (4 - above) could show the contribution percentages of collaborators instead of the current green/blue line. 53 | - Smaller charts embedded into results widget: smaller donut/time charts could be added to individual results to show individual project stats. 54 | - OR filters. Currently only and filters are used, however, the code can handle OR filters (Contributor is XX OR contributor is XX). Some UI changes would have to be made to accommodate or filters. Potentially, AND filters could be switched to OR filters with a click. 55 | - Added to project page, main search page, share page 56 | - Nested parallel requests: Currently requests can be either parallel (fire at the same time) or serial (fire only after previous request completes). Adding ‘nested parallel’ requests means we can have parallel processes fire after the completion of another request (i.e. parallel requests nested in serial requests). Modification of the runRequests function is required, and potential use of async libraries such as async.js may be helpful 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /charts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var c3 = require('c3'); 4 | var m = require('mithril'); 5 | var $ = require('jquery'); 6 | var widgetUtils = require('js/search_dashboard/widgetUtils'); 7 | 8 | require('c3/c3.css'); 9 | require('./css/search-widget.css'); 10 | 11 | //This module contains a bunch of generic charts and parsers for formating the correct data for them 12 | 13 | var COLORBREWER_COLORS = [[166, 206, 227], [31, 120, 180], [178, 223, 138], [51, 160, 44], [251, 154, 153], [227, 26, 28], [253, 191, 111], [255, 127, 0], [202, 178, 214], [106, 61, 154], [255, 255, 153], [177, 89, 40]] 14 | var charts = {}; 15 | 16 | function calculateDistanceBetweenColors(color1, color2) { 17 | return [Math.floor((color1[0] + color2[0]) / 2), 18 | Math.floor((color1[1] + color2[1]) / 2), 19 | Math.floor((color1[2] + color2[2]) / 2)]; 20 | } 21 | 22 | function rgbToHex(rgb) { 23 | var rgb = rgb[2] + (rgb[1] << 8) + (rgb[0] << 16); 24 | return '#' + (0x1000000 + rgb).toString(16).substring(1); 25 | } 26 | 27 | /** 28 | * Returns a colors in the middle of current colors 29 | * 30 | * @param {Array} colorsUsed: colors used 31 | * @return {Array} new colors to use 32 | */ 33 | function getNewColors (colorsUsed) { 34 | var newColors = []; 35 | for (var i=0; i < colorsUsed.length-1; i++) { 36 | newColors.push(calculateDistanceBetweenColors(colorsUsed[i], colorsUsed[i + 1])); 37 | } 38 | return newColors; 39 | }; 40 | 41 | /** 42 | * Returns a requested number of unique complementary colors 43 | * 44 | * @param {integer} numColors: number of colors to return 45 | * @return {Array} Array of Hex color values 46 | */ 47 | charts.generateColors = function(numColors) { 48 | var colorsToGenerate = COLORBREWER_COLORS.slice(); 49 | var colorsUsed = []; 50 | var colorsOut = []; 51 | 52 | while (colorsOut.length < numColors) { 53 | var color = colorsToGenerate.shift(); 54 | if (typeof color === 'undefined'){ 55 | colorsToGenerate = getNewColors(colorsUsed); 56 | colorsUsed = []; 57 | } else { 58 | colorsUsed.push(color); 59 | colorsOut.push(rgbToHex(color)); 60 | } 61 | } 62 | return colorsOut; 63 | }; 64 | 65 | /** 66 | * Finds the first range filter and applies its bounds to the timegraphs zoom. 67 | * This is useful when instantiating a page from URL 68 | * 69 | * @param {Object} request: The request to get filter bounds from 70 | * @return {Array} Zoom bounds in int format (time since epoch in MS) 71 | */ 72 | charts.getZoomFromTimeRangeFilter = function(request){ 73 | var zoom = null; 74 | request.userDefinedANDFilters.some(function(filterString) { 75 | var filterParts = filterString.split('='); //remove lock qualifier if it exists 76 | if (filterParts[1] !== undefined) {return; } //there is a lock, so do nothing with this filter 77 | 78 | var parts = filterParts[0].split(':'); 79 | var type = parts[0]; 80 | if (type === 'range') { //TODO this assumes all range filters work on dates, better would be to check if field matches the 'date_created' 81 | zoom = [parseInt(parts[2]), parseInt(parts[3])]; //also this will be the last range filter that is returned... 82 | } 83 | }); 84 | return zoom; 85 | }; 86 | 87 | /** 88 | * Mithril component for the timeseries object 89 | */ 90 | charts.timeSeries = { 91 | view: function(ctrl, params){ 92 | var vm = params.vm; 93 | var widget = params.widget; 94 | var parsedData = widget.display.parser(vm.requests[widget.display.reqRequests[0]].data, widget.levelNames, vm, widget); 95 | parsedData.zoom = charts.getZoomFromTimeRangeFilter(vm.requests[widget.display.reqRequests[0]]); 96 | var chartSetup = charts.timeSeriesChart(parsedData, vm, widget); 97 | return charts.updateC3(vm, chartSetup, widget.id); 98 | } 99 | }; 100 | 101 | /** 102 | * Mithril component for the chart object 103 | */ 104 | charts.donut = { 105 | view: function(ctrl, params){ 106 | var vm = params.vm; 107 | var widget = params.widget; 108 | var parsedData = widget.display.parser(vm.requests[widget.display.reqRequests[0]].data, widget.levelNames, vm, widget); 109 | var chartSetup = charts.donutChart(parsedData, vm, widget); 110 | return charts.updateC3(vm, chartSetup, widget.id); 111 | } 112 | }; 113 | 114 | /** 115 | * Wraps and returns a c3 chart in a component, or updates already created chart 116 | * Only updates when an update to this widget has been requested 117 | * 118 | * @param {Object} c3ChartSetup: A fully setup c3 chart object 119 | * @param {Object} vm: vm of the searchDashboard 120 | * @param {Object} divID: id of the chart (name of widget) 121 | * @return {Object} c3 chart wrapped in component 122 | */ 123 | charts.updateC3 = function(vm, c3ChartSetup, divID) { 124 | return m('div.c3-chart-padding', {id: divID, 125 | config: function(element, isInit, context){ 126 | if (!isInit) { 127 | vm.widgets[divID].handle = c3.generate(c3ChartSetup); 128 | return vm.widgets[divID].handle; 129 | } 130 | if (!widgetUtils.updateTriggered(divID,vm)) {return; } 131 | vm.widgets[divID].handle.load({ 132 | columns: c3ChartSetup.data.columns, 133 | unload: true 134 | }); 135 | } 136 | }); 137 | }; 138 | 139 | charts.getChangedColumns = function(oldCols, newCols){ 140 | var changedCols = []; 141 | $.each(newCols, function(i, col){ 142 | if($.inArray(col, oldCols) === -1) changedCols.push(col); 143 | }); 144 | }; 145 | 146 | /** 147 | * Creates a c3 donut chart component 148 | * 149 | * @param {Object} vm: vm of the searchDashboard 150 | * @param {Object} widget: params of the widget that chart is being created for 151 | * @return {Object} c3 chart wrapped in component 152 | */ 153 | charts.donutChart = function (data, vm, widget) { 154 | data.onclick = widget.display.callbacks.onclick ? widget.display.callbacks.onclick.bind({ 155 | vm: vm, 156 | widget: widget 157 | }) : undefined; 158 | data.type = 'donut'; 159 | 160 | return { 161 | bindto: '#' + widget.id, 162 | size: { 163 | height: widget.size[1] 164 | }, 165 | data: data, 166 | donut: { 167 | title: widget.display.title ? widget.display.title : data.title, 168 | label: {format: widget.display.labelFormat || undefined} 169 | }, 170 | legend: { 171 | show: true, 172 | position: 'right', 173 | item : {onclick: data.onclick} 174 | } 175 | }; 176 | }; 177 | 178 | /** 179 | * Creates a c3 histogram chart component //NOT USED, UNTESTED! 180 | * 181 | * @param {Object} vm: vm of the searchDashboard 182 | * @param {Object} widget: params of the widget that chart is being created for 183 | * @return {Object} c3 chart wrapped in component 184 | */ 185 | charts.barChart = function (data, vm, widget) { 186 | data.onclick = widget.display.callbacks.onclick ? widget.display.callbacks.onclick.bind({ 187 | vm: vm, 188 | widget: widget 189 | }) : undefined; 190 | data.type = 'bar'; 191 | 192 | return { 193 | bindto: '#' + widget.id, 194 | size: { 195 | height: widget.size[1], 196 | }, 197 | data: data, 198 | tooltip: { 199 | grouped: false 200 | }, 201 | legend: { 202 | position: 'right' 203 | }, 204 | axis: { 205 | x: { 206 | tick: { 207 | format: function (d) {return ''; }, 208 | }, 209 | label: { 210 | text: widget.display.xLabel ? widget.display.xLabel : '', 211 | position: 'outer-center' 212 | } 213 | }, 214 | y: { 215 | label: { 216 | text: widget.display.yLabel ? widget.display.yLabel : '', 217 | position: 'outer-middle' 218 | }, 219 | tick: { 220 | format: function (x) { 221 | if (x !== Math.floor(x)) { 222 | return ''; 223 | } 224 | return x; 225 | } 226 | } 227 | }, 228 | rotated: true 229 | } 230 | }; 231 | }; 232 | 233 | /** 234 | * Creates a c3 timeseries chart component after parsing raw data 235 | * 236 | * @param {Object} vm: vm of the searchDashboard 237 | * @param {Object} widget: params of the widget that chart is being created for 238 | * @return {Object} c3 chart wrapped in component 239 | */ 240 | charts.timeSeriesChart = function (data, vm, widget) { 241 | data.type = widget.display.type ? widget.display.type : 'area-spline'; 242 | if (!data.zoom && widget.handle) {widget.handle.unzoom(); } 243 | return { 244 | bindto: '#' + widget.id, 245 | size: { 246 | height: widget.size[1] 247 | }, 248 | data: data, 249 | subchart: { 250 | show: true, 251 | size: { 252 | height: 30 253 | }, 254 | onbrush: widget.display.callbacks.onbrushOfSubgraph ? widget.display.callbacks.onbrushOfSubgraph.bind({ 255 | vm: vm, 256 | widget: widget, 257 | bounds: data.bounds, 258 | }) : undefined 259 | }, 260 | axis: { 261 | x: { 262 | label: { 263 | text: widget.display.xLabel ? widget.display.xLabel : '', 264 | position: 'outer-center' 265 | }, 266 | extent: data.zoom, 267 | type: 'timeseries', 268 | tick: { 269 | format: function (d) {return widgetUtils.timeSinceEpochInMsToMMYY(d); } 270 | } 271 | }, 272 | y: { 273 | label: { 274 | text: widget.display.yLabel ? widget.display.yLabel : '', 275 | position: 'outer-middle' 276 | }, 277 | tick: { 278 | format: function (x) { 279 | if (x !== Math.floor(x)) { 280 | return ''; 281 | } 282 | return x; 283 | } 284 | } 285 | } 286 | }, 287 | legend: { 288 | position: 'inset', 289 | item: { 290 | onclick: widget.display.callbacks.onclickOfLegend ? widget.display.callbacks.onclickOfLegend.bind({ 291 | vm: vm, 292 | widget: widget 293 | }) : undefined 294 | } 295 | }, 296 | padding:{ 297 | right: 15 298 | }, 299 | tooltip: { 300 | grouped: false 301 | } 302 | }; 303 | }; 304 | 305 | /** 306 | * Parses a single level of elastic search data for a c3 single level chart (such as a donut) 307 | * 308 | * @param {Object} data: raw elastic aggregation Data to parse 309 | * @param {Object} levelNames: names of each level (one in this case) 310 | * @return {Object} parsed data 311 | */ 312 | charts.singleLevelAggParser = function (data, levelNames, vm, widget) { 313 | var chartData = {}; 314 | chartData.name = levelNames[0]; 315 | chartData.columns = []; 316 | chartData.rawX = []; 317 | chartData.colors = {}; 318 | chartData.type = 'donut'; 319 | var count = 0; 320 | var hexColors = widget.display.customColors ? 321 | widget.display.customColors : charts.generateColors(data.aggregations[levelNames[0]].buckets.length); 322 | var i = 0; 323 | data.aggregations[levelNames[0]].buckets.forEach( 324 | function (bucket) { 325 | chartData.columns.push([bucket.key, bucket.doc_count]); 326 | count = count + (bucket.doc_count ? 1 : 0); 327 | chartData.colors[bucket.key] = hexColors[i]; 328 | i = i + 1; 329 | } 330 | ); 331 | chartData.title = count.toString() + ' ' + (count !== 1 ? levelNames[0] : levelNames[0].slice(0,-1)); 332 | $('.c3-chart-arcs-title').text(chartData.title); //dynamically update chart title 333 | return chartData; 334 | }; 335 | 336 | /** 337 | * Parses a single level of elastic search data for a c3 two level chart (such as a timeseries or histogram) 338 | * 339 | * @param {Object} data: raw elastic aggregation Data to parse 340 | * @param {Object} levelNames: names of each level (two in this case) 341 | * @return {Object} parsed data 342 | */ 343 | charts.twoLevelAggParser = function (data, levelNames, vm, widget) { 344 | var chartData = {}; 345 | chartData.name = levelNames[0]; 346 | chartData.columns = []; 347 | chartData.colors = {}; 348 | chartData.x = 'x'; 349 | chartData.groups = []; 350 | var grouping = []; 351 | grouping.push('x'); 352 | var hexColors = widget.display.customColors ? 353 | widget.display.customColors : charts.generateColors(data.aggregations[levelNames[0]].buckets.length); 354 | var i = 0; 355 | var xCol = []; 356 | if(data.aggregations[levelNames[0]]) { 357 | data.aggregations[levelNames[0]].buckets.forEach( 358 | function (levelOneItem) { 359 | chartData.colors[levelOneItem.key] = hexColors[i]; 360 | var column = [levelOneItem.key]; 361 | grouping.push(levelOneItem.key); 362 | levelOneItem[levelNames[1]].buckets.forEach(function (levelTwoItem) { 363 | column.push(levelTwoItem.doc_count); 364 | if (i === 0) { 365 | xCol.push(levelTwoItem.key); 366 | } 367 | }); 368 | chartData.columns.push(column); 369 | i = i + 1; 370 | } 371 | ); 372 | } 373 | 374 | chartData.groups.push(grouping); 375 | xCol.unshift('x'); 376 | chartData.columns.unshift(xCol); 377 | chartData.bounds = [chartData.columns[0][1], chartData.columns[0][chartData.columns[0].length-1]]; //get bounds of chart 378 | chartData.zoom = charts.getZoomFromTimeRangeFilter(vm.requests.mainRequest, chartData.bounds); //TODO the request name should not be here... 379 | return chartData; 380 | }; 381 | 382 | module.exports = charts; 383 | -------------------------------------------------------------------------------- /css/search-widget.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: yanonekaffeesatz; 3 | src: url(/static/css/font/share/YanoneKaffeesatz-Regular.ttf); 4 | font-weight: 400; 5 | 6 | } 7 | 8 | .widget-expand:hover { 9 | background: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #fff), color-stop(100%, #f9f9f9)); 10 | background: -moz-linear-gradient(top, #fff, #f9f9f9); 11 | background: -webkit-linear-gradient(top, #fff, #f9f9f9); 12 | background: linear-gradient(to bottom, #fff, #f9f9f9); 13 | color: #333; 14 | text-decoration: none; 15 | border-color: #c0c0c0 16 | } 17 | 18 | .widget-expand { 19 | -moz-box-sizing: border-box; 20 | -webkit-box-sizing: border-box; 21 | box-sizing: border-box; 22 | display: block; 23 | position: relative; 24 | width: 100%; 25 | height: 19px; 26 | margin-bottom: 0px; 27 | background-color: inherit; 28 | text-align: center; 29 | color: #777; 30 | border: 1px solid transparent; 31 | /* border-top-color: #e4e4e4 */ 32 | } 33 | 34 | .li.render-filter { 35 | padding: 1px; 36 | font-size: 15px; 37 | } 38 | 39 | p.readable { 40 | font-size: 15px; 41 | font-weight: normal; 42 | line-height: 20px; 43 | margin: 5px 0; 44 | } 45 | 46 | .c3 path, .c3 line { 47 | fill: none; 48 | stroke: #BAB5B5; 49 | } 50 | 51 | .c3 .c3-axis-x path, .c3 .c3-axis-x line { 52 | stroke: lightgrey; 53 | } 54 | .c3 .c3-axis-y path, .c3 .c3-axis-y line { 55 | stroke: lightgrey; 56 | } 57 | 58 | .c3-chart-padding{ 59 | padding-bottom: 5px; 60 | padding-right: 5px; 61 | padding-top: 5px; 62 | } 63 | 64 | .info_bullets li { 65 | font-size: 150%; 66 | } 67 | -------------------------------------------------------------------------------- /profile/profileDashboard.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //Defines a template for a basic search widget 3 | var m = require('mithril'); 4 | var $ = require('jquery'); 5 | var $osf = require('js/osfHelpers'); 6 | 7 | //TODO pack into lib 8 | var searchUtils = require('js/search_dashboard/searchUtils'); //This file includes useful function to help with elasticsearch 9 | var widgetUtils = require('js/search_dashboard/widgetUtils'); //This file has widget helpers 10 | var charts = require('js/search_dashboard/charts'); //This file contains components and helper functions for charting (c3 stuff) 11 | var FilterWidget = require('js/search_dashboard/FilterWidget'); //This file has the component to display filters 12 | var SearchDashboard = require('js/search_dashboard/searchDashboard'); //This is the main file that setup the dashboard and widgets + processes requests 13 | 14 | //Custom widgets... 15 | var ResultsWidget = require('./resultsWidget'); //This is a non-standard file with a mithril component to display widget //TODO make generic 16 | 17 | 18 | var profileDashboard = {}; 19 | 20 | var ctx = window.contextVars; //contains user information 21 | /** 22 | * Setups elastic aggregations to get contributers. 23 | * 24 | * @return {object} JSON elastic search aggregation 25 | */ 26 | profileDashboard.contributorsAgg = function(){ 27 | var agg = {'contributors': searchUtils.termsFilter('field', 'contributors.url', undefined, undefined, 11)}; //11 because one contributor is the user 28 | return agg; 29 | }; 30 | 31 | /** 32 | * Setups elastic aggregations to get type of node. //NOT USED CURRENTLY 33 | * 34 | * @return {object} JSON elastic search aggregation 35 | */ 36 | profileDashboard.nodeTypeAgg = function(){ 37 | var agg = {'nodeType': searchUtils.termsFilter('field','_type', 0, 'user')}; 38 | return agg; 39 | }; 40 | 41 | /** 42 | * Setups elastic aggregations to get projects by time data. 43 | * 44 | * @return {object} JSON elastic search aggregation 45 | */ 46 | profileDashboard.projectsByTimesAgg = function() { 47 | var dateRegistered = new Date(ctx.date_registered); //get current time 48 | var agg = {'projectsByTimes': searchUtils.termsFilter('field','_type', 1, 'user')}; 49 | agg.projectsByTimes.aggregations = {'projectsOverTime': searchUtils.dateHistogramFilter('date_created',dateRegistered.getTime(),undefined,'day')}; 50 | return agg; 51 | }; 52 | 53 | /** 54 | * Parses the returned contributors information, it gets the conversion between guids and names if not present already 55 | * 56 | * @return {object} Parsed data for c3 objects 57 | */ 58 | 59 | profileDashboard.contributorsParser = function(rawData, levelNames, vm){ //a custom parser, because we need guidsToNames mapping... 60 | var guidsToNames = vm.requests.nameRequest.formattedData; 61 | 62 | var chartData = {}; 63 | chartData.name = levelNames[0]; //name if the chart influences its c3 divId 64 | chartData.columns = []; //Column wise data for c3 65 | chartData.colors = {}; //colours of column data 66 | var numProjects = 0; //numb of projects to change title 67 | var hexColors = charts.generateColors(rawData.aggregations[levelNames[0]].buckets.length); //get colors for columns 68 | var i = 0; 69 | rawData.aggregations[levelNames[0]].buckets.forEach( //step into returned agg data 70 | function (bucket) { 71 | if (bucket.key === ctx.userId){ 72 | numProjects = bucket.doc_count; 73 | return; 74 | } 75 | if (bucket.doc_count) { 76 | chartData.columns.push([guidsToNames[bucket.key], bucket.doc_count]); 77 | chartData.colors[guidsToNames[bucket.key]] = hexColors[i]; 78 | i = i + 1; 79 | } 80 | } 81 | ); 82 | if (numProjects > 0) { 83 | if (numProjects > 1) { 84 | chartData.title = numProjects.toString() + ' projects & components'; 85 | } else { 86 | chartData.title = numProjects.toString() + ' project or component'; 87 | } 88 | if (chartData.columns.length === 0){ 89 | chartData.title = chartData.title + ' with no collaborators'; 90 | } 91 | } else { 92 | chartData.title = 'No Results'; 93 | } 94 | $('.c3-chart-arcs-title').text(chartData.title); //dynamically update chart title //TODO update (remove) when c3 issue #1058 resolved (dynamic update of title) 95 | return chartData; 96 | 97 | }; 98 | 99 | /** 100 | * Mount function of the profile dashboard, it constructs and mounts a SearchDashboard. 101 | * Contains settings for all widgets and requests. 102 | */ 103 | profileDashboard.mount = function(divID) { 104 | var contributorLevelNames = ['contributors','contributorsName']; //names of the levels of aggregation, used for parsing results to c3 105 | var contributors = { 106 | id: contributorLevelNames[0], //{string} id of the widget, required. 107 | title: ctx.name + '\'s top 10 contributors', // {string} title of the widget, displayed in widget panel 108 | size: ['.col-md-6', 300], //{string/int} size of the widget, the width is controlled via bootstrap, the height is controlled by the display widget (c3 in this case) 109 | levelNames: contributorLevelNames, //{array of strings} Levels names for parsing to c3 110 | aggregations: {mainRequest: profileDashboard.contributorsAgg()}, //{object} an object containing aggregations and what request (object key) they should be applied too 111 | display: { //display object contains all the params for the display component that sits in the widgets outer pannel 112 | reqRequests: ['mainRequest', 'nameRequest'], //these are the requests that need to have completed before we can update this widget, order is irrelevant 113 | component: charts.donut, //{mithril component} display component which must contain a view function that returns mithril m()'s 114 | labelFormat: function(value, ratio){return value; }, //function to run to format chart labels 115 | parser: profileDashboard.contributorsParser, //this function is run by the display widget to format data for display 116 | callbacks: { onclick : function (key) { //callbacks that run when interacting with c3 chart, this one runs on click of donut slice 117 | //bound information (this) from chart contains vm and widget 118 | var vm = this.vm; //the search widget vm 119 | var widget = this.widget; //the search widget (i.e. contributers) 120 | var request = vm.requests.mainRequest; 121 | var guidsToNames = vm.requests.nameRequest.formattedData; 122 | var name = key; 123 | if (key.name) {name = key.name; } //force click of legend or donut slice to have same name 124 | //utils.removeFilter(vm, widget.filters.contributorsFilter, true); //uncomment to overwrite last filter 125 | widget.filters.contributorsFilter = 'match:contributors.url:' + widgetUtils.getKeyFromValue(guidsToNames, name); //create and save a match filter 126 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); //Signal all the other widgets to update with next run of requests 127 | searchUtils.updateFilter(vm, [request], widget.filters.contributorsFilter, true); //add filters and run requests 128 | }}, 129 | callbacksUpdate: ['all'] //signal that interaction with this widget should update all other widgets... 130 | } 131 | }; 132 | 133 | var projectLevelNames = ['projectsByTimes', 'projectsOverTime']; 134 | var projectsByTimes = { 135 | id: projectLevelNames[0], 136 | title: ctx.name + '\'s projects and components over time', 137 | size: ['.col-md-6', 300], 138 | levelNames: projectLevelNames, 139 | display: { 140 | reqRequests : ['mainRequest'], //the first req requests data will be the 'rawData' input to any parser, other request data should be pulled from vm.requests 141 | component: charts.timeSeries, 142 | parser: charts.twoLevelAggParser, 143 | yLabel: 'Number of Projects', 144 | xLabel: 'Time', 145 | type: 'area', 146 | customColors: [], //by setting custom colors to an empty string, we get the default c3 colors 147 | callbacks: { 148 | onbrushOfSubgraph : function(zoomWin){ 149 | var vm = this.vm; 150 | var widget = this.widget; 151 | var request = vm.requests.mainRequest; 152 | var bounds = this.bounds; 153 | clearTimeout(widget.projectByTimeTimeout); //stop constant redraws 154 | widget.projectByTimeTimeout = setTimeout( //update chart with new dates after some delay (1s) to stop repeated requests 155 | function(){ 156 | if ((zoomWin[0] <= bounds[0]) && (zoomWin[1] >= bounds[1])) { 157 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 158 | searchUtils.removeFilter(vm, [request],widget.filters.rangeFilter, false); 159 | return; 160 | } 161 | searchUtils.removeFilter(vm, [request],widget.filters.rangeFilter, true); 162 | widget.filters.rangeFilter = 'range:date_created:' + zoomWin[0].getTime() + ':' + zoomWin[1].getTime(); 163 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 164 | searchUtils.updateFilter(vm, [request], widget.filters.rangeFilter,true); 165 | },1000); 166 | }, 167 | onclickOfLegend : function(item){ 168 | var vm = this.vm; 169 | var widget = this.widget; 170 | var request = vm.requests.mainRequest; 171 | searchUtils.removeFilter(vm, [request], widget.filters.typeFilter, true); 172 | widget.filters.typeFilter = 'match:_type:' + item; 173 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 174 | searchUtils.updateFilter(vm, [request], widget.filters.typeFilter ,true); 175 | } 176 | }, 177 | callbacksUpdate: ['all'] 178 | }, 179 | aggregations: { //aggregations can be attached to any request, just specify name. 180 | mainRequest: profileDashboard.projectsByTimesAgg() 181 | } 182 | }; 183 | 184 | var activeFilters = { 185 | id: 'activeFilters', 186 | title: 'Active Filters', 187 | size: ['.col-md-12'], 188 | display: { 189 | reqRequests : ['mainRequest'], 190 | component: FilterWidget, //this widget does not require any callbacks, parsers, title etc 191 | callbacks: null, //callbacks included in displayWidget 192 | callbacksUpdate: ['all'], 193 | filterParsers: { //tells the filter object how to display filters as string tags 194 | range: function(field, value){ //if we have a range filter do this 195 | if (field[0] === 'date_created') { 196 | var valueOut = widgetUtils.timeSinceEpochInMsToMMYY(parseInt(value[0])) + 197 | ' to ' + widgetUtils.timeSinceEpochInMsToMMYY(parseInt(value[1])); 198 | return 'Data created is ' + valueOut; 199 | } 200 | return value + ' ' + field; 201 | }, 202 | match: function(field, value, vm){ //if we have a match filter, do this 203 | var fieldMappings = { 204 | '_type' : 'Type is ', 205 | 'contributors': ' is a Contributor', 206 | 'tags': 'Tags contain ' 207 | }; 208 | if (field[0] === 'contributors' && field[1] === 'url') { 209 | var url = value[0].replace(/\//g, ''); 210 | var urlToNameMapper = vm.requests.nameRequest.formattedData; 211 | var valueOut = urlToNameMapper[url]; 212 | return valueOut + fieldMappings[field[0]]; 213 | } 214 | return fieldMappings[field[0]] + value; 215 | } 216 | } 217 | } 218 | }; 219 | 220 | var results = { 221 | id: 'results', 222 | title: ctx.name + '\'s public projects and components', 223 | size: ['.col-md-12'], 224 | display: { 225 | reqRequests : ['mainRequest'], 226 | component: ResultsWidget, //This widget is custom made for the profile page, so not many display settings required 227 | callbacks: null, //callbacks included in displayWidget 228 | callbacksUpdate: ['all'] 229 | } 230 | }; 231 | 232 | //Requests are the things that get data from elastic-search which can then populate widgets. 233 | // Requests run in the order specified by 'requestOrder' and run on load, filter and history change. Can also be called manually with utils.runRequests(vm) 234 | var mainRequest = { //This is the main request which the majority of aggregations are applied too (see widgets) 235 | id: 'mainRequest', //id of request, required 236 | elasticURL: '/api/v1/search/', //elasticsearch DB to hit 237 | size: 5, //The number of results we want to return 238 | page: 0, //page to begin with. without setting page to zero, pagination is not possible 239 | ANDFilters: ['match:contributors.url:' + ctx.userId], //and filters that are always applied to this request (as opposed to user added) 240 | ORFilters: ['match:_type:project', 'match:_type:component'], //or filters that are always applied to this request 241 | sort: 'Date', //start by sorting by 'Date' entry of sort map 242 | sortMap: { //tells what elastic feilds to sort based on 'sort' value above 243 | Date: 'date_created', 244 | Relevance: null 245 | } 246 | }; 247 | 248 | var nameRequest = { 249 | id: 'nameRequest', 250 | elasticURL: '/api/v1/search/', 251 | ANDFilters: ['match:category:user'], 252 | //any missing variables will be populated with defaults by search widgets (ORFilters = []) 253 | preRequest: [function (requestIn, data) { //{array of functions} functions to modify filters and query before request, the last requests data is inputed 254 | var request = $.extend({}, requestIn); 255 | var urls = []; 256 | data.aggregations.contributors.buckets.forEach( //first find urls returned 257 | function (bucket) { 258 | urls.push(bucket.key); 259 | }); 260 | var missingGuids = widgetUtils.keysNotInObject(urls, request.formattedData); 261 | if (missingGuids.length === 0){ 262 | return false; //by returning false we do not run request. 263 | } 264 | var guidFilters = []; 265 | $.map(missingGuids, function (guid) { 266 | guidFilters.push('match:id:' + guid); 267 | }); 268 | request.userDefinedORFilters = guidFilters; 269 | request.size = missingGuids.length; 270 | return request; 271 | }], 272 | postRequest: [function (requestIn, data) { //{array of functions} functions to modify filters and query before request, result data from request is inputted 273 | var request = $.extend({}, requestIn); 274 | var newGuidMaps = {}; 275 | data.results.forEach(function (user) { 276 | newGuidMaps[user.id] = user.user; 277 | }); 278 | request.formattedData = $.extend(request.formattedData, newGuidMaps); 279 | return request; 280 | }] 281 | }; 282 | 283 | var searchSetup = { 284 | url: document.location.search, //url from page, this means we can resume filter state from any url 285 | loadingIcon: function() { //loading icon that we want to display when request data not ready 286 | return m('.spinner-loading-wrapper', [m('.logo-spin.text-center', 287 | m('img[src=/static/img/logo_spin.png][alt=loader]')), 288 | m('p.m-t-sm.fg-load-message', ' Loading... ')]); 289 | }, 290 | errorHandlers: { //handlers to run when we have an error 291 | invalidQuery: function(vm){$osf.growl('Error', 'invalid query');} 292 | }, 293 | requests : { 294 | mainRequest: mainRequest, //request objects to run 295 | nameRequest: nameRequest 296 | }, 297 | requestOrder: [ //this is the order to run requests, string names here must match ids of each request. 298 | ['mainRequest', 'nameRequest'], //run these two in serial 299 | //[] //this would be run in parallel with the two above 300 | ], 301 | widgets : { //widgets to add 302 | contributors: contributors, 303 | projectsByTimes: projectsByTimes, 304 | activeFilters: activeFilters, 305 | results: results 306 | }, 307 | rowMap: [ //the row layout of the widgets 308 | ['contributors', 'projectsByTimes'], //row 1 309 | ['activeFilters'], //row 2 310 | ['results'], //row 3 311 | ], 312 | }; 313 | 314 | SearchDashboard.mount(divID, searchSetup); //call to setup dashboard and attach to a div on page 315 | }; 316 | 317 | module.exports = profileDashboard; 318 | -------------------------------------------------------------------------------- /profile/resultsWidget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var pd = require('pretty-data').pd; 4 | var $ = require('jquery'); 5 | var m = require('mithril'); 6 | var $osf = require('js/osfHelpers'); 7 | var icon = require('js/iconmap'); 8 | var searchUtils = require('js/search_dashboard/searchUtils'); 9 | var widgetUtils = require('js/search_dashboard/widgetUtils'); 10 | require('truncate'); 11 | 12 | var ResultsWidget = { 13 | /** 14 | * view function for results component. Over-arching component to display all results 15 | * Loads more results on click of 'more results' button 16 | * 17 | * @param {Object} ctrl: empty controller pasted in by mithril 18 | * @param {Object} params: contains widget and vm information 19 | * @return {Object} initialised results component 20 | */ 21 | view: function(ctrl, params) { 22 | var vm = params.vm; 23 | var results = params.vm.requests.mainRequest.data.results; 24 | var totalResults = params.vm.requests.mainRequest.data.counts.total; 25 | var widget = params.widget; 26 | var resultViews = $.map(results || [], function(result, i) { 27 | return m.component(Result, {result: result, vm: vm, widget: widget }); 28 | }); 29 | 30 | var maybeResults = function(results) { 31 | if (results.length > 0) { 32 | return results; 33 | } else if (results.length === 0) { 34 | return m('p', {class: 'text-muted'}, 'No results for this query'); 35 | } 36 | }; 37 | 38 | return m('', [ 39 | m('.row', m('.col-md-12', maybeResults(resultViews))), 40 | m('.row', m('.col-md-12', m('div', {style: {display: 'block', margin: 'auto', 'text-align': 'center'}}, 41 | results.length > 0 && results.length < totalResults ? 42 | m('a.btn.btn-md.btn-default', { 43 | onclick: function(){ 44 | searchUtils.paginateRequests(vm, []); 45 | } 46 | }, 'More') : []) 47 | )) 48 | ]); 49 | 50 | } 51 | }; 52 | 53 | var Result = { 54 | /** 55 | * view function for result component. Component displays one result 56 | * 57 | * @param {Object} ctrl: empty controller pasted in by mithril 58 | * @param {Object} params: contains result, widget and vm information 59 | * @return {Object} initialised result component 60 | */ 61 | view: function(ctrl, params) { 62 | return m( '.animated.fadeInUp', [ 63 | m('div', [ 64 | m('h4', [ 65 | m.component(TitleBar, params) 66 | ]), 67 | m('.row', [ 68 | m('.col-md-7', 69 | m('span.pull-left', 70 | m.component(Contributors, params) 71 | ) 72 | ) 73 | ]), 74 | m.component(Footer, params), 75 | m('br') 76 | ]), 77 | m('hr') 78 | ]); 79 | 80 | } 81 | }; 82 | 83 | var TitleBar = { 84 | /** 85 | * view function for TitleBar component. TitleBar contains description also 86 | * 87 | * @param {Object} ctrl: empty controller pasted in by mithril 88 | * @param {Object} params: contains result, widget and vm information 89 | * @return {Object} initialised result component 90 | */ 91 | view: function(ctrl, params) { 92 | var result = params.result; 93 | var vm = params.vm; 94 | var widget = params.widget; 95 | var nodeType = result.is_component ? 96 | (result.is_registered ? 'registeredComponent' : 'component') : 97 | (result.is_registered ? 'registration' : 'project'); 98 | 99 | return m('span', {}, [ 100 | m('div.m-xs', { 101 | 'class': icon.projectIcons[nodeType], 102 | style: 'cursor:pointer', 103 | title: nodeType, 104 | onclick: function() { 105 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 106 | searchUtils.updateFilter(vm, [], 'match:_type:' + nodeType, true); 107 | }} 108 | ), 109 | m('a[href=' + result.url + ']', ((result.title || 'No title provided'))), 110 | m('br'), 111 | m.component(Description, params) 112 | ]); 113 | } 114 | }; 115 | 116 | /* Render the description of a single result. Will highlight the matched text */ 117 | var Description = { 118 | /** 119 | * controller function for Description component. Initialises show state. 120 | * 121 | */ 122 | controller: function() { 123 | var self = this; 124 | self.showAll = false; 125 | }, 126 | /** 127 | * view function for Description component. 128 | * 129 | * @param {Object} ctrl: controller pasted in by mithril 130 | * @param {Object} params: contains result, widget and vm information 131 | * @return {Object} initialised result component 132 | */ 133 | view: function(ctrl, params) { 134 | var result = params.result; 135 | if ((result.description || '').length > 350) { 136 | return m('p.readable.pointer', { 137 | onclick: function() { 138 | ctrl.showAll = !ctrl.showAll; 139 | } 140 | }, 141 | ctrl.showAll ? result.description : $.truncate(result.description, {length: 350}) 142 | ); 143 | } else { 144 | return m('p.readable', result.description); 145 | } 146 | } 147 | }; 148 | 149 | var Contributors = { 150 | /** 151 | * controller function for contributors component. Initialises show state. 152 | */ 153 | controller: function() { 154 | var self = this; 155 | self.showAll = false; 156 | }, 157 | /** 158 | * view function for Contributors component. This displays all individual contributor components 159 | * 160 | * @param {Object} ctrl: controller pasted in by mithril 161 | * @param {Object} params: contains result, widget and vm information 162 | * @return {Object} initialised result component 163 | */ 164 | view: function(ctrl, params) { 165 | var result = params.result; 166 | var contributorViews = $.map(result.contributors, function(contributor, i) { 167 | return m.component(Contributor, $.extend({contributor: contributor, index: i}, params)); 168 | }); 169 | 170 | return m('span.pull-left', {style: {'text-align': 'left'}}, 171 | ctrl.showAll || result.contributors.length < 8 ? 172 | contributorViews : 173 | m('span', [ 174 | contributorViews.slice(0, 7), 175 | m('br'), 176 | m('a', {onclick: function(){ctrl.showAll = !ctrl.showAll;}}, 'See All') 177 | ]) 178 | ); 179 | 180 | } 181 | }; 182 | 183 | var Contributor = { 184 | /** 185 | * view function for an individual Contributors component. 186 | * 187 | * @param {Object} ctrl: controller pasted in by mithril 188 | * @param {Object} params: contains result, widget and vm information 189 | * @return {Object} initialised contributor component 190 | */ 191 | view: function(ctrl, params) { 192 | var contributor = params.contributor; 193 | var index = params.index; 194 | var vm = params.vm; 195 | var widget = params.widget; 196 | return m('span', [ 197 | m('span', index !== 0 ? ' · ' : ''), 198 | m('a', { 199 | onclick: function() { 200 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 201 | searchUtils.updateFilter(vm, [], 'match:contributors.url:' + contributor.url, true); 202 | } 203 | }, contributor.fullname) 204 | ]); 205 | } 206 | }; 207 | 208 | var Tags = { 209 | /** 210 | * controller function for tags component. Initialises show state. 211 | */ 212 | controller: function(vm) { 213 | var self = this; 214 | self.showAll = false; 215 | }, 216 | /** 217 | * view function for tags component. 218 | * 219 | * @param {Object} ctrl: controller pasted in by mithril 220 | * @param {Object} params: contains result, widget and vm information 221 | * @return {Object} initialised tags component 222 | */ 223 | view: function(ctrl, params){ 224 | var result = params.result; 225 | var tagViews = $.map(result.tags || [], function(tag, i) { 226 | return m.component(Tag, $.extend({tag: tag}, params)); 227 | }); 228 | if (ctrl.showAll || (result.tags || []).length <= 5) { 229 | return m('span', tagViews); 230 | } 231 | return m('span', [ 232 | tagViews.slice(0, 5), 233 | m('br'), 234 | m('div', m('a', {onclick: function() {ctrl.showAll = !ctrl.showAll;}},'See All')) 235 | ]); 236 | 237 | } 238 | }; 239 | 240 | var Tag = { 241 | /** 242 | * view function for an individual tag component. 243 | * 244 | * @param {Object} ctrl: controller pasted in by mithril 245 | * @param {Object} params: contains result, widget and vm information 246 | * @return {Object} initialised tag component 247 | */ 248 | view: function(ctrl, params) { 249 | var tag = params.tag; 250 | var vm = params.vm; 251 | var widget = params.widget; 252 | return m('span', m('.badge.pointer.m-t-xs', {onclick: function(){ 253 | widgetUtils.signalWidgetsToUpdate(vm, widget.display.callbacksUpdate); 254 | searchUtils.updateFilter(vm, [], 'match:tags:' + tag, true); 255 | }}, $.truncate(tag, {length: 50}), ' ' 256 | )); 257 | } 258 | }; 259 | 260 | var Footer = { 261 | view: function(ctrl, params) { 262 | var result = params.result; 263 | var vm = params.vm; 264 | return m('span', {}, [ 265 | m('row',{},[ 266 | m('span.pull-left','Date created: ' + result.date_created.substring(0,10)), 267 | m('.pull-right', 268 | {style: {'text-align': 'right'}}, 269 | m.component(Tags, params) 270 | ) 271 | ]), 272 | ]); 273 | } 274 | }; 275 | 276 | module.exports = ResultsWidget; 277 | -------------------------------------------------------------------------------- /readme_images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cos-archives/elasticSearchDashboard/cd50d6647a6af960320a7e11d6db5a7e7858755c/readme_images/dashboard.png -------------------------------------------------------------------------------- /readme_images/fullRangeFilter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cos-archives/elasticSearchDashboard/cd50d6647a6af960320a7e11d6db5a7e7858755c/readme_images/fullRangeFilter.png -------------------------------------------------------------------------------- /readme_images/reducedRangeFilter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cos-archives/elasticSearchDashboard/cd50d6647a6af960320a7e11d6db5a7e7858755c/readme_images/reducedRangeFilter.png -------------------------------------------------------------------------------- /searchDashboard.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | //Defines a template for a basic search widget 3 | var m = require('mithril'); 4 | var History = require('exports?History!history'); 5 | 6 | var widgetUtils = require('js/search_dashboard/widgetUtils'); 7 | var searchUtils = require('js/search_dashboard/searchUtils'); 8 | 9 | require('./css/search-widget.css'); 10 | 11 | var searchDashboard = {}; 12 | 13 | 14 | searchDashboard.mount = function(divId, params){ 15 | var component = { 16 | view: function(ctrl) 17 | { 18 | return m.component(searchDashboard, params); 19 | } 20 | } 21 | m.mount(divId, component); 22 | }; 23 | /** 24 | * View function for the search dashboard. Gridifys the contained 25 | * widgets depending on their row (in rowmap). 26 | * 27 | * @param {Object} ctrl: controller object automatically passed in by mithril 28 | * @return {Object} initialised searchDashboard component 29 | */ 30 | searchDashboard.view = function (ctrl, params) { 31 | var grid = []; 32 | params.rowMap.forEach(function(row) { 33 | grid.push(m('.row', {}, row.map(function (widgetName) { 34 | return m.component(SearchWidget, { 35 | key: widgetName, 36 | widget: ctrl.vm.widgets[widgetName], 37 | vm: ctrl.vm 38 | }); 39 | }))); 40 | }); 41 | return m('.col-lg-12', {} ,grid); 42 | 43 | }; 44 | 45 | /*populates a row with widgets*/ 46 | searchDashboard.returnRow = function(widgetNames, vm){ 47 | widgetNames.map(function(widgetName){ 48 | return m.component(SearchWidget, { 49 | key: vm.widgets[widgetName].id, 50 | widget: vm.widgets[widgetName], 51 | vm: vm}); 52 | }); 53 | }; 54 | 55 | 56 | searchDashboard.vm = {}; 57 | 58 | /** 59 | * controller function for a search Dashboard component. 60 | * Setups vm for dashboard, elastic searches, and params for widgets 61 | * 62 | * @return {m.component.controller} returns itself 63 | */ 64 | searchDashboard.controller = function (params) { 65 | var self = this; 66 | if (params.url){ 67 | params.url = JSON.parse(decodeURIComponent(params.url).substring(1)); 68 | } else { 69 | params.url = {}; 70 | } 71 | //search model state 72 | self.vm = searchDashboard.vm; 73 | self.vm.loadingIcon = params.loadingIcon || function(){return m('div',' Loading... '); }; 74 | self.vm.errorHandlers = params.errorHandlers; 75 | self.vm.requestOrder = params.requestOrder; 76 | self.vm.tempData = params.tempData; 77 | self.vm.widgetsToUpdate = []; 78 | self.vm.widgets = params.widgets; 79 | self.vm.widgetIds = []; 80 | for (var widget in self.vm.widgets) { 81 | if (self.vm.widgets.hasOwnProperty(widget)) { 82 | self.vm.widgetIds.push(widget); 83 | self.vm.widgets[widget].filters = {}; 84 | } 85 | } 86 | 87 | //Build requests 88 | self.vm.requests = params.requests; 89 | for (var request in self.vm.requests) { 90 | if (self.vm.requests.hasOwnProperty(request)) { 91 | var aggregations = []; 92 | for (var widget in self.vm.widgets) { 93 | if (self.vm.widgets.hasOwnProperty(widget)) { 94 | if(self.vm.widgets[widget].aggregations) { 95 | if (self.vm.widgets[widget].aggregations[request]) { 96 | aggregations.push(self.vm.widgets[widget].aggregations[request]); 97 | } 98 | } 99 | } 100 | } 101 | self.vm.requests[request] = searchDashboard.buildRequest(request, self.vm.requests[request], params.url, aggregations); 102 | } 103 | } 104 | 105 | //add hook to history 106 | History.Adapter.bind(window, 'statechange', function(e) { 107 | var historyChanged = searchUtils.hasRequestsStateChanged(self.vm); 108 | if (historyChanged){ 109 | widgetUtils.signalWidgetsToUpdate(self.vm, self.vm.widgetIds); 110 | searchUtils.updateRequestsFromHistory(self.vm); 111 | } 112 | }); 113 | 114 | //run dat shit 115 | searchUtils.runRequests(self.vm); 116 | }; 117 | 118 | /** 119 | * Builds a request object based on the current URL and user defined inputs or defaults 120 | * 121 | * @param {string} id: name of request 122 | * @param {object} userRequestParams: user defined params to override request default params, can be {} 123 | * @param {object} currentUrl: user defined params to override request default params, can be {} 124 | * @param {Array} aggs: user defined aggregations to add to this query, can be [] 125 | * @return {object} initialised Request object 126 | */ 127 | searchDashboard.buildRequest = function(id, userRequestParams, currentUrl, aggs){ 128 | var requestURL = currentUrl[id] || {}; 129 | var ANDFilters = []; 130 | var ORFilters = []; 131 | if (requestURL.ANDFilters) { 132 | ANDFilters = requestURL.ANDFilters.split('|'); 133 | } 134 | if (requestURL.ORFilters) { 135 | ORFilters = requestURL.ORFilters.split('|'); 136 | } 137 | return { 138 | id : id, 139 | elasticURL: userRequestParams.elasticURL, 140 | query: m.prop(requestURL.query || (userRequestParams.query || '*')), 141 | userDefinedANDFilters: ANDFilters || [], 142 | userDefinedORFilters: ORFilters || [], 143 | dashboardDefinedANDFilters: userRequestParams.ANDFilters || [], 144 | dashboardDefinedORFilters: userRequestParams.ORFilters || [], 145 | preRequest: userRequestParams.preRequest, 146 | postRequest: userRequestParams.postRequest, 147 | aggregations : aggs || [], 148 | size: userRequestParams.size, 149 | page: userRequestParams.page, 150 | data: null, 151 | formattedData: {}, 152 | complete: m.prop(false), 153 | sort: m.prop(requestURL.sort || userRequestParams.sort), 154 | sortMap: userRequestParams.sortMap 155 | }; 156 | }; 157 | 158 | var SearchWidget = { 159 | /** 160 | * View function for a search widget panel. Returns search widget nicely wrapped in panel with minimize actions. 161 | * 162 | * @param {Object} ctrl: controller object automatically passed in by mithril 163 | * @param {Object} params: params containing vm 164 | * @return {object} initialised SearchWidget component 165 | */ 166 | view : function (ctrl, params) { 167 | var dataReady = params.widget.display.reqRequests.every(function(req){ 168 | return params.vm.requests[req].complete(); 169 | }); 170 | 171 | return m(params.widget.size[0], {}, 172 | m('.panel.panel-default', {}, [ 173 | m('.panel-heading clearfix', {},[ 174 | m('h3.panel-title',params.widget.title), 175 | m('.pull-right', {}, 176 | m('a.widget-expand', {onclick: function () { 177 | ctrl.hidden(!ctrl.hidden()); 178 | m.redraw(true); 179 | }}, 180 | ctrl.hidden() ? m('i.fa.fa-angle-up') : m('i.fa.fa-angle-down') 181 | ) 182 | ) 183 | ]), 184 | m('.panel-body', {style: ctrl.hidden() ? 'display:none' : ''}, 185 | dataReady ? m.component(params.widget.display.component, params) : params.vm.loadingIcon()) 186 | ]) 187 | ); 188 | }, 189 | 190 | /** 191 | * controller function for a search widget panel. Initialises component. 192 | */ 193 | controller : function(params) { 194 | this.hidden = m.prop(false); 195 | } 196 | }; 197 | 198 | module.exports = searchDashboard; 199 | -------------------------------------------------------------------------------- /searchUtils.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var m = require('mithril'); 3 | var Raven = require('raven-js'); 4 | var $osf = require('js/osfHelpers'); 5 | var History = require('exports?History!history'); 6 | 7 | var callbacks = []; 8 | 9 | var searchUtils = {}; 10 | 11 | var tags = ['div', 'i', 'b', 'sup', 'p', 'span', 'sub', 'bold', 'strong', 'italic', 'a', 'small']; 12 | 13 | /* This resets the state of the vm on error */ 14 | searchUtils.errorState = function(vm){ 15 | for (var request in vm.requests) { 16 | if (vm.requests.hasOwnProperty(request)) { 17 | vm.requests[request].query = m.prop('*'); 18 | vm.requests[request].userDefinedANDFilters = []; 19 | vm.requests[request].userDefinedORFilters = []; 20 | //vm.requests[request].sort = m.sort('*') // TODO Default sort should be saved so it can be repopulated here 21 | } 22 | } 23 | m.redraw(true); 24 | if (vm.errorHandlers.invalidQuery) { //Add other errors here 25 | vm.errorHandlers.invalidQuery(vm); 26 | } 27 | }; 28 | 29 | /** 30 | * Makes request to elastic 31 | * 32 | * @param {object} vm: searchDashboard viewmodel 33 | * @param {object} request: objects of request parameters 34 | * @param {object} data: input data (possibly from previous requests) to feed into any preRequest functions 35 | * @return {object} a promise 36 | */ 37 | searchUtils.runRequest = function(vm, request, data) { 38 | var ret = m.deferred(); 39 | var runRequest = true; 40 | if (request.preRequest) { 41 | runRequest = request.preRequest.every(function (funcToRun) { 42 | var returnedRequest = funcToRun(request, data); 43 | if (!returnedRequest) { 44 | return false; //if any of the preRequest functions return false, then don't run this request 45 | } 46 | request = returnedRequest; 47 | return true; 48 | }); 49 | } 50 | 51 | if (runRequest) { 52 | m.startComputation(); 53 | request.complete(false); 54 | return m.request({ 55 | method: 'post', 56 | background: true, 57 | data: searchUtils.buildQuery(request), 58 | url: '/api/v1/search/' 59 | }).then(function (data) { 60 | var oldData = request.data; 61 | request.data = data; 62 | if (oldData !== null && request.page > 0) { //Add old results back on for pagination, but what about if we want to drop all results??? 63 | request.data.results = oldData.results.concat(request.data.results); 64 | } 65 | if (request.postRequest) { 66 | request.postRequest.forEach(function (funcToRun) { 67 | request = funcToRun(request, data); 68 | }); 69 | } 70 | request.complete(true); 71 | return data; 72 | }, function (xhr, status, err) { 73 | ret.reject(xhr, status, err); 74 | searchUtils.errorState.call(this, vm); 75 | }); 76 | } 77 | return ret.promise; 78 | }; 79 | 80 | /** 81 | * Runs all requests contained in vm according to the requestOrder specified in the view model 82 | * 83 | * @param {object} vm: searchDashboard viewmodel with requests to run 84 | */ 85 | searchUtils.runRequests = function(vm){ 86 | if (searchUtils.hasRequestsStateChanged(vm)) { 87 | searchUtils.pushRequestsToHistory(vm); 88 | } 89 | vm.requestOrder.forEach(function(parallelReqs){ //TODO move to async.js instead of this custom parellel/serial request 90 | searchUtils.recursiveRequest(vm, parallelReqs); 91 | }); 92 | }; 93 | 94 | /** 95 | * Recursively and asynchronously run requests, only running the next when the previous has complete 96 | * 97 | * @param {object} vm: searchDashboard viewmodel 98 | * @param {object} requests: serial requests to run, one after the other 99 | * @param {int} level: level to handle recusive nature of function, handled internally, this can be set undefined 100 | * @param {object} data: data from previous request to use in new request 101 | */ 102 | searchUtils.recursiveRequest = function(vm, requests, level, data){ 103 | 104 | if (level === undefined){ 105 | level = 0; //initial call 106 | } else { 107 | level = level + 1; //intermediate call 108 | if (level >= requests.length){ 109 | return; //final call 110 | } 111 | } 112 | if (vm.requests[requests[level]]) { 113 | searchUtils.runRequest(vm, vm.requests[requests[level]], data).then(function(newData){ 114 | m.endComputation(); //trigger mithril to redraw, or decrement redraw counter 115 | searchUtils.recursiveRequest(vm, requests, level, newData); 116 | }); 117 | } 118 | }; 119 | 120 | /** 121 | * Get another page worth of results from specified requests 122 | * 123 | * @param {object} vm: searchDashboard view model 124 | * @param {array} requests: a list of requests to get more results from, note if request.page does not exist, then request will not run 125 | */ 126 | searchUtils.paginateRequests = function(vm, requests){ 127 | if (requests.length === 0){ 128 | for (var request in vm.requests) { 129 | if (vm.requests.hasOwnProperty(request)) { 130 | requests.push(vm.requests[request]); 131 | } 132 | } 133 | } 134 | 135 | requests.forEach(function(request) { 136 | if (request.page !== undefined){ 137 | request.page = request.page + 1; 138 | } 139 | }); 140 | searchUtils.runRequests(vm); 141 | }; 142 | 143 | /** 144 | * populate requests with data from history, then run these requests, called on forward/back buttons by history.js 145 | * 146 | * @param {object} vm: searchDashboard viewmodel 147 | */ 148 | searchUtils.updateRequestsFromHistory = function(vm){ 149 | var state = History.getState().data.requests; 150 | for (var request in vm.requests) { 151 | if (vm.requests.hasOwnProperty(request) && state.hasOwnProperty(request)) { 152 | vm.requests[request].userDefinedORFilters = state[request].userDefinedORFilters; 153 | vm.requests[request].userDefinedANDFilters = state[request].userDefinedANDFilters; 154 | vm.requests[request].query(state[request].query); 155 | vm.requests[request].sort(state[request].sort); 156 | } 157 | } 158 | searchUtils.runRequests(vm); 159 | }; 160 | 161 | /** 162 | * Adds current requests to history 163 | * 164 | * @param {object} vm: searchDashboard viewmodel 165 | */ 166 | searchUtils.pushRequestsToHistory = function(vm){ 167 | History.pushState( 168 | {requests: vm.requests}, 169 | document.getElementsByTagName("title")[0].innerHTML, 170 | '?' + searchUtils.buildURLParams(vm) 171 | ); 172 | }; 173 | 174 | /** 175 | * Test if the state of all request has changed since last push to history 176 | * 177 | * @param {object} vm: searchDashboard viewmodel 178 | * @return {bool} Returns true if any of the requests have changed state, false otherwise 179 | */ 180 | searchUtils.hasRequestsStateChanged = function (vm) { 181 | for (var request in vm.requests) { 182 | if (vm.requests.hasOwnProperty(request)) { 183 | var stateChanged = searchUtils.hasRequestStateChanged(vm, vm.requests[request]); 184 | if (stateChanged) { 185 | return true; 186 | } 187 | } 188 | } 189 | return false; 190 | }; 191 | 192 | /** 193 | * Test if a single request has changed stage from the last push to history, called by hasRequestsStateChanged 194 | * 195 | * @param {object} vm: searchDashboard viewmodel 196 | * @param {object} currentRequest: request object to check against 197 | * @return {bool} Returns true if the request has changed state, false otherwise 198 | */ 199 | searchUtils.hasRequestStateChanged = function (vm, currentRequest){ 200 | var state = History.getState().data; 201 | if (state.requests) { 202 | if (state.requests.hasOwnProperty(currentRequest.id)) { 203 | var oldRequest = state.requests[currentRequest.id]; 204 | var isEqual = (oldRequest.query === currentRequest.query() && oldRequest.sort === currentRequest.sort() && 205 | searchUtils.arrayEqual(oldRequest.userDefinedORFilters, currentRequest.userDefinedORFilters) && 206 | searchUtils.arrayEqual(oldRequest.userDefinedANDFilters, currentRequest.userDefinedANDFilters)); 207 | if (isEqual) { 208 | return false; 209 | } 210 | } 211 | } 212 | return true; 213 | }; 214 | 215 | /** 216 | * Converts state of requests into url 217 | * 218 | * @param {object} vm: searchDashboard viewmodel 219 | * @return {string} url version of requests 220 | */ 221 | searchUtils.buildURLParams = function(vm){ 222 | var d = {}; 223 | for (var request in vm.requests) { 224 | if (vm.requests.hasOwnProperty(request)) { 225 | d[request] = {}; 226 | if (vm.requests[request].query()) { 227 | d[request].query = vm.requests[request].query(); 228 | } 229 | if (vm.requests[request].userDefinedANDFilters.length > 0) { 230 | d[request].ANDFilters = vm.requests[request].userDefinedANDFilters.join('|'); 231 | } 232 | if (vm.requests[request].userDefinedORFilters.length > 0) { 233 | d[request].ORFilters = vm.requests[request].userDefinedORFilters.join('|'); 234 | } 235 | if (vm.requests[request].sort()) { 236 | d[request].sort = vm.requests[request].sort(); 237 | } 238 | } 239 | } 240 | return encodeURIComponent(JSON.stringify(d)); 241 | }; 242 | 243 | /** 244 | * Builds elasticsearch query to be posted from request 245 | * 246 | * @param {object} request: request to build from 247 | * @return {object} JSON formatted elastic request 248 | */ 249 | searchUtils.buildQuery = function (request) { 250 | var userMust = $.map(request.userDefinedANDFilters, searchUtils.parseFilter); 251 | var userShould = $.map(request.userDefinedORFilters, searchUtils.parseFilter); 252 | var must = $.map(request.dashboardDefinedANDFilters, searchUtils.parseFilter); 253 | var should = $.map(request.dashboardDefinedORFilters, searchUtils.parseFilter); 254 | must = must.concat(userMust); 255 | should = should.concat(userShould); 256 | 257 | var size = request.size || 10; 258 | var sort = {}; 259 | 260 | if (request.sortMap){ 261 | if (request.sortMap[request.sort()]) { 262 | sort[request.sortMap[request.sort()]] = 'desc'; 263 | } 264 | } 265 | 266 | return { 267 | 'query' : { 268 | 'filtered': { 269 | 'query': (request.query().length > 0 && (request.query() !== '*')) ? searchUtils.commonQuery(request.query()) : searchUtils.matchAllQuery(), 270 | 'filter': searchUtils.boolQuery(must, null, should) 271 | } 272 | }, 273 | 'aggregations': searchUtils.buildAggs(request), 274 | 'from': request.page * size, 275 | 'size': size, 276 | 'sort': [sort], 277 | 'highlight': { //TODO @bdyetton->@fabianvf work out what this does and generalize... 278 | 'fields': { 279 | 'title': {'fragment_size': 2000}, 280 | 'description': {'fragment_size': 2000}, 281 | 'contributors.name': {'fragment_size': 2000} 282 | } 283 | } 284 | }; 285 | 286 | }; 287 | 288 | /** 289 | * Adds a filter to the list of filters for the requests specified. If no request specified then add to all requests. 290 | * Then run requests again. 291 | * 292 | * @param {object} vm: searchDashboard vm 293 | * @param {Array} requests: array of request ids to add to 294 | * @param {object} filter: filter to add 295 | * @param {required} isANDFilter: if the filter is to be ANDed or ORed 296 | */ 297 | searchUtils.updateFilter = function (vm, requests, filter, isANDFilter) { 298 | if (requests.length === 0){ 299 | for (var request in vm.requests) { 300 | if (vm.requests.hasOwnProperty(request)) { 301 | requests.push(vm.requests[request]); 302 | } 303 | } 304 | } 305 | 306 | requests.forEach(function(request) { 307 | if (isANDFilter && request.userDefinedANDFilters.indexOf(filter) === -1) { 308 | request.userDefinedANDFilters.push(filter); 309 | request.page = 0; 310 | } else if (request.userDefinedORFilters.indexOf(filter) === -1 && !isANDFilter) { 311 | request.userDefinedORFilters.push(filter); 312 | request.page = 0; 313 | } 314 | }); 315 | searchUtils.runRequests(vm); 316 | }; 317 | 318 | /** 319 | * Removes a filter to the list of filters for the requests specified. If no request specified then add to all requests. 320 | * Then run requests again. 321 | * 322 | * @param {object} vm: searchDashboard vm 323 | * @param {Array} requests: array of request ids to add to 324 | * @param {object} filter: filter to remove 325 | */ 326 | searchUtils.removeFilter = function (vm, requests, filter) { 327 | if (requests.length === 0){ 328 | for (var request in vm.requests) { 329 | if (vm.requests.hasOwnProperty(request)) { 330 | requests.push(vm.requests[request]); 331 | } 332 | } 333 | } 334 | 335 | requests.forEach(function(request){ 336 | var reqIndex = request.userDefinedANDFilters.indexOf(filter); 337 | var optIndex = request.userDefinedORFilters.indexOf(filter); 338 | if (reqIndex > -1) { 339 | request.userDefinedANDFilters.splice(reqIndex, 1); 340 | request.page = 0; //reset the page will reset the results. 341 | } 342 | if (optIndex > -1) { 343 | request.userDefinedORFilters.splice(optIndex, 1); 344 | request.page = 0; 345 | } 346 | }); 347 | searchUtils.runRequests(vm); 348 | }; 349 | 350 | /* Tests array equality */ 351 | searchUtils.arrayEqual = function (a, b) { 352 | return $(a).not(b).length === 0 && $(b).not(a).length === 0; 353 | }; 354 | 355 | /** 356 | * Parses a filter string into one of the above filters 357 | * 358 | * parses a filter of the form 359 | * filterName:fieldName:param1:param2... 360 | * ex: range:providerUpdatedDateTime:2015-06-05:2015-06-16 361 | * @param {String} filterString A string representation of a filter dictionary 362 | */ 363 | searchUtils.parseFilter = function (filterString) { 364 | var parts = filterString.split(':'); 365 | var type = parts[0]; 366 | var field = parts[1]; 367 | 368 | // Any time you add a filter, put it here 369 | switch(type) { 370 | case 'range': 371 | return searchUtils.rangeFilter(field, parts[2], parts[3]); 372 | case 'match': 373 | return searchUtils.queryFilter( 374 | searchUtils.matchQuery(field, parts[2]) 375 | ); 376 | } 377 | }; 378 | 379 | /** 380 | * Adds aggregation to current aggregation, returns combination. 381 | * If global flag set, then agg becomes global of all elastic queries 382 | * 383 | * @param {object} currentAgg: agg object to add to 384 | * @param {object} newAgg: new agg to add 385 | * @param {bool} globalAgg: filter to add 386 | * @return {object} combined new agg 387 | */ 388 | searchUtils.updateAgg = function (currentAgg, newAgg, globalAgg) { 389 | globalAgg = globalAgg || false; 390 | 391 | if (currentAgg) { 392 | var returnAgg = $.extend({},currentAgg); 393 | if (returnAgg.all && globalAgg) { 394 | $.extend(returnAgg.all.aggregations, newAgg); 395 | } else { 396 | $.extend(returnAgg, newAgg); 397 | } 398 | return returnAgg; 399 | } 400 | 401 | if (globalAgg) { 402 | return {'all': {'global': {}, 'aggregations': newAgg}}; 403 | } 404 | 405 | return newAgg; //else, do nothing 406 | }; 407 | 408 | /** 409 | * creates and returns aggregation from request object with list of aggregations 410 | * 411 | * @param {object} request: Request object with list of aggregations to add 412 | * @return {object} agg object in elasticsearch format 413 | */ 414 | searchUtils.buildAggs = function (request) { 415 | var currentAggs = {}; 416 | if (request.aggregations === undefined) {return []; } 417 | $.map(request.aggregations, function (agg) { 418 | currentAggs = searchUtils.updateAgg(currentAggs, agg, false); 419 | }); 420 | return currentAggs; 421 | }; 422 | 423 | /*** Elasticsearch functions below ***/ 424 | 425 | /** 426 | * Creates a filtered query in elastic search format 427 | * 428 | * @param {object} query: query to bve filtered 429 | * @param {object} filter: filter object to apply to query 430 | * @return {object} elastic formatted filtered query 431 | */ 432 | searchUtils.filteredQuery = function(query, filter) { 433 | var ret = { 434 | 'filtered': {} 435 | }; 436 | if (filter) { 437 | ret.filtered.filter = filter; 438 | } 439 | if (query) { 440 | ret.filtered.query = query; 441 | } 442 | return ret; 443 | }; 444 | 445 | /** 446 | * Creates a term filter in elastic search format 447 | * 448 | * @param {object} field: Field to filter on (generally feild) 449 | * @param {object} value: value to filter (must match this) 450 | * @param {object} minDocCount: The smallest number of results that must match before inclusion in results 451 | * @param {object} exclusions: Excluded terms from search (i.e. if it contains these values, then dont return result) 452 | * @return {object} elastic formatted filtered query 453 | */ 454 | searchUtils.termsFilter = function (field, value, minDocCount, exclusions) { 455 | minDocCount = minDocCount || 0; 456 | exclusions = ('|'+ exclusions) || ''; 457 | var ret = {'terms': {}}; 458 | ret.terms[field] = value; 459 | ret.terms.size = 0; 460 | ret.terms.exclude = 'of|and|or' + exclusions; 461 | ret.terms.min_doc_count = minDocCount; 462 | return ret; 463 | }; 464 | 465 | /* Creates a match query */ 466 | searchUtils.matchQuery = function (field, value) { 467 | var ret = {'match': {}}; 468 | ret.match[field] = value; 469 | return ret; 470 | }; 471 | 472 | /* Creates a range filter */ 473 | searchUtils.rangeFilter = function (fieldName, gte, lte) { 474 | lte = lte || new Date().getTime(); 475 | gte = gte || 0; 476 | var ret = {'range': {}}; 477 | ret.range[fieldName] = {'gte': gte, 'lte': lte}; 478 | return ret; 479 | }; 480 | 481 | /* Creates a bool query */ 482 | searchUtils.boolQuery = function (must, mustNot, should, minimum) { 483 | var ret = { 484 | 'bool': { 485 | 'must': (must || []), 486 | 'must_not': (mustNot || []), 487 | 'should': (should || []) 488 | } 489 | }; 490 | if (minimum) { 491 | ret.bool.minimum_should_match = minimum; 492 | } 493 | 494 | return ret; 495 | }; 496 | 497 | /* Creates a date histogram filter */ 498 | searchUtils.dateHistogramFilter = function (field, gte, lte, interval) { 499 | //gte and lte in ms since epoch 500 | lte = lte || new Date().getTime(); 501 | gte = gte || 0; 502 | 503 | interval = interval || 'week'; 504 | return { 505 | 'date_histogram': { 506 | 'field': field, 507 | 'interval': interval, 508 | 'min_doc_count': 0, 509 | 'extended_bounds': { 510 | 'min': gte, 511 | 'max': lte 512 | } 513 | } 514 | }; 515 | }; 516 | 517 | /* Creates a common query */ 518 | searchUtils.commonQuery = function (queryString, field) { 519 | field = field || '_all'; 520 | var ret = {'common': {}}; 521 | ret.common[field] = { 522 | query: queryString 523 | }; 524 | return ret; 525 | }; 526 | 527 | /* Creates a match_all query */ 528 | searchUtils.matchAllQuery = function () { 529 | return { 530 | match_all: {} 531 | }; 532 | }; 533 | 534 | /* Creates an and filter */ 535 | searchUtils.andFilter = function (filters) { 536 | return { 537 | 'and': filters 538 | }; 539 | }; 540 | 541 | /* Creates a query filter */ 542 | searchUtils.queryFilter = function (query) { 543 | return { 544 | query: query 545 | }; 546 | }; 547 | 548 | module.exports = searchUtils; 549 | -------------------------------------------------------------------------------- /widgetUtils: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var $ = require('jquery'); 3 | var $osf = require('js/osfHelpers'); 4 | 5 | var widgetUtils = {}; 6 | 7 | /** 8 | * Return time since epoch (ms) in human readable format 9 | * 10 | * @param {string} timeSinceEpochInMs: the time since epoch in ms 11 | * @return {string} human readable date 12 | */ 13 | widgetUtils.timeSinceEpochInMsToMMYY = function(timeSinceEpochInMs) { 14 | var d = new Date(timeSinceEpochInMs); 15 | return (d.getDate()+1).toString() + '/' + (d.getMonth()+1).toString() + '/' + d.getFullYear().toString().substring(2); 16 | }; 17 | 18 | /** 19 | * Checks if an update has been requested for this widget. THIS IS NOT A PURE FUNCTION: vm.widgetsToUpdate is modified 20 | * 21 | * @param {string} widgetName: name of widget to check 22 | * @param {object} vm: searchDashboard vm containing a list of widgets that need to be updated (vm.widgetsToUpdate) 23 | * @return {bool} returns true if it has, false if not. 24 | */ 25 | widgetUtils.updateTriggered = function(widgetName, vm){ 26 | if (vm.widgetsToUpdate.indexOf(widgetName) === -1){ 27 | return false; 28 | } 29 | vm.widgetsToUpdate.splice($.inArray(widgetName, vm.widgetsToUpdate), 1); //signal that this widget has been redrawn 30 | return true; 31 | }; 32 | 33 | /** 34 | * Adds the widget to the list of widgets that need to be updated (will actually update at next redraw) 35 | * 36 | * @param {Object} vm: searchDashboard vm containing a list of widgets that need to be updated (vm.widgetsToUpdate) 37 | * @param {Array} widgetsToAdd: list of widgets that need to be added to the list of widgets to update 38 | */ 39 | widgetUtils.signalWidgetsToUpdate = function(vm, widgetsToAdd){ 40 | if ($.inArray('all', widgetsToAdd) > -1){ 41 | vm.widgetsToUpdate = widgetUtils.concatUnique(vm.widgetsToUpdate, vm.widgetIds); 42 | return; 43 | } 44 | vm.widgetsToUpdate = widgetUtils.concatUnique(vm.widgetsToUpdate, widgetsToAdd); 45 | }; 46 | 47 | /** 48 | * Concatenates too arrays and removes non-unique values 49 | * 50 | * @param {Array} array1: array to add to 51 | * @param {Array} array2: array to add 52 | * @return {Bool} concatenation of the unique values of the two arrays 53 | */ 54 | widgetUtils.concatUnique = function(array1, array2){ 55 | var temp = array1.concat(array2); 56 | return temp.filter(function(item, i, arr){ return arr.indexOf(item) === i; }); //make unique so we dont redraw widgets more than we have to 57 | }; 58 | 59 | /** 60 | * Finds and returns the keys not present in an object. 61 | * 62 | * @param {array} keys: an array of keys to check against 63 | * @param {object} object: The object to search through 64 | * @return {array} Missing keys from object, returns [] if nothing missing 65 | */ 66 | widgetUtils.keysNotInObject = function(keys, object){ 67 | return $.map(keys, function(key){ 68 | if (object[key] === undefined){ 69 | return key; 70 | } 71 | }); 72 | }; 73 | 74 | /** 75 | * finds and returns and object from a list of objects whose id matches the given id. 76 | * 77 | * @param {array} listOfObjects: A array containing a set of objects to match the ide feild with 78 | * @param {string} id: The id to match 79 | * @return {Object} matched object 80 | */ 81 | widgetUtils.getObjectById = function(listOfObjects, id, field){ 82 | field = field || 'id'; 83 | var obIndex = $.map(listOfObjects, function(obj, index) { 84 | if (obj[field] === id) { 85 | return index; 86 | } 87 | }); 88 | return listOfObjects[obIndex]; 89 | }; 90 | 91 | /** 92 | * Get the key of the object whose value matches given value 93 | * 94 | * @param {Object} object: Object to search through 95 | * @param {string/int} value: value to match to 96 | * @return {Object} matched object 97 | */ 98 | widgetUtils.getKeyFromValue = function(object, value){ 99 | var key; 100 | for(key in object){ 101 | if(object[key] === value){ 102 | return key; 103 | } 104 | } 105 | return null; 106 | }; 107 | 108 | module.exports = widgetUtils; 109 | --------------------------------------------------------------------------------