blob: 882931983e4cd1387f2adb7fec4e6b2bed00280e [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Button from 'src/components/Button';
import { t, styled } from '@superset-ui/core';
import buildFilterScopeTreeEntry from '../../util/buildFilterScopeTreeEntry';
import getFilterScopeNodesTree from '../../util/getFilterScopeNodesTree';
import getFilterFieldNodesTree from '../../util/getFilterFieldNodesTree';
import getFilterScopeParentNodes from '../../util/getFilterScopeParentNodes';
import getKeyForFilterScopeTree from '../../util/getKeyForFilterScopeTree';
import getSelectedChartIdForFilterScopeTree from '../../util/getSelectedChartIdForFilterScopeTree';
import getFilterScopeFromNodesTree from '../../util/getFilterScopeFromNodesTree';
import getRevertedFilterScope from '../../util/getRevertedFilterScope';
import FilterScopeTree from './FilterScopeTree';
import FilterFieldTree from './FilterFieldTree';
import { getChartIdsInFilterScope } from '../../util/activeDashboardFilters';
import {
getChartIdAndColumnFromFilterKey,
getDashboardFilterKey,
} from '../../util/getDashboardFilterKey';
import { ALL_FILTERS_ROOT } from '../../util/constants';
import { dashboardFilterPropShape } from '../../util/propShapes';
const propTypes = {
dashboardFilters: PropTypes.objectOf(dashboardFilterPropShape).isRequired,
layout: PropTypes.object.isRequired,
updateDashboardFiltersScope: PropTypes.func.isRequired,
setUnsavedChanges: PropTypes.func.isRequired,
onCloseModal: PropTypes.func.isRequired,
};
const ActionsContainer = styled.div`
height: ${({ theme }) => theme.gridUnit * 16}px;
// TODO: replace hardcoded color with theme variable after refactoring filter-scope-selector.less to Emotion
border-top: ${({ theme }) => theme.gridUnit / 4}px solid #cfd8dc;
padding: ${({ theme }) => theme.gridUnit * 6}px;
margin: 0 0 0 ${({ theme }) => -theme.gridUnit * 6}px;
text-align: right;
.btn {
margin-right: ${({ theme }) => theme.gridUnit * 4}px;
&:last-child {
margin-right: 0;
}
}
`;
export default class FilterScopeSelector extends React.PureComponent {
constructor(props) {
super(props);
const { dashboardFilters, layout } = props;
if (Object.keys(dashboardFilters).length > 0) {
// display filter fields in tree structure
const filterFieldNodes = getFilterFieldNodesTree({
dashboardFilters,
});
// filterFieldNodes root node is dashboard_root component,
// so that we can offer a select/deselect all link
const filtersNodes = filterFieldNodes[0].children;
this.allfilterFields = [];
filtersNodes.forEach(({ children }) => {
children.forEach(child => {
this.allfilterFields.push(child.value);
});
});
this.defaultFilterKey = filtersNodes[0].children[0].value;
// build FilterScopeTree object for each filterKey
const filterScopeMap = Object.values(dashboardFilters).reduce(
(map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(columns).reduce(
(mapByChartId, columnName) => {
const filterKey = getDashboardFilterKey({
chartId: filterId,
column: columnName,
});
const nodes = getFilterScopeNodesTree({
components: layout,
filterFields: [filterKey],
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
// force display filter_box chart as unchecked, but show checkbox as disabled
const chartIdsInFilterScope = (
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter(id => id !== filterId);
return {
...mapByChartId,
[filterKey]: {
// unfiltered nodes
nodes,
// filtered nodes in display if searchText is not empty
nodesFiltered: [...nodes],
checked: chartIdsInFilterScope,
expanded,
},
};
},
{},
);
return {
...map,
...filterScopeByChartId,
};
},
{},
);
// initial state: active defaultFilerKey
const { chartId } = getChartIdAndColumnFromFilterKey(
this.defaultFilterKey,
);
const checkedFilterFields = [];
const activeFilterField = this.defaultFilterKey;
// expand defaultFilterKey in filter field tree
const expandedFilterIds = [ALL_FILTERS_ROOT].concat(chartId);
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField,
filterScopeMap,
layout,
});
this.state = {
showSelector: true,
activeFilterField,
searchText: '',
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
};
} else {
this.state = {
showSelector: false,
};
}
this.filterNodes = this.filterNodes.bind(this);
this.onChangeFilterField = this.onChangeFilterField.bind(this);
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.onCheckFilterField = this.onCheckFilterField.bind(this);
this.onExpandFilterField = this.onExpandFilterField.bind(this);
this.onClose = this.onClose.bind(this);
this.onSave = this.onSave.bind(this);
}
onCheckFilterScope(checked = []) {
const {
activeFilterField,
filterScopeMap,
checkedFilterFields,
} = this.state;
const key = getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
const editingList = activeFilterField
? [activeFilterField]
: checkedFilterFields;
const updatedEntry = {
...filterScopeMap[key],
checked,
};
const updatedFilterScopeMap = getRevertedFilterScope({
checked,
filterFields: editingList,
filterScopeMap,
});
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
...updatedFilterScopeMap,
[key]: updatedEntry,
},
}));
}
onExpandFilterScope(expanded = []) {
const {
activeFilterField,
checkedFilterFields,
filterScopeMap,
} = this.state;
const key = getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
expanded,
};
this.setState(() => ({
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
}));
}
onCheckFilterField(checkedFilterFields = []) {
const { layout } = this.props;
const { filterScopeMap } = this.state;
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: null,
filterScopeMap,
layout,
});
this.setState(() => ({
activeFilterField: null,
checkedFilterFields,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
}));
}
onExpandFilterField(expandedFilterIds = []) {
this.setState(() => ({
expandedFilterIds,
}));
}
onChangeFilterField(filterField = {}) {
const { layout } = this.props;
const nextActiveFilterField = filterField.value;
const {
activeFilterField: currentActiveFilterField,
checkedFilterFields,
filterScopeMap,
} = this.state;
// we allow single edit and multiple edit in the same view.
// if user click on the single filter field,
// will show filter scope for the single field.
// if user click on the same filter filed again,
// will toggle off the single filter field,
// and allow multi-edit all checked filter fields.
if (nextActiveFilterField === currentActiveFilterField) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: null,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: null,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
});
} else if (this.allfilterFields.includes(nextActiveFilterField)) {
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
checkedFilterFields,
activeFilterField: nextActiveFilterField,
filterScopeMap,
layout,
});
this.setState({
activeFilterField: nextActiveFilterField,
filterScopeMap: {
...filterScopeMap,
...filterScopeTreeEntry,
},
});
}
}
onSearchInputChange(e) {
this.setState({ searchText: e.target.value }, this.filterTree);
}
onClose() {
this.props.onCloseModal();
}
onSave() {
const { filterScopeMap } = this.state;
const allFilterFieldScopes = this.allfilterFields.reduce(
(map, filterKey) => {
const { nodes } = filterScopeMap[filterKey];
const checkedChartIds = filterScopeMap[filterKey].checked;
return {
...map,
[filterKey]: getFilterScopeFromNodesTree({
filterKey,
nodes,
checkedChartIds,
}),
};
},
{},
);
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
this.props.setUnsavedChanges(true);
// click Save button will do save and close modal
this.props.onCloseModal();
}
filterTree() {
// Reset nodes back to unfiltered state
if (!this.state.searchText) {
this.setState(prevState => {
const {
activeFilterField,
checkedFilterFields,
filterScopeMap,
} = prevState;
const key = getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered: filterScopeMap[key].nodes,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
};
});
} else {
const updater = prevState => {
const {
activeFilterField,
checkedFilterFields,
filterScopeMap,
} = prevState;
const key = getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
const nodesFiltered = filterScopeMap[key].nodes.reduce(
this.filterNodes,
[],
);
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
const updatedEntry = {
...filterScopeMap[key],
nodesFiltered,
expanded,
};
return {
filterScopeMap: {
...filterScopeMap,
[key]: updatedEntry,
},
};
};
this.setState(updater);
}
}
filterNodes(filtered = [], node = {}) {
const { searchText } = this.state;
const children = (node.children || []).reduce(this.filterNodes, []);
if (
// Node's label matches the search string
node.label.toLocaleLowerCase().indexOf(searchText.toLocaleLowerCase()) >
-1 ||
// Or a children has a matching node
children.length
) {
filtered.push({ ...node, children });
}
return filtered;
}
renderFilterFieldList() {
const {
activeFilterField,
filterFieldNodes,
checkedFilterFields,
expandedFilterIds,
} = this.state;
return (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree() {
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = this.state;
const key = getKeyForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
const selectedChartId = getSelectedChartIdForFilterScopeTree({
activeFilterField,
checkedFilterFields,
});
return (
<>
<input
className="filter-text scope-search multi-edit-mode"
placeholder={t('Search...')}
type="text"
value={searchText}
onChange={this.onSearchInputChange}
/>
<FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded}
onCheck={this.onCheckFilterScope}
onExpand={this.onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself
selectedChartId={selectedChartId}
/>
</>
);
}
renderEditingFiltersName() {
const { dashboardFilters } = this.props;
const { activeFilterField, checkedFilterFields } = this.state;
const currentFilterLabels = []
.concat(activeFilterField || checkedFilterFields)
.map(key => {
const { chartId, column } = getChartIdAndColumnFromFilterKey(key);
return dashboardFilters[chartId].labels[column] || column;
});
return (
<div className="selected-fields multi-edit-mode">
{currentFilterLabels.length === 0 && t('No filter is selected.')}
{currentFilterLabels.length === 1 && t('Editing 1 filter:')}
{currentFilterLabels.length > 1 &&
t('Batch editing %d filters:', currentFilterLabels.length)}
<span className="selected-scopes">
{currentFilterLabels.join(', ')}
</span>
</div>
);
}
render() {
const { showSelector } = this.state;
return (
<div className="filter-scope-container">
<div className="filter-scope-header">
<h4>{t('Configure filter scopes')}</h4>
{showSelector && this.renderEditingFiltersName()}
</div>
<div className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
) : (
<div className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{this.renderFilterFieldList()}
</div>
<div className="filter-scope-pane multi-edit-mode">
{this.renderFilterScopeTree()}
</div>
</div>
)}
</div>
<ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}>
{t('Close')}
</Button>
{showSelector && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</div>
);
}
}
FilterScopeSelector.propTypes = propTypes;