import { filter, isNil, pluck } from 'ramda';
import React from 'react';
import PropTypes from 'prop-types';
import Base, { singleColorStyle, hideGroupComponents, multiCallbacks } from './_sub/Base.react';
import Alert from 'react-bootstrap/Alert';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';
import FormControl from 'react-bootstrap/FormControl';
import Select from 'react-select';
import Accordion from 'react-bootstrap/Accordion';
import DateTimePicker from 'react-datetime-picker';
/**
* <div style="width:450px; margin-left: 20px; float: right; margin-top: -150px;">
* <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/filter.png"/>
* <img src="https://raw.githubusercontent.com/VK/dash-express-components/main/.media/filter-modal.png"/>
* </div>
*
*
* The filter component allows filters to be set on individual columns.
*
* These are all combined with and.
*
* There are the options like >, <, = for numerical, as well as isin etc. for categorical columns.
* Possible options are given by the `meta` props.
*
*
* @hideconstructor
*
* @example
* import dash_express_components as dxc
* import plotly.express as px
*
* meta = dxc.get_meta(px.data.gapminder())
*
* dxc.Filter(
* id="filter",
* meta=meta
* )
* @public
*/
class Filter extends Base {
constructor(props) {
super([], props);
this.state =
{
...this.state,
/* state of the modal to add new filters */
showAddModal: false,
/* content of the dropdown to select coolum names*/
selectedColumn: null,
selectedType: null,
/* conent of the dropdown to select a label if a cat column is selected*/
categoryOptions: [],
selectedCategories: [],
categoryOptionsLarge: false,
manualCategories: "",
selectedDateTime: new Date(),
filterType: "",
filterNumber: 0,
filterIndex: undefined,
};
this.update_config(this.state.config, true);
}
handleClose() {
this.setState({ showAddModal: false });
}
handleShow() {
this.setState({ showAddModal: true });
}
update_config(new_config, constructor = false) {
super.update_config(new_config, constructor);
let new_meta = JSON.parse(JSON.stringify(this.props.meta))
//let new_meta = { ...this.props.meta };
if (new_config)
new_config.forEach(el => {
if (el.col in new_meta) {
if (el["type"] === "gt" || el["type"] === "gte") {
new_meta[el.col].min = el.value;
new_meta[el.col].max = Math.max(new_meta[el.col].max, el.value);
new_meta[el.col].median = Math.max(new_meta[el.col].median, el.value);
}
if (el["type"] === "lt" || el["type"] === "lte") {
new_meta[el.col].max = el.value;
new_meta[el.col].min = Math.max(new_meta[el.col].min, el.value);
new_meta[el.col].median = Math.max(new_meta[el.col].median, el.value);
}
if (el["type"] === "eq") {
new_meta[el.col].max = el.value;
new_meta[el.col].min = el.value;
new_meta[el.col].median = el.value;
}
if (el["type"] === "isin") {
el.value.forEach(ael => {
if (!"cat" in new_meta[el.col]) {
new_meta[el.col].cat = [];
}
if (!new_meta[el.col].cat.includes(ael)) {
new_meta[el.col].cat.push(ael);
}
}
);
new_meta[el.col].cat = new_meta[el.col].cat.filter(
ael => el.value.indexOf(ael) !== -1
)
}
if (el["type"] === "isnotin") {
if (!"cat" in new_meta[el.col]) {
new_meta[el.col].cat = [];
}
new_meta[el.col].cat = new_meta[el.col].cat.filter(
ael => el.value.indexOf(ael) === -1
)
}
}
});
super.update_meta_out(new_meta, constructor);
return new_meta;
}
filter_to_string(el) {
let translate = {
isin: "∈",
isnotin: "∉",
gt: ">",
gte: "≥",
lt: "<",
lte: "≤",
eq: "=",
neq: "≠",
istrue: "= True",
isfalse: "= False",
after: "after",
before: "before",
lastn: "last days: ",
}
try {
if (el["type"] === "isin" || el["type"] === "isnotin") {
return <span><b>{el.col}</b> {translate[el["type"]]} [{el.value.join(', ')}]</span>;
}
} catch { }
return <span><b>{el.col}</b> {translate[el["type"]]} {el.value}</span>;
}
get_filter_blocks() {
const { config } = this.state;
const { meta } = this.props;
if (config) {
return <div>
{
config.map((el, id) =>
<Alert dismissible key={id} onClose={() => {
let new_config = config.filter((e, idx) => idx !== id);
this.update_config(new_config)
}}>{this.filter_to_string(el)}
<button className='btn-close btn-edit'
onClick={() => {
let update_state = {
filterIndex: id,
selectedColumn: config[id].col,
selectedType: ("type" in meta[config[id].col]) ? meta[config[id].col].type : "",
filterType: config[id].type,
filterNumber: config[id].value,
}
if (meta[config[id].col].type === "categorical") {
update_state["selectedCategories"] = config[id].value;
if (Array.isArray(config[id].value)) {
update_state["manualCategories"] = config[id].value.join("\n");
} else if (typeof config[id].value === 'string') {
update_state["manualCategories"] = config[id].value;
}
if ("large" in meta[config[id].col] && meta[config[id].col].large) {
update_state["categoryOptionsLarge"] = true;
} else {
update_state["categoryOptionsLarge"] = false;
update_state["categoryOptions"] = [
...meta[config[id].col].cat.map(option => ({
label: String(option),
value: option,
}))];
}
}
this.setState(update_state, () => {
this.handleShow();
})
}}
></button>
</Alert>
)
}
</div>
}
}
get_modal_blocks() {
const {
allColOptions,
allOptions,
showAddModal,
selectedColumn, selectedType,
categoryOptions, selectedCategories, categoryOptionsLarge, manualCategories,
selectedDateTime,
filterType,
filterNumber,
filterIndex,
config } = this.state;
const { meta, id } = this.props;
return (<Modal
centered
backdrop="static"
animation={false}
show={showAddModal}
onHide={() => this.handleClose()}
>
<Modal.Header closeButton>
<Modal.Title>{(filterIndex === undefined) ? "Add" : "Edit"} filter</Modal.Title>
</Modal.Header>
<Modal.Body><div style={{ minHeight: "15em" }} className="mb-3">
Specify the column you want to filter.
<Select
className="mb-3"
key={id + "-selectOptions"}
options={allColOptions}
value={allOptions.filter(o => o.value === selectedColumn)[0]}
onChange={selectedOption => {
let value = selectedOption.value;
let type = meta[value].type;
if (type == "categorical") {
this.setState({
categoryOptions: meta[value].cat.map(option => ({
label: String(option),
value: option,
})),
categoryOptionsLarge: "large" in meta[value] && meta[value].large
})
}
this.setState({
selectedColumn: value,
selectedType: type
});
}}
styles={singleColorStyle}
components={hideGroupComponents}
/>
{
selectedType == "numerical" &&
<div>
Select the numerical filtering you want to apply:
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Type</InputGroup.Text>
<Form.Select
key={id + "-selectType"}
value={filterType}
onChange={e => { this.setState({ filterType: e.target.value }); }}
>
<option value=""></option>
<option value="gt">></option>
<option value="gte">≥</option>
<option value="lt"><</option>
<option value="lte">≤</option>
<option value="eq">=</option>
<option value="neq">≠</option>
</Form.Select>
</InputGroup>
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Value</InputGroup.Text>
<FormControl type="number"
key={id + "-inputValue"}
value={filterNumber} onChange={e => { this.setState({ filterNumber: e.target.value }); }} />
</InputGroup>
</div>
}
{
selectedType == "categorical" &&
<div>
Select which categories you want to filter:
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Type</InputGroup.Text>
<Form.Select
key={id + "-selectCatOption"}
value={filterType}
onChange={e => { this.setState({ filterType: e.target.value }); }}
>
<option value=""></option>
<option value="eq">=</option>
<option value="isin">∈</option>
<option value="isnotin">∉</option>
</Form.Select>
</InputGroup>
{["isnotin", "isin"].includes(filterType) && !categoryOptionsLarge && <Select
options={categoryOptions}
key={id + "-selectOptions"}
{...multiCallbacks(
this,
(s) => this.setState(s),
"selectedCategories",
categoryOptions
)}
/>}
{["eq"].includes(filterType) && !categoryOptionsLarge && <Select
options={categoryOptions}
isClearable
key={id + "-selectEqOptions"}
value={(selectedCategories) ? categoryOptions.filter(o => selectedCategories === o.value) : undefined}
onChange={selectedOption => {
if (selectedOption) {
this.setState({ selectedCategories: selectedOption.value });
} else {
this.setState({ selectedCategories: undefined });
}
}}
/>}
{["isnotin", "isin", "eq"].includes(filterType) && categoryOptionsLarge && <div>
<FormControl as="textarea" rows={3} value={manualCategories} onChange={e => {
let cols = e.target.value.split(/\r?\n/).map(e => e.trimLeft().trimRight());
if (filterType === "eq") {
cols = e.target.value
}
this.setState({
manualCategories: e.target.value,
selectedCategories: cols
});
}} />
</div>
}
</div>
}
{
selectedType == "temporal" &&
<div>
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Type</InputGroup.Text>
<Form.Select
value={filterType}
key={id + "-selectTimeOptions"}
onChange={e => { this.setState({ filterType: e.target.value }); }}
>
<option value=""></option>
<option value="after">after</option>
<option value="before">before</option>
<option value="lastn">last days</option>
</Form.Select>
</InputGroup>
{filterType !== "lastn" &&
<DateTimePicker
className="w-100 border rounded"
value={selectedDateTime}
key={id + "-selectTime"}
onChange={newDateTime => {
this.setState({
selectedDateTime: newDateTime
})
}}
/>}
{filterType === "lastn" &&
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Value</InputGroup.Text>
<FormControl type="number" key={id + "-inputDeltaTime"}
value={filterNumber} onChange={e => { this.setState({ filterNumber: e.target.value }); }} />
</InputGroup>
}
</div>
}
{
selectedType == "bool" &&
<div>
Select which flag you want to keep:
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">Type</InputGroup.Text>
<Form.Select
value={filterType}
key={id + "-selectBool"}
onChange={e => { this.setState({ filterType: e.target.value }); }}
>
<option value=""></option>
<option value="istrue">True</option>
<option value="isfalse">False</option>
</Form.Select>
</InputGroup>
</div>
}
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => this.handleClose()}>
Close
</Button>
<Button variant="primary" onClick={(e) => {
let new_filter = { col: selectedColumn, "type": "test", "value": "test" };
if (selectedType === "categorical") {
new_filter = {
col: selectedColumn,
"type": filterType, "value": selectedCategories
};
if (filterType === "" || selectedCategories.length == 0) {
return;
}
}
if (selectedType === "numerical") {
new_filter = {
col: selectedColumn, "type": filterType, "value": parseFloat(filterNumber)
};
if (filterType === "") {
return;
}
}
if (selectedType === "bool") {
new_filter = {
col: selectedColumn, "type": filterType,
};
if (filterType === "") {
return;
}
}
if (selectedType === "temporal") {
if (filterType !== "lastn") {
new_filter = {
col: selectedColumn, "type": filterType, "value": selectedDateTime.toISOString()
};
} else {
new_filter = {
col: selectedColumn, "type": filterType, "value": parseFloat(filterNumber)
};
}
if (filterType === "") {
return;
}
}
if (filterIndex === undefined) {
let new_config = [
...config,
new_filter
];
this.update_config(new_config);
} else {
let new_config = [
...config
];
new_config[filterIndex] = new_filter;
this.update_config(new_config);
}
this.handleClose();
}}>
{(filterIndex === undefined) ? "Add" : "Update"}
</Button>
</Modal.Footer>
</Modal>)
}
render() {
const { id } = this.props;
return (
<div id={id}>
{this.get_filter_blocks()}
<Button className='w-100' onClick={() => {
this.setState({ filterIndex: undefined });
this.handleShow()
}}>
Add filter
</Button>
{this.get_modal_blocks()}
</div >
)
}
}
Filter.defaultProps = {};
/**
* @typedef
* @public
* @enum {}
*/
Filter.propTypes = {
/**
* The ID used to identify this component in Dash callbacks.
*/
id: PropTypes.string.isRequired,
/**
* The config the user sets in this component.
*/
config: PropTypes.any,
/**
* The metadata this section is based on.
*/
meta: PropTypes.any.isRequired,
/**
* The metadata section will create as output.
*/
meta_out: PropTypes.any,
/**
* Dash-assigned callback that should be called to report property changes
* to Dash, to make them available for callbacks.
*/
setProps: PropTypes.func
};
/**
* @private
*/
export default Filter;