UNPKG

labo-components

Version:
1,067 lines (919 loc) 41.5 kB
import React from 'react'; import PropTypes from 'prop-types'; //search api import SearchAPI from '../../api/SearchAPI'; //data utilities import CollectionUtil from '../../util/CollectionUtil'; import ComponentUtil from '../../util/ComponentUtil'; import IDUtil from '../../util/IDUtil'; import TimeUtil from '../../util/TimeUtil'; import LocalStorageHandler from '../../util/LocalStorageHandler'; import Query from '../../model/Query'; //ui controls for assembling queries import SearchTermInput from './SearchTermInput' import FieldCategorySelector from './FieldCategorySelector'; import DateFieldSelector from './DateFieldSelector'; import DateRangeSelector from './DateRangeSelector'; import KeywordFieldSelector from './KeywordFieldSelector'; import KeywordTermLimitSelector from './KeywordTermLimitSelector'; import AggregationList from './AggregationList'; //visualisations import Histogram from '../stats/Histogram'; import TermHistogram from '../stats/TermHistogram'; import QuerySingleLineChart from '../stats/QuerySingleLineChart'; import TermQuerySingleLineChart from '../stats/TermQuerySingleLineChart'; //simple visual component import MessageHelper from '../helpers/MessageHelper'; //third party import ReactTooltip from 'react-tooltip'; import classNames from 'classnames'; export default class QueryBuilder extends React.Component { //should have an initial query in the props, then only updates it in the state constructor(props) { super(props); this.state = { displayFacets : this.props.collectionConfig.facets ? true : false, showTimeLine: LocalStorageHandler.checkLocalStorageKey('state-show-timeline') === true ? LocalStorageHandler.getJSONFromLocalStorage('state-show-timeline') : this.props.showTimeLine, showKeywordHistogram: LocalStorageHandler.checkLocalStorageKey('state-show-keyword-histogram') === true ? LocalStorageHandler.getJSONFromLocalStorage('state-show-keyword-histogram') : this.props.showKeywordHistogram, dateGraphType : null, termGraphType : null, isSearching : false, selectedKeywordField: this.props.selectedKeywordField ? this.props.selectedKeywordField : null, //this is selected by the user termLimit: 20, //this can be changed by the user query : this.props.query, //this is only set by the owner after choosing a collection or loading the page aggregations : {}, searchTerm: this.props.query ? this.props.query.term : null, isQueryAccessDenied : this.props.isQueryAccessDenied || false, facetsLocked : false, }; this.CLASS_PREFIX = 'qb'; } /*---------------------------------- COMPONENT INIT --------------------------------------*/ //TODO also provide an option to directly pass a config, this is pretty annoying with respect to reusability componentDidMount() { //do an initial search in case there are search params in the URL //NB: make sure the search form is rendered (and not e.g. the query access denied message) if(this.props.query && !this.props.isQueryAccessDenied) { this.state.searchTerm = this.props.query.term || null; this.doSearch(this.props.query); } } switchDateGraphType(typeOfGraph) { this.setState({ dateGraphType : typeOfGraph } ) } switchTermGraphType(typeOfGraph) { this.setState({ termGraphType : typeOfGraph } ) } /*---------------------------------- SEARCH --------------------------------------*/ doSearch(query) { if(this.props.onStartSearch && typeof(this.props.onStartSearch) === 'function') { this.props.onStartSearch(); } if(query.storeAfterExecution) { //whenever the query is stored (via the SearchAPI) make sure to delete the stored-priority-query LocalStorageHandler.removeJSONByKeyInLocalStorage('stored-priority-query'); } this.setState( {isSearching : true}, () => { SearchAPI.search( query, this.props.collectionConfig, this.onOutput.bind(this), query.storeAfterExecution ) } ) } newSearch = term => { this.state.searchTerm = term //FIXME this is a really nasty use of setting a state variable... const q = this.state.query; //reset certain query properties if(this.state.totalHits <= 0) { q.dateRange = null; // only reset when there are no results } if (!this.state.facetsLocked) { q.selectedFacets = {}; // reset the facets if not locked } q.offset = 0; q.term = this.getCurrentSearchTerm(); // make sure the term is always a string, otherwise 0 results by default q.includeMediaObjects = this.props.collectionConfig.includeMediaObjects(q.term); // influenced by term, so check it always this.doSearch(q); }; blockSearch = e => e.preventDefault(); clearSearch = () => this.onOutput(null); //this resets the paging toggleSearchLayer(e) { const q = this.state.query; const searchLayers = this.state.query.searchLayers; searchLayers[e.target.id] = !searchLayers[e.target.id]; //reset certain query properties q.searchLayers = searchLayers; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } //this sets the state of the 'lock facets' option onFacetLockChange(e) { this.state.facetsLocked = e.target.checked } /*---------------------------------- FUNCTION THAT RECEIVES DATA FROM CHILD COMPONENTS --------------------------------------*/ onComponentOutput(componentClass, data) { if(componentClass === 'AggregationList') { const q = this.state.query; //reset the following query params q.desiredFacets = data.desiredFacets; q.selectedFacets = data.selectedFacets; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } else if(componentClass === 'DateRangeSelector') { const q = this.state.query; //reset the following params q.dateRange = Object.assign(data, {field: q.dateRange ? q.dateRange.field : null }); q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q) } else if(componentClass === 'KeywordFieldSelector') { const df = this.state.query.desiredFacets; const sf = this.state.query.selectedFacets; this.state.termLimit = 20 // reset the term limit when changing the keyword //check if the keyword has changed or been removed if ((this.state.selectedKeywordField && data == null) || (data && data.field != this.state.selectedKeywordField)){ //now check if the old selection is one of the facets defined for the //collection. If not, delete the old selection from the desired and selected facets let isCoreFacet = false; this.props.collectionConfig.getFacets().forEach(element => {if(element.field === this.state.selectedKeywordField ) { isCoreFacet = true; } }); if (!isCoreFacet){ let index = -1; df.forEach(element => {if(element.field === this.state.selectedKeywordField) { index = df.indexOf(element); } }); if(index !== -1) { df.splice(index,1); } if(this.state.selectedKeywordField in sf){ delete sf[this.state.selectedKeywordField]; } } //add the new selection if(data) { let selectionAlreadyPresent = false df.forEach(element => {if(element.field === data.field) { selectionAlreadyPresent = true; } }); if(!selectionAlreadyPresent){ //add the desired term aggregation (of the type string) df.push({ field: data.field, title : this.props.collectionConfig.toPrettyFieldName(data.field), id : data.field, type : 'string' }); } } const q = this.state.query; //reset the following params q.desiredFacets = df; q.offset = 0; q.term = this.getCurrentSearchTerm(); //save the keyword in the state this.state.selectedKeywordField = (data && data.field) ? data.field : null this.doSearch(q); } //save the term limit in the state if(data && data.termLimit){ this.setState({termLimit: data.termLimit}); } }else if(componentClass === 'KeywordTermLimitSelector') { //save the term limit in the state if(data && data.termLimit){ this.setState({termLimit: data.termLimit}); } }else if(componentClass === 'DateFieldSelector') { //first delete the old selection from the desired facets const df = this.state.query.desiredFacets; let index = -1; df.forEach(element => {if(element.type === 'date_histogram') { index = df.indexOf(element); }}); if(index !== -1) { df.splice(index,1); } //add the new selection if(data !== null) { //add the desired date aggregation (of the type date_histogram) df.push({ field: data.field, title : this.props.collectionConfig.toPrettyFieldName(data.field), id : data.field, type : 'date_histogram' }); } const q = this.state.query; //reset the following params q.dateRange = data; q.desiredFacets = df; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } else if(componentClass === 'FieldCategorySelector') { const q = this.state.query; q.fieldCategory = data; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q) } else if(componentClass === 'SearchTermInput') { //called when entities have been added via autocomplete or deleted const entityAdded = (!this.state.query.entities || this.state.query.entities.length < data.length) const q = this.state.query; q.entities = data; q.offset = 0; if(entityAdded){ q.term = ""; //clear the search term as the search field has been wiped by the text entered to find the entity this.state.searchTerm = ""; } //if an entity has been deleted then we keep the search term this.doSearch(q) } } /*---------------------------------- FUNCTIONS THAT COMMUNICATE TO THE PARENT --------------------------------------*/ //this function is piped back to the owner via onOutput() gotoPage = pageNumber => { const q = this.state.query; q.offset = (pageNumber-1) * this.props.pageSize; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } //this function is piped back to the owner via onOutput() sortResults(sortParams) { const q = this.state.query; q.sort = sortParams; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } resetDateRange() { const q = this.state.query; q.dateRange = null; q.offset = 0; q.term = this.getCurrentSearchTerm(); this.doSearch(q); } toggleTimeLine = () => { LocalStorageHandler.storeJSONInLocalStorage('state-show-timeline', !this.state.showTimeLine); this.setState({showTimeLine:!this.state.showTimeLine}); } toggleKeywordHistogram = () => { LocalStorageHandler.storeJSONInLocalStorage('state-show-keyword-histogram', !this.state.showKeywordHistogram); this.setState({showKeywordHistogram:!this.state.showKeywordHistogram}); } /* ----------------------------------------- DATA FUNCTIONS FOR THE RENDER ------------------------------ */ //FIXME not a pure function calcTotalDatesOutsideOfRange = (currentDateAggregation, dateRange) => { if(!currentDateAggregation || !dateRange) return 0; const startMillis = dateRange.start; const endMillis = dateRange.end; const datesOutsideOfRange = currentDateAggregation.filter(x => { if(startMillis != null && x.date_millis < startMillis) { return true; } return endMillis !== null && x.date_millis > endMillis; }); if(datesOutsideOfRange.length > 0) { return datesOutsideOfRange.map((x => x.doc_count)).reduce(function(accumulator, currentValue) { return accumulator + currentValue; }); } return 0; } calcDateCounts = dateAggregation => { if(!dateAggregation || dateAggregation.length === 0) { return -1; } return dateAggregation.map( (x => x.doc_count)).reduce(function(accumulator, currentValue) { return accumulator + currentValue; } ); }; getCurrentDateAggregation = (aggregations, dateRange) => { if(dateRange && dateRange.field !== 'null_option' && aggregations[dateRange.field] !== undefined) { return aggregations[dateRange.field].filter(value => value && value.key != 'Empty field'); } return null; }; getCurrentKeywordAggregation = (aggregations, selectedKeywordField) => { if(selectedKeywordField!== 'null_option' && aggregations[selectedKeywordField] !== undefined) { return aggregations[selectedKeywordField].filter(value => value && value.key != 'Empty field'); } return null; }; getCurrentSearchTerm = () => { return this.state.searchTerm || ""; }; /* ----------------------------------------- COMPONENT OUTPUT FUNCTION ------------------------------ */ //communicates all that is required for a parent component to draw hits & statistics onOutput(resultsObj) { // instance of SearchResults //this propagates the query output back to the recipe, who will delegate it further to any configured visualisation if (this.props.onOutput) { this.props.onOutput(this.constructor.name, resultsObj); } if (resultsObj && !resultsObj.error) { this.setState( { //so involved components know that a new search was done searchId: resultsObj.searchId, //refresh params of the query object query : resultsObj.query, //actual OUTPUT of the query aggregations: ComponentUtil.filterWeirdDates(resultsObj.aggregations, resultsObj.query.dateRange, this.props.collectionConfig), totalHits: resultsObj.totalHits, //shown in the stats totalUniqueHits: resultsObj.totalUniqueHits, //shown in the stats isQueryAccessDenied : false }, () => { this.setState({isSearching: false}); } ); } else { console.debug('ERROR?', resultsObj) //Note: searchLayers & desiredFacets & selectedSortParams stay the same const q = this.state.query; //q.dateRange = null; q.selectedFacets = {}; //q.fieldCategory = null; this.setState( { searchId: null, query : q, //query OUTPUT is all empty aggregations: null, totalHits: 0, totalUniqueHits: 0, isQueryAccessDenied: resultsObj && resultsObj.error === 'Access denied' }, () => { this.setState({isSearching: false}); } ); } } /* ----------------------------------------- RENDER FUNCTIONS ------------------------------ */ renderNoResultsMessage = (aggregations, query, onClearSearch) => { if(aggregations && query) { return ( <div className={classNames("alert alert-danger")}> {MessageHelper.renderNoSearchResultsMessage(query, onClearSearch)} </div> ) } return null; }; renderPagingOutOfBoundsMessage = () => ( <div className="alert alert-danger"> {MessageHelper.renderPagingOutOfBoundsMessage(() => this.gotoPage(1))} </div> ); //if the search API returned an access denied error, return a helpful message for the user renderQueryAccessDeniedMessage = onClearSearch => ( //TODO it should clear the entire search instead of going to page 1 <div className="alert alert-danger"> {MessageHelper.renderQueryAccessDeniedMessage(onClearSearch)} </div> ); renderQueryResultHits = totalHits => { return ( <span className={IDUtil.cssClassName('total-count', this.CLASS_PREFIX)} title="Total number of results based on keyword and selected filters"> Results <span className={IDUtil.cssClassName('count', this.CLASS_PREFIX)}> {ComponentUtil.formatNumber(totalHits)} </span> </span> ); }; renderFieldCategorySelector = (query, collectionConfig, onOutput) => { return ( <FieldCategorySelector queryId={query.id} fieldCategory={query.fieldCategory} collectionConfig={collectionConfig} onOutput={onOutput} /> ); }; renderDateFieldSelector = (searchId, query, aggregations, collectionConfig, onOutput) => { if(collectionConfig.getDateFields() == null) return null; return ( <DateFieldSelector searchId={searchId} //for determining when the component should rerender queryId={query.id} //used for the guid (is it still needed?) dateRange={query.dateRange} //for activating the selected date field aggregations={aggregations} //to fetch the date aggregations collectionConfig={collectionConfig} //for determining available date fields & aggregations onOutput={onOutput} //for communicating output to the parent component /> ); }; renderDateRangeSelector = (searchId, query, aggregations, collectionConfig, onOutput) => { if(collectionConfig.getDateFields() == null) return null; return ( <DateRangeSelector searchId={searchId} //for determining when the component should rerender queryId={query.id} //used for the guid (is it still needed?) dateRange={query.dateRange} //for activating the selected date field aggregations={aggregations} //to fetch the date aggregations collectionConfig={collectionConfig} //for determining available date fields & aggregations onOutput={onOutput} //for communicating output to the parent component /> ); }; renderKeywordFieldSelector = (searchId, query, selectedKeywordDataField, collectionConfig, onOutput) => { if(collectionConfig.getKeywordFields() == null) return null; return ( <KeywordFieldSelector searchId={searchId} //for determining when the component should rerender collectionConfig={collectionConfig} //for determining available keyword fields & aggregations allowHeavyFacets={(query !== undefined && query.term !== undefined && query.term.length > 2)} //for determining whether it is smart to allow all facets to be selected selectedField = {selectedKeywordDataField} //selected field onOutput={onOutput} //for communicating output to the parent component /> ); }; renderKeywordTermLimit = (searchId, termLimit, dataLimit, onOutput) => { return ( <KeywordTermLimitSelector searchId={searchId} //for determining when the component should rerender termLimit = {termLimit} //selected term limit dataLimit = {dataLimit} //limit to number of terms in data onOutput={onOutput} //for communicating output to the parent component /> ); }; renderAggregationList = (searchId, query, aggregations, collectionConfig, onComponentOutput) => ( <div className="aggregation-list"> <AggregationList searchId={searchId} //for determining when the component should rerender allowHeavyFacets={(query !== undefined && query.term !== undefined && query.term.length > 2)} //for determining whether it is smart to allow all facets to be selected queryId={query.id} //TODO implement in the list component aggregations={aggregations} //part of the search results desiredFacets={query.desiredFacets} selectedFacets={query.selectedFacets} collectionConfig={collectionConfig} //for the aggregation creator only onOutput={onComponentOutput} //for communicating output to the parent component key={searchId} /> </div> ); renderSearchTermInput = (collectionConfig, fieldCategories, entities, term, newSearchFunc, onComponentOutput) => ( <SearchTermInput collectionConfig={collectionConfig} fieldCategories = {fieldCategories ? fieldCategories : []} entities = {entities} term = {term} newSearch = {newSearchFunc} //for changed search term onSuggestionOutput = {onComponentOutput} //for selection of suggestion /> ); //FIXME for motu & arttube this is needed. Now it is deactivated though! //renders the checkboxes for selecting layers renderSearchLayerOptions = () => { if(this.state.query.searchLayers && 1===2) { //DEACTIVATED const layers = Object.keys(this.state.query.searchLayers).map((layer, index) => { return ( <label key={'layer__' + index} className="checkbox-inline"> <input id={layer} type="checkbox" checked={this.state.query.searchLayers[layer] === true} onChange={this.toggleSearchLayer.bind(this)}/> {CollectionUtil.getSearchLayerName(this.props.collectionConfig.getSearchIndex(), layer)} </label> ) }); if(layers) { return ( <div className={IDUtil.cssClassName('search-layers', this.CLASS_PREFIX)}> {layers} </div> ) } } return null; }; renderTimeLine = (dateAggregation, graphType, searchId, query, collectionConfig) => { if(!dateAggregation) { return null; } else if (dateAggregation.length === 0) { return MessageHelper.renderNoDocumentsWithDateFieldMessage(); } if (graphType === 'lineChart') { return ( <div className={IDUtil.cssClassName('graph', this.CLASS_PREFIX)}> <button onClick={this.switchDateGraphType.bind(this, 'histogram')} type="button" className="btn btn-primary btn-xs"> Histogram </button> <QuerySingleLineChart query={query} data={dateAggregation} onClick={this.filterByDateRange} collectionConfig={collectionConfig} /> </div> ); } else { return ( <div className={IDUtil.cssClassName('graph', this.CLASS_PREFIX)}> <button onClick={this.switchDateGraphType.bind(this, 'lineChart')} type="button" className="btn btn-primary btn-xs"> Line chart </button> <Histogram query={query} data={dateAggregation} collectionConfig={collectionConfig} onClick={this.filterByDateRange} title={collectionConfig.toPrettyFieldName(query.dateRange.field)} /> </div> ); } }; renderTermHistogram = (selectedKeywordField, termLimit, termAggregation, graphType, searchId, query, collectionConfig) => { if(!termAggregation) { return null; } else if (termAggregation.length === 0) { return MessageHelper.renderNoDocumentsWithTermFieldMessage(); } if (graphType === 'lineChart') { return ( <div className={IDUtil.cssClassName('graph', this.CLASS_PREFIX)}> <button onClick={this.switchTermGraphType.bind(this, 'histogram')} type="button" className="btn btn-primary btn-xs"> Histogram </button> <TermQuerySingleLineChart title={collectionConfig.toPrettyFieldName(selectedKeywordField)} query={query} data={termAggregation} selectedKeywordField={selectedKeywordField} termLimit={termLimit} collectionConfig={collectionConfig} onClick={this.filterByKeywordField} /> </div> ); } else { return ( <div className={IDUtil.cssClassName('graph', this.CLASS_PREFIX)}> <button onClick={this.switchTermGraphType.bind(this, 'lineChart')} type="button" className="btn btn-primary btn-xs"> Line chart </button> <TermHistogram title={collectionConfig.toPrettyFieldName(selectedKeywordField)} query={query} data={termAggregation} selectedKeywordField={selectedKeywordField} termLimit={termLimit} collectionConfig={collectionConfig} onClick={this.filterByKeywordField} /> </div> ); } }; filterByDateRange = e => { const q = Query.construct(this.state.query); q.dateRange.start = e+"-01-01" q.dateRange.end = e+"-12-31" this.doSearch(q) }; filterByKeywordField = e => { const q = Query.construct(this.state.query); //overwrite any existing filter values for this facet - check this behaviour with users q.selectedFacets[this.state.selectedKeywordField] = [e] this.doSearch(q) }; renderDateRangeCrumb = (query, onResetDateRange) => { let info = 'Everything '; if (query.dateRange.start && query.dateRange.end) { info += 'since ' + query.dateRange.start + ' until ' + query.dateRange.end; } else if (query.dateRange.start) { info += 'since ' + query.dateRange.start; } else { info += 'until ' + query.dateRange.end; } info += ' (using: ' + query.dateRange.field + ')'; return ( <div className={IDUtil.cssClassName('breadcrumbs', this.CLASS_PREFIX)}> <div key="date_crumb" className={IDUtil.cssClassName('crumb', this.CLASS_PREFIX)} title="Clear current date range"> <em>Selected date range:&nbsp;</em> {info} &nbsp; <i className="fas fa-times" onClick={onResetDateRange}/> </div> </div> ); }; renderDateTotalStats = (dateCounts, query) => { return ( <div> <span title="Total number of dates found based on selected date field" className={IDUtil.cssClassName('date-count', this.CLASS_PREFIX)}> {ComponentUtil.formatNumber(dateCounts)} </span> &nbsp; <span data-tip data-for={'__qb__tt' + query.id}> <i className="fas fa-info-circle"/> </span> <ReactTooltip id={'__qb__tt' + query.id} getContent={ () => MessageHelper.renderDateTotalStatsTooltip(IDUtil.cssClassName('tooltip'))} /> </div> ); }; renderDateRangeStats = (dateCounts, outOfRangeCount) => { return ( <div className={IDUtil.cssClassName('date-range-stats', this.CLASS_PREFIX)}> <span> Inside range: <span className={IDUtil.cssClassName('date-count', this.CLASS_PREFIX)} title="Number of dates found inside selected date range"> {ComponentUtil.formatNumber(dateCounts - outOfRangeCount)} </span> </span> <span> Outside range: <span className={IDUtil.cssClassName('date-count', this.CLASS_PREFIX)} title="Number of dates found outside selected date range"> {ComponentUtil.formatNumber(outOfRangeCount)} </span> </span> </div> ); }; renderKeywordControls = (currentKeywordAggregation, query, selectedKeywordField, termLimit, dataLimit, showKeywordHistogram, collectionConfig, searchId, totalHits, graphType, onOutput) => { if(searchId == null || totalHits === 0) return null; //results are mandatory const keywordFieldSelector = this.renderKeywordFieldSelector(searchId, query, selectedKeywordField, collectionConfig, onOutput); const keywordTermLimit = this.renderKeywordTermLimit(searchId, termLimit, dataLimit, onOutput); const graph = (showKeywordHistogram && selectedKeywordField) ? this.renderTermHistogram(selectedKeywordField, termLimit, currentKeywordAggregation, graphType, searchId, query, collectionConfig) : null; return ( <div className={IDUtil.cssClassName('result-dates', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('result-dates-header', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('keyword-field', this.CLASS_PREFIX)}> <span data-tip data-for={'__qb__tt-keyword-select'}> <i className="fa fa-info-circle" aria-hidden="true"/> </span> <ReactTooltip id={'__qb__tt-keyword-select'} getContent={ () => MessageHelper.renderKeywordSelectorTooltip(IDUtil.cssClassName('tooltip'))} /> {keywordFieldSelector} {keywordTermLimit} </div> {/* Show chart button */} {selectedKeywordField && <button className="btn" onClick={this.toggleKeywordHistogram}> {showKeywordHistogram ? "Hide chart" : "Show chart"} </button> } </div> { (showKeywordHistogram && selectedKeywordField && <div className={IDUtil.cssClassName('result-dates-content', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('date-graph', this.CLASS_PREFIX)}> {graph} </div> </div>) } </div> ) } renderDateControls = (aggregations, query, collectionConfig, searchId, totalHits, showTimeLine, graphType, onOutput) => { if(searchId == null || (!query.dateRange && totalHits === 0)) return null; //date range & results are mandatory const currentDateAggregation = this.getCurrentDateAggregation(aggregations, query.dateRange); const dateFieldSelector = this.renderDateFieldSelector(searchId, query, aggregations, collectionConfig, onOutput); const dateRangeSelector = this.renderDateRangeSelector(searchId, query, aggregations, collectionConfig, onOutput); let dateRangeCrumb = null; let outOfRangeCount = 0; const dateCounts = this.calcDateCounts(currentDateAggregation); if(query.dateRange && (query.dateRange.start || query.dateRange.end)) { outOfRangeCount = this.calcTotalDatesOutsideOfRange(currentDateAggregation, query.dateRange); dateRangeCrumb = this.renderDateRangeCrumb(query, this.resetDateRange.bind(this)); //TODO pass param } //render date stats const dateTotalStats = dateCounts !== -1 ? this.renderDateTotalStats(dateCounts, query) : null; const dateRangeStats = dateCounts !== -1 ? this.renderDateRangeStats(dateCounts, outOfRangeCount) : null; const graph = showTimeLine ? this.renderTimeLine(currentDateAggregation, graphType, searchId, query, collectionConfig) : null; return ( <div className={IDUtil.cssClassName('result-dates', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('result-dates-header', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('date-field', this.CLASS_PREFIX)}> <span data-tip data-for={'__qb__tt-date-range'}> <i className="fas fa-info-circle" aria-hidden="true"/> </span> <ReactTooltip id={'__qb__tt-date-range'} getContent={ () => MessageHelper.renderDatFieldTooltip(IDUtil.cssClassName('tooltip'))} /> {dateFieldSelector}{dateTotalStats && " ► "} {dateTotalStats} </div> {(query.dateRange && query.dateRange.field) && (<div className={IDUtil.cssClassName('date-range', this.CLASS_PREFIX)}> Date range {dateRangeSelector} {dateRangeStats && " ► "} {dateRangeStats} </div>) } {/* Show chart button */} {(query.dateRange && query.dateRange.field) && <button className="btn" onClick={this.toggleTimeLine}> {showTimeLine ? "Hide chart" : "Show chart"} </button> } </div> {(showTimeLine && query.dateRange && query.dateRange.field) && <div className={IDUtil.cssClassName('result-dates-content', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('date-graph', this.CLASS_PREFIX)}> {graph} </div> {dateRangeCrumb} </div> } </div> ) } renderLockFacetsCheckBox = () => { return ( <div className={IDUtil.cssClassName('lock-facets', this.CLASS_PREFIX)}> <label htmlFor="lock-facets-id">Lock facet selections </label> <input id="lock-facets_id" type="checkbox" onChange={this.onFacetLockChange.bind(this)} /> </div>) } render() { if (this.props.collectionConfig && this.state.query && this.state.isQueryAccessDenied === false) { const layerOptions = this.renderSearchLayerOptions(); //FIXME always returns null (keeping it for other clients to fix later on) //draw the field category selector const fieldCategorySelector = this.renderFieldCategorySelector( this.state.query, this.props.collectionConfig, this.onComponentOutput.bind(this) ); const dateControls = this.props.dateRangeSelector ? this.renderDateControls( this.state.aggregations, this.state.query, this.props.collectionConfig, this.state.searchId, this.state.totalHits, this.state.showTimeLine, this.state.dateGraphType, this.onComponentOutput.bind(this) ) : null; //render the keyword field selector and term aggregation chart //first set the term limit appropriately to the data const currentKeywordAggregation = this.getCurrentKeywordAggregation(this.state.aggregations, this.state.selectedKeywordField) const keywordControls = this.renderKeywordControls( currentKeywordAggregation, this.state.query, this.state.selectedKeywordField, this.state.termLimit, currentKeywordAggregation ? currentKeywordAggregation.length : this.state.termLimit, //data limit this.state.showKeywordHistogram, this.props.collectionConfig, this.state.searchId, this.state.totalHits, this.state.termGraphType, this.onComponentOutput.bind(this)) const queryResultCount = this.state.totalHits === 0 || this.state.totalHits ? this.renderQueryResultHits(this.state.totalHits) : null; const aggregationBox = this.state.aggregations && this.state.searchId && this.state.query && this.props.collectionConfig ? this.renderAggregationList( this.state.searchId, this.state.query, this.state.aggregations, this.props.collectionConfig, this.onComponentOutput.bind(this) ) : null; //render the search term input. This allows the user to enter a search term //If a person-related field category (cluster) is selected (these are specified in the collection config), //then the input will offer auto-complete options from the GTAA if(!this.props.query.entities) { this.props.query.entities = [] } const searchTermInput = this.renderSearchTermInput( this.props.collectionConfig, this.state.query.fieldCategory, this.props.query.entities, this.props.query.term, this.newSearch, //calling the new search function if a term is entered this.onComponentOutput.bind(this) //calling the addEntities function if a suggestion is selected ); //render the checkbox for saving facet values const lockFacetsCheckbox = this.renderLockFacetsCheckBox() const noResultsMessage = this.state.isSearching === false && this.state.searchId != null && this.state.totalHits === 0 ? this.renderNoResultsMessage( this.state.aggregations, this.state.query, this.clearSearch ) : null; //if the search API returned a paging out of bounds error, return a helpful message for the user const pagingOutOfBounds = this.props.isPagingOutOfBounds ? this.renderPagingOutOfBoundsMessage() : null; return ( <div className={IDUtil.cssClassName('query-builder')}> <div className={IDUtil.cssClassName('query-row', this.CLASS_PREFIX)}> {/* Search keywords input */} {searchTermInput} {/* Metadata field selector */} <div className={IDUtil.cssClassName('selector-holder', this.CLASS_PREFIX)}> <div className={IDUtil.cssClassName('in-label', this.CLASS_PREFIX)}>in</div> <div className={IDUtil.cssClassName('tt', this.CLASS_PREFIX)}> <span data-tip data-for={'__fs__tt-category-selector'}> <i className="fas fa-info-circle" aria-hidden="true"/> </span> <ReactTooltip id={'__fs__tt-category-selector'} place='right' getContent={ () => MessageHelper.renderCategorySelectorTooltip(IDUtil.cssClassName('tooltip'))} /> </div> {fieldCategorySelector} </div> </div> {lockFacetsCheckbox} {layerOptions} <div> {dateControls} {keywordControls} <div className="separator"/> {queryResultCount} <div className="row"> <div className="col-md-4"> {aggregationBox} </div> <div className="col-md-8"> {this.props.resultList} {noResultsMessage} {pagingOutOfBounds} </div> </div> </div> </div> ) } else if (this.state.isQueryAccessDenied) { //if the user tries to access a query that is not shared by its owner (OR does not exist) return this.renderQueryAccessDeniedMessage(this.clearSearch) } else { return (<div>Loading collection configuration...</div>); } } } QueryBuilder.propTypes = { header: PropTypes.bool, //whether to show a header with a title aggregationView: PropTypes.string.isRequired, //always set to 'list' (used to support 'box' as well) dateRangeSelector: PropTypes.bool, //whether or not to show a date range selector showTimeLine: PropTypes.bool, //whether or not to show the timeline component showKeywordHistogram: PropTypes.bool, //whether or not to show the keyword histogram component query: PropTypes.object, //the initial query that is run when this component has mounted (optional) collectionConfig: PropTypes.shape({ clientId: PropTypes.string, collectionId: PropTypes.string, collectionMetadata: PropTypes.object, dateFields: PropTypes.array, docType: PropTypes.string, doubleFields: PropTypes.array, keywordFields: PropTypes.array, longFields:PropTypes.array, nestedFields: PropTypes.array, nonAnalyzedFields: PropTypes.array, stringFields: PropTypes.array, textFields: PropTypes.array, user: PropTypes.object }).isRequired, //needed for each search query resultList : PropTypes.object, //markup with the entire result list isPagingOutOfBounds: PropTypes.bool, //whenever the user paged too far (ES limitation) isQueryAccessDenied: PropTypes.bool, //when a user tries to access someone else's query that is not shared pageSize: PropTypes.number, //result list size onOutput: PropTypes.func.isRequired, //calls this function after the search results are received, so the owner can process/visualise them onStartSearch : PropTypes.func //calls this function whenever a search call starts, so the owner can draw a loading graphic, };