labo-components
Version:
1,067 lines (919 loc) • 41.5 kB
JSX
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: </em>
{info}
<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>
<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,
};