This is an automated email from the ASF dual-hosted git repository. graceguo pushed a commit to branch feature--dashboard-scoped-filter in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/feature--dashboard-scoped-filter by this push: new e36ef96 [WIP][dashboard scoped filter] part 1: scope selector modal (#8557) e36ef96 is described below commit e36ef965074d7cf8e92e9559ba42d70bc40178e2 Author: Grace Guo <grace....@airbnb.com> AuthorDate: Wed Nov 13 13:49:32 2019 -0800 [WIP][dashboard scoped filter] part 1: scope selector modal (#8557) * filter scope selector modal * add single-field-edit in multi-edit mode switch * fix code review comments (round 1) * refactory after design review * fix a few props initial value --- superset/assets/package-lock.json | 11 + superset/assets/package.json | 1 + .../dashboard/fixtures/mockDashboardFilters.js | 5 +- .../dashboard/fixtures/mockDashboardLayout.js | 2 +- .../dashboard/reducers/dashboardFilters_spec.js | 18 +- .../index.less => components/ChartIcon.jsx} | 24 +- superset/assets/src/components/CheckboxIcons.jsx | 40 ++ superset/assets/src/components/ModalTrigger.jsx | 2 + .../dashboard/components/DeleteComponentModal.jsx | 4 +- .../components/FilterIndicatorsContainer.jsx | 11 +- .../assets/src/dashboard/components/Header.jsx | 9 +- .../components/filterscope/FilterFieldItem.jsx} | 48 +- .../components/filterscope/FilterFieldTree.jsx | 94 ++++ .../components/filterscope/FilterScopeModal.jsx | 59 +++ .../components/filterscope/FilterScopeSelector.jsx | 518 +++++++++++++++++++++ .../components/filterscope/FilterScopeTree.jsx | 94 ++++ .../filterscope/renderFilterFieldTreeNodes.jsx} | 57 +-- .../filterscope/renderFilterScopeTreeNodes.jsx | 74 +++ .../dashboard/containers/FilterScope.jsx} | 51 +- .../src/dashboard/reducers/dashboardFilters.js | 22 +- .../src/dashboard/reducers/getInitialState.js | 5 +- .../src/dashboard/stylesheets/dashboard.less | 17 +- .../stylesheets/filter-scope-selector.less | 241 ++++++++++ .../assets/src/dashboard/stylesheets/index.less | 1 + .../src/dashboard/util/activeDashboardFilters.js | 17 + .../dashboard/util/buildFilterScopeTreeEntry.js | 65 +++ superset/assets/src/dashboard/util/constants.js | 3 + .../src/dashboard/util/dashboardFiltersColorMap.js | 8 +- .../src/dashboard/util/getCurrentScopeChartIds.js | 62 +++ .../index.less => util/getDashboardFilterKey.js} | 21 +- .../dashboard/util/getFilterFieldNodesTree.js} | 50 +- .../src/dashboard/util/getFilterScopeNodesTree.js | 128 +++++ .../getFilterScopeParentNodes.js} | 33 +- .../getKeyForFilterScopeTree.js} | 22 +- .../index.less => util/getRevertedFilterScope.js} | 36 +- .../util/getSelectedChartIdForFilterScopeTree.js | 53 +++ superset/assets/src/dashboard/util/propShapes.jsx | 29 +- .../src/visualizations/FilterBox/FilterBox.css | 2 +- .../src/visualizations/FilterBox/FilterBox.jsx | 5 +- 39 files changed, 1742 insertions(+), 200 deletions(-) diff --git a/superset/assets/package-lock.json b/superset/assets/package-lock.json index 743952c..5e8aac3 100644 --- a/superset/assets/package-lock.json +++ b/superset/assets/package-lock.json @@ -20244,6 +20244,17 @@ } } }, + "react-checkbox-tree": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/react-checkbox-tree/-/react-checkbox-tree-1.5.1.tgz", + "integrity": "sha512-fBLMVpd7/YXavzIBz+3OMS5eo2oZLW9PlTY4M1zrJ3TdZRzgILicSzRj6V5VKKm80y8uQXn60skn98pwn3i3Ig==", + "requires": { + "classnames": "^2.2.5", + "lodash": "^4.17.10", + "nanoid": "^2.0.0", + "prop-types": "^15.5.8" + } + }, "react-color": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.14.1.tgz", diff --git a/superset/assets/package.json b/superset/assets/package.json index c286ee7..42b56b5 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -117,6 +117,7 @@ "react-bootstrap": "^0.31.5", "react-bootstrap-dialog": "^0.10.0", "react-bootstrap-slider": "2.1.5", + "react-checkbox-tree": "^1.5.1", "react-color": "^2.13.8", "react-datetime": "^2.14.0", "react-dnd": "^2.5.4", diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js index e72e13e..7f13db7 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js @@ -17,6 +17,7 @@ * under the License. */ import { filterId } from './mockSliceEntities'; +import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../../../../src/dashboard/reducers/dashboardFilters'; export const emptyFilters = {}; @@ -33,7 +34,9 @@ export const dashboardFilters = { 'ROW-l6PrlhwSjh', 'CHART-rwDfbGqeEn', ], - scope: 'ROOT_ID', + scopes: { + region: DASHBOARD_FILTER_SCOPE_GLOBAL, + }, isDateFilter: false, isInstantFilter: true, columns: { diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js index aad4445..3267c5c 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js @@ -204,6 +204,6 @@ export const filterComponent = { chartId: filterId, width: 3, height: 10, - chartName: 'Filter', + sliceName: 'Filter', }, }; diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js index 88c6714..e234248 100644 --- a/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js +++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardFilters_spec.js @@ -22,7 +22,9 @@ import { REMOVE_FILTER, CHANGE_FILTER, } from '../../../../src/dashboard/actions/dashboardFilters'; -import dashboardFiltersReducer from '../../../../src/dashboard/reducers/dashboardFilters'; +import dashboardFiltersReducer, { + DASHBOARD_FILTER_SCOPE_GLOBAL, +} from '../../../../src/dashboard/reducers/dashboardFilters'; import { emptyFilters, dashboardFilters, @@ -33,7 +35,6 @@ import { column, } from '../fixtures/mockSliceEntities'; import { filterComponent } from '../fixtures/mockDashboardLayout'; -import { DASHBOARD_ROOT_ID } from '../../../../src/dashboard/util/constants'; describe('dashboardFilters reducer', () => { const form_data = sliceEntitiesForDashboard.slices[filterId].form_data; @@ -54,7 +55,7 @@ describe('dashboardFilters reducer', () => { chartId: filterId, componentId: component.id, directPathToFilter, - scope: DASHBOARD_ROOT_ID, + filterName: component.meta.sliceName, isDateFilter: false, isInstantFilter: !!form_data.instant_filtering, columns: { @@ -63,6 +64,9 @@ describe('dashboardFilters reducer', () => { labels: { [column]: column, }, + scopes: { + [column]: DASHBOARD_FILTER_SCOPE_GLOBAL, + }, }, }); }); @@ -83,7 +87,6 @@ describe('dashboardFilters reducer', () => { chartId: filterId, componentId: component.id, directPathToFilter, - scope: DASHBOARD_ROOT_ID, isDateFilter: false, isInstantFilter: !!form_data.instant_filtering, columns: { @@ -93,6 +96,9 @@ describe('dashboardFilters reducer', () => { labels: { [column]: column, }, + scopes: { + [column]: DASHBOARD_FILTER_SCOPE_GLOBAL, + }, }, }); }); @@ -113,7 +119,6 @@ describe('dashboardFilters reducer', () => { chartId: filterId, componentId: component.id, directPathToFilter, - scope: DASHBOARD_ROOT_ID, isDateFilter: false, isInstantFilter: !!form_data.instant_filtering, columns: { @@ -123,6 +128,9 @@ describe('dashboardFilters reducer', () => { labels: { [column]: column, }, + scopes: { + [column]: DASHBOARD_FILTER_SCOPE_GLOBAL, + }, }, }); }); diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/components/ChartIcon.jsx similarity index 62% copy from superset/assets/src/dashboard/stylesheets/index.less copy to superset/assets/src/components/ChartIcon.jsx index 01a0e3c..d25e453 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/components/ChartIcon.jsx @@ -16,17 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -@import './variables.less'; +import React from 'react'; -@import './builder.less'; -@import './builder-sidepane.less'; -@import './buttons.less'; -@import './dashboard.less'; -@import './dnd.less'; -@import './filter-indicator.less'; -@import './filter-indicator-tooltip.less'; -@import './grid.less'; -@import './hover-menu.less'; -@import './popover-menu.less'; -@import './resizable.less'; -@import './components/index.less'; +const ChartIcon = () => ( + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect x="0.5" y="0.5" width="17" height="17" rx="2.5" fill="#EFF9F9" stroke="#B3DADC" /> + <rect x="8" y="4" width="2" height="10" rx="1" fill="#B3DADC" /> + <rect x="12" y="10" width="2" height="4" rx="1" fill="#B3DADC" /> + <rect x="4" y="6" width="2" height="8" rx="1" fill="#B3DADC" /> + </svg> +); + +export default ChartIcon; diff --git a/superset/assets/src/components/CheckboxIcons.jsx b/superset/assets/src/components/CheckboxIcons.jsx new file mode 100644 index 0000000..f26e752 --- /dev/null +++ b/superset/assets/src/components/CheckboxIcons.jsx @@ -0,0 +1,40 @@ +/** + * 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'; + +export const CheckboxChecked = () => ( + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M16 0H2C0.89 0 0 0.9 0 2V16C0 17.1 0.89 18 2 18H16C17.11 18 18 17.1 18 16V2C18 0.9 17.11 0 16 0Z" fill="#00A699" /> + <path d="M7 14L2 9L3.41 7.59L7 11.17L14.59 3.58L16 5L7 14Z" fill="white" /> + </svg> +); + +export const CheckboxHalfChecked = () => ( + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z" fill="#999999" /> + <path d="M14 10H4V8H14V10Z" fill="white" /> + </svg> +); + +export const CheckboxUnchecked = () => ( + <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z" fill="#CCCCCC" /> + <path d="M16 2V16H2V2H16V2Z" fill="white" /> + </svg> +); diff --git a/superset/assets/src/components/ModalTrigger.jsx b/superset/assets/src/components/ModalTrigger.jsx index 750be52..8f363f5 100644 --- a/superset/assets/src/components/ModalTrigger.jsx +++ b/superset/assets/src/components/ModalTrigger.jsx @@ -24,6 +24,7 @@ import cx from 'classnames'; import Button from './Button'; const propTypes = { + dialogClassName: PropTypes.string, animation: PropTypes.bool, triggerNode: PropTypes.node.isRequired, modalTitle: PropTypes.node, @@ -72,6 +73,7 @@ export default class ModalTrigger extends React.Component { renderModal() { return ( <Modal + dialogClassName={this.props.dialogClassName} animation={this.props.animation} show={this.state.showModal} onHide={this.close} diff --git a/superset/assets/src/dashboard/components/DeleteComponentModal.jsx b/superset/assets/src/dashboard/components/DeleteComponentModal.jsx index 366e78a..1d50017 100644 --- a/superset/assets/src/dashboard/components/DeleteComponentModal.jsx +++ b/superset/assets/src/dashboard/components/DeleteComponentModal.jsx @@ -57,14 +57,14 @@ export default class DeleteComponentModal extends React.PureComponent { ref={this.setModalRef} triggerNode={this.props.triggerNode} modalBody={ - <div className="delete-component-modal"> + <div className="dashboard-modal delete"> <h1>{t('Delete dashboard tab?')}</h1> <div> Deleting a tab will remove all content within it. You may still reverse this action with the <b>undo</b> button (cmd + z) until you save your changes. </div> - <div className="delete-modal-actions-container"> + <div className="dashboard-modal-actions-container"> <Button onClick={this.close}>{t('Cancel')}</Button> <Button bsStyle="primary" onClick={this.deleteTab}> {t('Delete')} diff --git a/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx b/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx index f84e32d..f287108 100644 --- a/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx +++ b/superset/assets/src/dashboard/components/FilterIndicatorsContainer.jsx @@ -23,10 +23,8 @@ import { isEmpty } from 'lodash'; import FilterIndicator from './FilterIndicator'; import FilterIndicatorGroup from './FilterIndicatorGroup'; import { FILTER_INDICATORS_DISPLAY_LENGTH } from '../util/constants'; -import { - getFilterColorKey, - getFilterColorMap, -} from '../util/dashboardFiltersColorMap'; +import { getDashboardFilterKey } from '../util/getDashboardFilterKey'; +import { getFilterColorMap } from '../util/dashboardFiltersColorMap'; const propTypes = { // from props @@ -92,7 +90,10 @@ export default class FilterIndicatorsContainer extends React.PureComponent { !filterImmuneSlices.includes(currentChartId) ) { Object.keys(columns).forEach(name => { - const colorMapKey = getFilterColorKey(chartId, name); + const colorMapKey = getDashboardFilterKey({ + chartId, + column: name, + }); const directPathToLabel = directPathToFilter.slice(); directPathToLabel.push(`LABEL-${name}`); const indicator = { diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx index b807273..569dac9 100644 --- a/superset/assets/src/dashboard/components/Header.jsx +++ b/superset/assets/src/dashboard/components/Header.jsx @@ -26,6 +26,7 @@ import HeaderActionsDropdown from './HeaderActionsDropdown'; import EditableTitle from '../../components/EditableTitle'; import Button from '../../components/Button'; import FaveStar from '../../components/FaveStar'; +import FilterScopeModal from './filterscope/FilterScopeModal'; import PublishedStatus from './PublishedStatus'; import UndoRedoKeylisteners from './UndoRedoKeylisteners'; @@ -347,7 +348,7 @@ class Header extends React.PureComponent { bsSize="small" onClick={this.onInsertComponentsButtonClick} > - {t('Insert components')} + {t('Components')} </Button> )} @@ -361,6 +362,12 @@ class Header extends React.PureComponent { </Button> )} + {editMode && ( + <FilterScopeModal + triggerNode={<Button bsSize="small">{t('Filters')}</Button>} + /> + )} + {editMode && hasUnsavedChanges && ( <Button bsSize="small" diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js b/superset/assets/src/dashboard/components/filterscope/FilterFieldItem.jsx similarity index 55% copy from superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js copy to superset/assets/src/dashboard/components/filterscope/FilterFieldItem.jsx index e72e13e..fb3689d 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js +++ b/superset/assets/src/dashboard/components/filterscope/FilterFieldItem.jsx @@ -16,31 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { filterId } from './mockSliceEntities'; +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; -export const emptyFilters = {}; +import FilterBadgeIcon from '../../../components/FilterBadgeIcon'; -export const dashboardFilters = { - [filterId]: { - chartId: filterId, - componentId: 'CHART-rwDfbGqeEn', - directPathToFilter: [ - 'ROOT_ID', - 'TABS-VPEX_c476g', - 'TAB-PMJyKM1yB', - 'TABS-YdylzDMTMQ', - 'TAB-O9AaU9FT0', - 'ROW-l6PrlhwSjh', - 'CHART-rwDfbGqeEn', - ], - scope: 'ROOT_ID', - isDateFilter: false, - isInstantFilter: true, - columns: { - region: ['a', 'b'], - }, - labels: { - region: 'region', - }, - }, +const propTypes = { + label: PropTypes.string.isRequired, + colorCode: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, }; + +export default function FilterFieldItem({ label, colorCode, isSelected }) { + return ( + <a + className={cx('filter-field-item filter-container', { + 'is-selected': isSelected, + })} + > + <FilterBadgeIcon colorCode={colorCode} /> + <label htmlFor={label}>{label}</label> + </a> + ); +} + +FilterFieldItem.propTypes = propTypes; diff --git a/superset/assets/src/dashboard/components/filterscope/FilterFieldTree.jsx b/superset/assets/src/dashboard/components/filterscope/FilterFieldTree.jsx new file mode 100644 index 0000000..7b778ed --- /dev/null +++ b/superset/assets/src/dashboard/components/filterscope/FilterFieldTree.jsx @@ -0,0 +1,94 @@ +/** + * 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 CheckboxTree from 'react-checkbox-tree'; +import { t } from '@superset-ui/translation'; + +import 'react-checkbox-tree/lib/react-checkbox-tree.css'; +import { + CheckboxChecked, + CheckboxUnchecked, + CheckboxHalfChecked, +} from '../../../components/CheckboxIcons'; +import renderFilterFieldTreeNodes from './renderFilterFieldTreeNodes'; +import { filterScopeSelectorTreeNodePropShape } from '../../util/propShapes'; + +const propTypes = { + activeKey: PropTypes.oneOfType([null, PropTypes.string]), + nodes: PropTypes.arrayOf(filterScopeSelectorTreeNodePropShape).isRequired, + checked: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + ).isRequired, + expanded: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + ).isRequired, + onCheck: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, +}; + +const defaultProps = { + activeKey: null, +}; + +const FILTER_FIELD_CHECKBOX_TREE_ICONS = { + check: <CheckboxChecked />, + uncheck: <CheckboxUnchecked />, + halfCheck: <CheckboxHalfChecked />, + expandClose: <span className="rct-icon rct-icon-expand-close" />, + expandOpen: <span className="rct-icon rct-icon-expand-open" />, + expandAll: ( + <span className="rct-icon rct-icon-expand-all">{t('Expand all')}</span> + ), + collapseAll: ( + <span className="rct-icon rct-icon-collapse-all">{t('Collapse all')}</span> + ), + parentClose: <span className="rct-icon rct-icon-parent-close" />, + parentOpen: <span className="rct-icon rct-icon-parent-open" />, + leaf: <span className="rct-icon rct-icon-leaf" />, +}; + +export default function FilterFieldTree({ + activeKey, + nodes = [], + checked = [], + expanded = [], + onClick, + onCheck, + onExpand, +}) { + return ( + <CheckboxTree + showExpandAll + showNodeIcon={false} + expandOnClick + nodes={renderFilterFieldTreeNodes({ nodes, activeKey })} + checked={checked} + expanded={expanded} + onClick={onClick} + onCheck={onCheck} + onExpand={onExpand} + icons={FILTER_FIELD_CHECKBOX_TREE_ICONS} + /> + ); +} + +FilterFieldTree.propTypes = propTypes; +FilterFieldTree.defaultProps = defaultProps; diff --git a/superset/assets/src/dashboard/components/filterscope/FilterScopeModal.jsx b/superset/assets/src/dashboard/components/filterscope/FilterScopeModal.jsx new file mode 100644 index 0000000..b2a8be8 --- /dev/null +++ b/superset/assets/src/dashboard/components/filterscope/FilterScopeModal.jsx @@ -0,0 +1,59 @@ +/** + * 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 ModalTrigger from '../../../components/ModalTrigger'; +import FilterScope from '../../containers/FilterScope'; + +const propTypes = { + triggerNode: PropTypes.node.isRequired, +}; + +export default class FilterScopeModal extends React.PureComponent { + constructor(props) { + super(props); + + this.modal = React.createRef(); + this.handleCloseModal = this.handleCloseModal.bind(this); + } + + handleCloseModal() { + if (this.modal) { + this.modal.current.close(); + } + } + + render() { + return ( + <ModalTrigger + dialogClassName="filter-scope-modal" + ref={this.modal} + triggerNode={this.props.triggerNode} + modalBody={ + <div className="dashboard-modal filter-scope"> + <FilterScope onCloseModal={this.handleCloseModal} /> + </div> + } + /> + ); + } +} + +FilterScopeModal.propTypes = propTypes; diff --git a/superset/assets/src/dashboard/components/filterscope/FilterScopeSelector.jsx b/superset/assets/src/dashboard/components/filterscope/FilterScopeSelector.jsx new file mode 100644 index 0000000..ae42a57 --- /dev/null +++ b/superset/assets/src/dashboard/components/filterscope/FilterScopeSelector.jsx @@ -0,0 +1,518 @@ +/** + * 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 'react-bootstrap'; +import { t } from '@superset-ui/translation'; + +import buildFilterScopeTreeEntry from '../../util/buildFilterScopeTreeEntry'; +import getFilterScopeNodesTree from '../../util/getFilterScopeNodesTree'; +import getFilterFieldNodesTree from '../../util/getFilterFieldNodesTree'; +import getFilterScopeParentNodes from '../../util/getFilterScopeParentNodes'; +import getCurrentScopeChartIds from '../../util/getCurrentScopeChartIds'; +import getKeyForFilterScopeTree from '../../util/getKeyForFilterScopeTree'; +import getSelectedChartIdForFilterScopeTree from '../../util/getSelectedChartIdForFilterScopeTree'; +import getRevertedFilterScope from '../../util/getRevertedFilterScope'; +import FilterScopeTree from './FilterScopeTree'; +import FilterFieldTree from './FilterFieldTree'; +import { + getChartIdAndColumnFromFilterKey, + getDashboardFilterKey, +} from '../../util/getDashboardFilterKey'; +import { ALL_FILTERS_ROOT } from '../../util/constants'; + +const propTypes = { + dashboardFilters: PropTypes.object.isRequired, + layout: PropTypes.object.isRequired, + filterImmuneSlices: PropTypes.arrayOf(PropTypes.number).isRequired, + filterImmuneSliceFields: PropTypes.object.isRequired, + setDirectPathToChild: PropTypes.func.isRequired, + onCloseModal: PropTypes.func.isRequired, +}; + +export default class FilterScopeSelector extends React.PureComponent { + constructor(props) { + super(props); + + const { + dashboardFilters, + filterImmuneSlices, + filterImmuneSliceFields, + 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 checked = getCurrentScopeChartIds({ + scopeComponentIds: ['ROOT_ID'], // dashboardFilters[chartId].scopes[columnName], + filterField: columnName, + filterImmuneSlices, + filterImmuneSliceFields, + components: layout, + }); + const expanded = getFilterScopeParentNodes(nodes, 1); + return { + ...mapByChartId, + [filterKey]: { + // unfiltered nodes + nodes, + // filtered nodes in display if searchText is not empty + nodesFiltered: [...nodes], + checked, + 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; + + console.log( + 'i am current state', + this.allfilterFields.reduce( + (map, key) => ({ + ...map, + [key]: filterScopeMap[key].checked, + }), + {}, + ), + ); + + // save does not close modal + } + + 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 ( + <React.Fragment> + <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} + /> + </React.Fragment> + ); + } + + 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 ( + <React.Fragment> + <div className="filter-scope-container"> + <div className="filter-scope-header"> + <h4>{t('Configure filter scopes')}</h4> + {this.renderEditingFiltersName()} + </div> + + {!showSelector ? ( + <div>{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> + <div className="dashboard-modal-actions-container"> + <Button onClick={this.onClose}>{t('Cancel')}</Button> + {showSelector && ( + <Button bsStyle="primary" onClick={this.onSave}> + {t('Save')} + </Button> + )} + </div> + </React.Fragment> + ); + } +} + +FilterScopeSelector.propTypes = propTypes; diff --git a/superset/assets/src/dashboard/components/filterscope/FilterScopeTree.jsx b/superset/assets/src/dashboard/components/filterscope/FilterScopeTree.jsx new file mode 100644 index 0000000..e433f15 --- /dev/null +++ b/superset/assets/src/dashboard/components/filterscope/FilterScopeTree.jsx @@ -0,0 +1,94 @@ +/** + * 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 CheckboxTree from 'react-checkbox-tree'; +import 'react-checkbox-tree/lib/react-checkbox-tree.css'; +import { t } from '@superset-ui/translation'; + +import { + CheckboxChecked, + CheckboxUnchecked, + CheckboxHalfChecked, +} from '../../../components/CheckboxIcons'; +import renderFilterScopeTreeNodes from './renderFilterScopeTreeNodes'; +import { filterScopeSelectorTreeNodePropShape } from '../../util/propShapes'; + +const propTypes = { + nodes: PropTypes.arrayOf(filterScopeSelectorTreeNodePropShape).isRequired, + checked: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + ).isRequired, + expanded: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + ).isRequired, + onCheck: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + selectedChartId: PropTypes.oneOfType([null, PropTypes.number]), +}; + +const defaultProps = { + selectedChartId: null, +}; + +const NOOP = () => {}; + +const FILTER_SCOPE_CHECKBOX_TREE_ICONS = { + check: <CheckboxChecked />, + uncheck: <CheckboxUnchecked />, + halfCheck: <CheckboxHalfChecked />, + expandClose: <span className="rct-icon rct-icon-expand-close" />, + expandOpen: <span className="rct-icon rct-icon-expand-open" />, + expandAll: ( + <span className="rct-icon rct-icon-expand-all">{t('Expand all')}</span> + ), + collapseAll: ( + <span className="rct-icon rct-icon-collapse-all">{t('Collapse all')}</span> + ), + parentClose: <span className="rct-icon rct-icon-parent-close" />, + parentOpen: <span className="rct-icon rct-icon-parent-open" />, + leaf: <span className="rct-icon rct-icon-leaf" />, +}; + +export default function FilterScopeTree({ + nodes = [], + checked = [], + expanded = [], + onCheck, + onExpand, + selectedChartId, +}) { + return ( + <CheckboxTree + showExpandAll + expandOnClick + showNodeIcon={false} + nodes={renderFilterScopeTreeNodes({ nodes, selectedChartId })} + checked={checked} + expanded={expanded} + onCheck={onCheck} + onExpand={onExpand} + onClick={NOOP} + icons={FILTER_SCOPE_CHECKBOX_TREE_ICONS} + /> + ); +} + +FilterScopeTree.propTypes = propTypes; +FilterScopeTree.defaultProps = defaultProps; diff --git a/superset/assets/src/dashboard/util/activeDashboardFilters.js b/superset/assets/src/dashboard/components/filterscope/renderFilterFieldTreeNodes.jsx similarity index 50% copy from superset/assets/src/dashboard/util/activeDashboardFilters.js copy to superset/assets/src/dashboard/components/filterscope/renderFilterFieldTreeNodes.jsx index 8c70577..5ffd49e 100644 --- a/superset/assets/src/dashboard/util/activeDashboardFilters.js +++ b/superset/assets/src/dashboard/components/filterscope/renderFilterFieldTreeNodes.jsx @@ -16,35 +16,40 @@ * specific language governing permissions and limitations * under the License. */ -let activeFilters = {}; +import React from 'react'; -export function getActiveFilters() { - return activeFilters; -} +import FilterFieldItem from './FilterFieldItem'; +import { getFilterColorMap } from '../../util/dashboardFiltersColorMap'; -// non-empty filters from dashboardFilters, -// this function does not take into account: filter immune or filter scope settings -export function buildActiveFilters(allDashboardFilters = {}) { - activeFilters = Object.values(allDashboardFilters).reduce( - (result, filter) => { - const { chartId, columns } = filter; +export default function renderFilterFieldTreeNodes({ nodes, activeKey }) { + if (!nodes) { + return []; + } - Object.keys(columns).forEach(key => { - if ( - Array.isArray(columns[key]) - ? columns[key].length - : columns[key] !== undefined - ) { - /* eslint-disable no-param-reassign */ - result[chartId] = { - ...result[chartId], - [key]: columns[key], - }; - } - }); + const root = nodes[0]; + const allFilterNodes = root.children; + const children = allFilterNodes.map(node => ({ + ...node, + children: node.children.map(child => { + const { label, value } = child; + const colorCode = getFilterColorMap()[value]; + return { + ...child, + label: ( + <FilterFieldItem + isSelected={value === activeKey} + label={label} + colorCode={colorCode} + /> + ), + }; + }), + })); - return result; + return [ + { + ...root, + children, }, - {}, - ); + ]; } diff --git a/superset/assets/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx b/superset/assets/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx new file mode 100644 index 0000000..f4a85cf --- /dev/null +++ b/superset/assets/src/dashboard/components/filterscope/renderFilterScopeTreeNodes.jsx @@ -0,0 +1,74 @@ +/** + * 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 cx from 'classnames'; + +import ChartIcon from '../../../components/ChartIcon'; +import { CHART_TYPE } from '../../util/componentTypes'; + +function traverse({ currentNode = {}, selectedChartId }) { + if (!currentNode) { + return null; + } + + const { label, value, type, children } = currentNode; + if (children && children.length) { + const updatedChildren = children.map(child => + traverse({ currentNode: child, selectedChartId }), + ); + return { + ...currentNode, + label: ( + <a + className={cx(`filter-scope-type ${type.toLowerCase()}`, { + 'selected-filter': selectedChartId === value, + })} + > + {type === CHART_TYPE && ( + <span className="type-indicator"> + <ChartIcon /> + </span> + )} + {label} + </a> + ), + children: updatedChildren, + }; + } + return { + ...currentNode, + label: ( + <a + className={cx(`filter-scope-type ${type.toLowerCase()}`, { + 'selected-filter': selectedChartId === value, + })} + > + {label} + </a> + ), + }; +} + +export default function renderFilterScopeTreeNodes({ nodes, selectedChartId }) { + if (!nodes) { + return []; + } + + return nodes.map(node => traverse({ currentNode: node, selectedChartId })); +} diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js b/superset/assets/src/dashboard/containers/FilterScope.jsx similarity index 51% copy from superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js copy to superset/assets/src/dashboard/containers/FilterScope.jsx index e72e13e..f88d665 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js +++ b/superset/assets/src/dashboard/containers/FilterScope.jsx @@ -16,31 +16,32 @@ * specific language governing permissions and limitations * under the License. */ -import { filterId } from './mockSliceEntities'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; -export const emptyFilters = {}; +import { setDirectPathToChild } from '../actions/dashboardState'; +import FilterScopeSelector from '../components/filterscope/FilterScopeSelector'; -export const dashboardFilters = { - [filterId]: { - chartId: filterId, - componentId: 'CHART-rwDfbGqeEn', - directPathToFilter: [ - 'ROOT_ID', - 'TABS-VPEX_c476g', - 'TAB-PMJyKM1yB', - 'TABS-YdylzDMTMQ', - 'TAB-O9AaU9FT0', - 'ROW-l6PrlhwSjh', - 'CHART-rwDfbGqeEn', - ], - scope: 'ROOT_ID', - isDateFilter: false, - isInstantFilter: true, - columns: { - region: ['a', 'b'], - }, - labels: { - region: 'region', +function mapStateToProps({ dashboardLayout, dashboardFilters, dashboardInfo }) { + return { + dashboardFilters, + filterImmuneSlices: dashboardInfo.metadata.filterImmuneSlices || [], + filterImmuneSliceFields: + dashboardInfo.metadata.filterImmuneSliceFields || {}, + layout: dashboardLayout.present, + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators( + { + setDirectPathToChild, }, - }, -}; + dispatch, + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(FilterScopeSelector); diff --git a/superset/assets/src/dashboard/reducers/dashboardFilters.js b/superset/assets/src/dashboard/reducers/dashboardFilters.js index 7cd7c89..4c1f14d 100644 --- a/superset/assets/src/dashboard/reducers/dashboardFilters.js +++ b/superset/assets/src/dashboard/reducers/dashboardFilters.js @@ -17,7 +17,6 @@ * under the License. */ /* eslint-disable camelcase */ -import { DASHBOARD_ROOT_ID } from '../util/constants'; import { ADD_FILTER, REMOVE_FILTER, @@ -28,15 +27,23 @@ import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox'; import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata'; import { buildFilterColorMap } from '../util/dashboardFiltersColorMap'; import { buildActiveFilters } from '../util/activeDashboardFilters'; +import { DASHBOARD_ROOT_ID } from '../util/constants'; + +export const DASHBOARD_FILTER_SCOPE_GLOBAL = { + scope: [DASHBOARD_ROOT_ID], + immune: [], +}; export const dashboardFilter = { chartId: 0, - componentId: '', + componentId: null, + filterName: null, directPathToFilter: [], - scope: DASHBOARD_ROOT_ID, isDateFilter: false, isInstantFilter: true, columns: {}, + labels: {}, + scopes: {}, }; export default function dashboardFiltersReducer(dashboardFilters = {}, action) { @@ -44,6 +51,13 @@ export default function dashboardFiltersReducer(dashboardFilters = {}, action) { [ADD_FILTER]() { const { chartId, component, form_data } = action; const { columns, labels } = getFilterConfigsFromFormdata(form_data); + const scopes = Object.keys(columns).reduce( + (map, column) => ({ + ...map, + [column]: DASHBOARD_FILTER_SCOPE_GLOBAL, + }), + {}, + ); const directPathToFilter = component ? (component.parents || []).slice().concat(component.id) : []; @@ -52,9 +66,11 @@ export default function dashboardFiltersReducer(dashboardFilters = {}, action) { ...dashboardFilter, chartId, componentId: component.id, + filterName: component.meta.sliceName, directPathToFilter, columns, labels, + scopes, isInstantFilter: !!form_data.instant_filtering, isDateFilter: Object.keys(columns).includes(TIME_RANGE), }; diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js index 85a62e3..185a8b0 100644 --- a/superset/assets/src/dashboard/reducers/getInitialState.js +++ b/superset/assets/src/dashboard/reducers/getInitialState.js @@ -180,6 +180,7 @@ export default function(bootstrapData) { ...dashboardFilter, chartId: key, componentId, + filterName: slice.slice_name, directPathToFilter, columns, labels, @@ -187,8 +188,6 @@ export default function(bootstrapData) { isDateFilter: Object.keys(columns).includes(TIME_RANGE), }; } - buildActiveFilters(dashboardFilters); - buildFilterColorMap(dashboardFilters); } // sync layout names with current slice names in case a slice was edited @@ -199,6 +198,8 @@ export default function(bootstrapData) { layout[layoutId].meta.sliceName = slice.slice_name; } }); + buildActiveFilters(dashboardFilters); + buildFilterColorMap(dashboardFilters); // store the header as a layout component so we can undo/redo changes layout[DASHBOARD_HEADER_ID] = { diff --git a/superset/assets/src/dashboard/stylesheets/dashboard.less b/superset/assets/src/dashboard/stylesheets/dashboard.less index c37d5e5..cdf11f7 100644 --- a/superset/assets/src/dashboard/stylesheets/dashboard.less +++ b/superset/assets/src/dashboard/stylesheets/dashboard.less @@ -165,19 +165,26 @@ body { padding: 24px 24px 29px 24px; } - .delete-modal-actions-container { + .modal-dialog.filter-scope-modal { + width: 80%; + } + + .dashboard-modal-actions-container { margin-top: 24px; + text-align: right; .btn { margin-right: 16px; &:last-child { margin-right: 0; } + } + } - &.btn-primary { - background: @pink !important; - border-color: @pink !important; - } + .dashboard-modal.delete { + .btn.btn-primary { + background: @pink; + border-color: @pink; } } } diff --git a/superset/assets/src/dashboard/stylesheets/filter-scope-selector.less b/superset/assets/src/dashboard/stylesheets/filter-scope-selector.less new file mode 100644 index 0000000..dd19a66 --- /dev/null +++ b/superset/assets/src/dashboard/stylesheets/filter-scope-selector.less @@ -0,0 +1,241 @@ +/** + * 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 "../../../stylesheets/less/cosmo/variables.less"; + +.filter-scope-container { + font-size: 14px; + + .nav.nav-tabs { + border: none; + } +} + +.filter-scope-header { + h4 { + margin-top: 0; + } + + .selected-fields { + margin: 12px 0 16px; + visibility: hidden; + + &.multi-edit-mode { + visibility: visible; + } + + .selected-scopes { + padding-left: 5px; + } + } +} + +.filters-scope-selector { + margin: 10px -24px 20px; + display: flex; + flex-direction: row; + position: relative; + border: 1px solid #ccc; + border-left: none; + border-right: none; + + a, a:active, a:hover { + color: @almost-black; + text-decoration: none; + } + + .react-checkbox-tree .rct-icon.rct-icon-expand-all, + .react-checkbox-tree .rct-icon.rct-icon-collapse-all { + font-size: 13px; + font-family: @font-family-sans-serif; + color: @brand-primary; + + &::before { + content: ''; + } + + &:hover { + text-decoration: underline; + } + + &:focus { + outline: none; + } + } + + .filter-field-pane { + position: relative; + width: 40%; + padding: 16px 16px 16px 24px; + border-right: 1px solid #ccc; + + .filter-container { + label { + font-weight: normal; + margin: 0 0 0 16px; + } + } + + .filter-field-item { + height: 35px; + display: flex; + align-items: center; + padding: 0 24px; + margin-left: -24px; + + &.is-selected { + border: 1px solid #aaa; + border-radius: 4px; + background-color: #eee; + margin-left: -25px; + } + } + + .react-checkbox-tree { + .rct-text { + height: 40px; + } + } + } + + .filter-scope-pane { + position: relative; + flex: 1; + padding: 16px 24px 16px 16px; + } + + .react-checkbox-tree { + flex-direction: column; + color: @almost-black; + font-size: 14px; + + .filter-scope-type { + padding: 8px 0; + display: block; + + .type-indicator { + position: relative; + top: 3px; + margin-right: 8px; + } + + &.chart { + font-weight: normal; + } + + &.selected-filter { + padding-left: 28px; + position: relative; + color: #aaa; + + &::before { + content: " "; + position: absolute; + left: 0; + top: 50%; + width: 18px; + height: 18px; + border-radius: 2px; + margin-top: -9px; + box-shadow: inset 0 0 0 2px #ccc; + background: #f2f2f2; + } + } + + &.root { + font-weight: 700; + } + + &.tab { + font-weight: 700; + } + } + + .rct-checkbox { + svg { + position: relative; + top: 3px; + width: 18px; + } + } + + .rct-node-leaf { + .rct-bare-label { + &::before { + padding-left: 5px; + } + } + } + + .rct-options { + text-align: left; + margin-left: 0; + margin-bottom: 8px; + } + + .rct-text { + margin: 0; + display: flex; + } + + .rct-title { + display: block; + font-weight: bold; + } + + // disable style from react-checkbox-trees.css + .rct-node-clickable:hover, + .rct-node-clickable:focus, + label:hover, + label:active { + background: none !important; + } + } + + .multi-edit-mode { + &.filter-scope-pane { + .rct-node.rct-node-leaf .filter-scope-type.filter_box { + display: none; + } + } + + .filter-field-item { + padding: 0 16px 0 50px; + margin-left: -50px; + + &.is-selected { + margin-left: -51px; + } + } + } + + .scope-search { + position: absolute; + right: 16px; + top: 16px; + border-radius: 4px; + border: 1px solid #ccc; + padding: 4px 8px 4px 8px; + font-size: 13px; + outline: none; + + &:focus { + border: 1px solid @brand-primary; + } + } +} diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/stylesheets/index.less index 01a0e3c..8ebce25 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/stylesheets/index.less @@ -23,6 +23,7 @@ @import './buttons.less'; @import './dashboard.less'; @import './dnd.less'; +@import './filter-scope-selector.less'; @import './filter-indicator.less'; @import './filter-indicator-tooltip.less'; @import './grid.less'; diff --git a/superset/assets/src/dashboard/util/activeDashboardFilters.js b/superset/assets/src/dashboard/util/activeDashboardFilters.js index 8c70577..8fa00d0 100644 --- a/superset/assets/src/dashboard/util/activeDashboardFilters.js +++ b/superset/assets/src/dashboard/util/activeDashboardFilters.js @@ -17,14 +17,31 @@ * under the License. */ let activeFilters = {}; +let allFilterBoxChartIds = []; export function getActiveFilters() { return activeFilters; } +// currently filterbox is a chart, +// when define filter scopes, they have to be out pulled out in a few places. +// after we make filterbox a dashboard build-in component, +// will not need this check anymore +export function isFilterBox(chartId) { + return allFilterBoxChartIds.includes(chartId); +} + +export function getAllFilterBoxChartIds() { + return allFilterBoxChartIds; +} + // non-empty filters from dashboardFilters, // this function does not take into account: filter immune or filter scope settings export function buildActiveFilters(allDashboardFilters = {}) { + allFilterBoxChartIds = Object.values(allDashboardFilters).map( + filter => filter.chartId, + ); + activeFilters = Object.values(allDashboardFilters).reduce( (result, filter) => { const { chartId, columns } = filter; diff --git a/superset/assets/src/dashboard/util/buildFilterScopeTreeEntry.js b/superset/assets/src/dashboard/util/buildFilterScopeTreeEntry.js new file mode 100644 index 0000000..e91ac51 --- /dev/null +++ b/superset/assets/src/dashboard/util/buildFilterScopeTreeEntry.js @@ -0,0 +1,65 @@ +/** + * 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 getFilterScopeNodesTree from './getFilterScopeNodesTree'; +import getFilterScopeParentNodes from './getFilterScopeParentNodes'; +import getKeyForFilterScopeTree from './getKeyForFilterScopeTree'; +import getSelectedChartIdForFilterScopeTree from './getSelectedChartIdForFilterScopeTree'; + +export default function buildFilterScopeTreeEntry({ + checkedFilterFields = [], + activeFilterField, + filterScopeMap = {}, + layout = {}, +}) { + const key = getKeyForFilterScopeTree({ + checkedFilterFields, + activeFilterField, + }); + const editingList = activeFilterField + ? [activeFilterField] + : checkedFilterFields; + const selectedChartId = getSelectedChartIdForFilterScopeTree({ + checkedFilterFields, + activeFilterField, + }); + const nodes = getFilterScopeNodesTree({ + components: layout, + filterFields: editingList, + selectedChartId, + }); + const checkedChartIdSet = new Set(); + editingList.forEach(filterField => { + (filterScopeMap[filterField].checked || []).forEach(chartId => { + checkedChartIdSet.add(`${chartId}:${filterField}`); + }); + }); + const checked = [...checkedChartIdSet]; + const expanded = filterScopeMap[key] + ? filterScopeMap[key].expanded + : getFilterScopeParentNodes(nodes, 1); + + return { + [key]: { + nodes, + nodesFiltered: [...nodes], + checked, + expanded, + }, + }; +} diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js index e2cbd32..5eae2a8 100644 --- a/superset/assets/src/dashboard/util/constants.js +++ b/superset/assets/src/dashboard/util/constants.js @@ -76,3 +76,6 @@ export const FILTER_INDICATORS_DISPLAY_LENGTH = 3; // in-component element types: can be added into // directPathToChild, used for in dashboard navigation and focus export const IN_COMPONENT_ELEMENT_TYPES = ['LABEL']; + +// filter scope selector filter fields pane root id +export const ALL_FILTERS_ROOT = 'ALL_FILTERS_ROOT'; diff --git a/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js b/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js index bb8f762..129acf5 100644 --- a/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js +++ b/superset/assets/src/dashboard/util/dashboardFiltersColorMap.js @@ -16,15 +16,13 @@ * specific language governing permissions and limitations * under the License. */ +import { getDashboardFilterKey } from './getDashboardFilterKey'; + // should be consistent with @badge-colors .less variable const FILTER_COLORS_COUNT = 20; let filterColorMap = {}; -export function getFilterColorKey(chartId, column) { - return `${chartId}_${column}`; -} - export function getFilterColorMap() { return filterColorMap; } @@ -38,7 +36,7 @@ export function buildFilterColorMap(allDashboardFilters = {}) { Object.keys(columns) .sort() .forEach(column => { - const key = getFilterColorKey(chartId, column); + const key = getDashboardFilterKey({ chartId, column }); const colorCode = `badge-${filterColorIndex % FILTER_COLORS_COUNT}`; /* eslint-disable no-param-reassign */ colorMap[key] = colorCode; diff --git a/superset/assets/src/dashboard/util/getCurrentScopeChartIds.js b/superset/assets/src/dashboard/util/getCurrentScopeChartIds.js new file mode 100644 index 0000000..60d86b5 --- /dev/null +++ b/superset/assets/src/dashboard/util/getCurrentScopeChartIds.js @@ -0,0 +1,62 @@ +/** + * 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 { CHART_TYPE } from '../util/componentTypes'; + +export default function getCurrentScopeChartIds({ + scopeComponentIds, + filterField, + filterImmuneSlices, + filterImmuneSliceFields, + components, +}) { + let chartIds = []; + + function traverse(component) { + if (!component) { + return; + } + + if ( + component.type === CHART_TYPE && + component.meta && + component.meta.chartId + ) { + chartIds.push(component.meta.chartId); + } else if (component.children) { + component.children.forEach(child => traverse(components[child])); + } + } + + scopeComponentIds.forEach(componentId => traverse(components[componentId])); + + if (filterImmuneSlices && filterImmuneSlices.length) { + chartIds = chartIds.filter(id => !filterImmuneSlices.includes(id)); + } + + if (filterImmuneSliceFields) { + chartIds = chartIds.filter( + id => + !(id.toString() in filterImmuneSliceFields) || + !filterImmuneSliceFields[id].includes(filterField), + ); + } + + return chartIds; +} diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/util/getDashboardFilterKey.js similarity index 67% copy from superset/assets/src/dashboard/stylesheets/index.less copy to superset/assets/src/dashboard/util/getDashboardFilterKey.js index 01a0e3c..e6307c3 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/util/getDashboardFilterKey.js @@ -16,17 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -@import './variables.less'; +export function getDashboardFilterKey({ chartId, column }) { + return `${chartId}_${column}`; +} -@import './builder.less'; -@import './builder-sidepane.less'; -@import './buttons.less'; -@import './dashboard.less'; -@import './dnd.less'; -@import './filter-indicator.less'; -@import './filter-indicator-tooltip.less'; -@import './grid.less'; -@import './hover-menu.less'; -@import './popover-menu.less'; -@import './resizable.less'; -@import './components/index.less'; +export function getChartIdAndColumnFromFilterKey(key) { + const [chartId, ...parts] = key.split('_'); + const column = parts.slice().join('_'); + return { chartId: parseInt(chartId, 10), column }; +} diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js b/superset/assets/src/dashboard/util/getFilterFieldNodesTree.js similarity index 50% copy from superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js copy to superset/assets/src/dashboard/util/getFilterFieldNodesTree.js index e72e13e..b55d28f 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardFilters.js +++ b/superset/assets/src/dashboard/util/getFilterFieldNodesTree.js @@ -16,31 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -import { filterId } from './mockSliceEntities'; +import { t } from '@superset-ui/translation'; -export const emptyFilters = {}; +import { getDashboardFilterKey } from './getDashboardFilterKey'; +import { ALL_FILTERS_ROOT } from './constants'; -export const dashboardFilters = { - [filterId]: { - chartId: filterId, - componentId: 'CHART-rwDfbGqeEn', - directPathToFilter: [ - 'ROOT_ID', - 'TABS-VPEX_c476g', - 'TAB-PMJyKM1yB', - 'TABS-YdylzDMTMQ', - 'TAB-O9AaU9FT0', - 'ROW-l6PrlhwSjh', - 'CHART-rwDfbGqeEn', - ], - scope: 'ROOT_ID', - isDateFilter: false, - isInstantFilter: true, - columns: { - region: ['a', 'b'], - }, - labels: { - region: 'region', +export default function getFilterFieldNodesTree({ dashboardFilters = {} }) { + const allFilters = Object.values(dashboardFilters).map(dashboardFilter => { + const { chartId, filterName, columns, labels } = dashboardFilter; + const children = Object.keys(columns).map(column => ({ + value: getDashboardFilterKey({ chartId, column }), + label: labels[column] || column, + })); + return { + value: chartId, + label: filterName, + children, + showCheckbox: true, + }; + }); + + return [ + { + value: ALL_FILTERS_ROOT, + label: t('Select/deselect all filters'), + children: allFilters, }, - }, -}; + ]; +} diff --git a/superset/assets/src/dashboard/util/getFilterScopeNodesTree.js b/superset/assets/src/dashboard/util/getFilterScopeNodesTree.js new file mode 100644 index 0000000..470ac08 --- /dev/null +++ b/superset/assets/src/dashboard/util/getFilterScopeNodesTree.js @@ -0,0 +1,128 @@ +/** + * 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 { isEmpty } from 'lodash'; +import { t } from '@superset-ui/translation'; + +import { DASHBOARD_ROOT_ID } from './constants'; +import { + CHART_TYPE, + DASHBOARD_ROOT_TYPE, + TAB_TYPE, +} from '../util/componentTypes'; + +const FILTER_SCOPE_CONTAINER_TYPES = [TAB_TYPE, DASHBOARD_ROOT_TYPE]; + +function traverse({ + currentNode = {}, + components = {}, + filterFields = [], + selectedChartId, +}) { + if (!currentNode) { + return null; + } + + const type = currentNode.type; + if ( + CHART_TYPE === type && + currentNode && + currentNode.meta && + currentNode.meta.chartId + ) { + const chartNode = { + value: currentNode.meta.chartId, + label: + currentNode.meta.sliceName || `${type} ${currentNode.meta.chartId}`, + type, + showCheckbox: selectedChartId !== currentNode.meta.chartId, + }; + + return { + ...chartNode, + children: filterFields.map(filterField => ({ + value: `${currentNode.meta.chartId}:${filterField}`, + label: `${chartNode.label}`, + type: 'filter_box', + showCheckbox: false, + })), + }; + } + + let children = []; + if (currentNode.children && currentNode.children.length) { + currentNode.children.forEach(child => { + const childNodeTree = traverse({ + currentNode: components[child], + components, + filterFields, + selectedChartId, + }); + + const childType = components[child].type; + if (FILTER_SCOPE_CONTAINER_TYPES.includes(childType)) { + children.push(childNodeTree); + } else { + children = children.concat(childNodeTree); + } + }); + } + + if (FILTER_SCOPE_CONTAINER_TYPES.includes(type)) { + let label = null; + if (type === DASHBOARD_ROOT_TYPE) { + label = t('Select/deselect all charts'); + } else { + label = + currentNode.meta && currentNode.meta.text + ? currentNode.meta.text + : `${type} ${currentNode.id}`; + } + + return { + value: currentNode.id, + label, + type, + children, + }; + } + + return children; +} + +export default function getFilterScopeNodesTree({ + components = {}, + filterFields = [], + selectedChartId, +}) { + if (isEmpty(components)) { + return []; + } + + const root = traverse({ + currentNode: components[DASHBOARD_ROOT_ID], + components, + filterFields, + selectedChartId, + }); + return [ + { + ...root, + }, + ]; +} diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/util/getFilterScopeParentNodes.js similarity index 61% copy from superset/assets/src/dashboard/stylesheets/index.less copy to superset/assets/src/dashboard/util/getFilterScopeParentNodes.js index 01a0e3c..8330c64 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/util/getFilterScopeParentNodes.js @@ -16,17 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -@import './variables.less'; +export default function getFilterScopeParentNodes(nodes = [], depthLimit = -1) { + const parentNodes = []; + const traverse = (currentNode, depth) => { + if (!currentNode) { + return; + } -@import './builder.less'; -@import './builder-sidepane.less'; -@import './buttons.less'; -@import './dashboard.less'; -@import './dnd.less'; -@import './filter-indicator.less'; -@import './filter-indicator-tooltip.less'; -@import './grid.less'; -@import './hover-menu.less'; -@import './popover-menu.less'; -@import './resizable.less'; -@import './components/index.less'; + if (currentNode.children && (depthLimit === -1 || depth < depthLimit)) { + parentNodes.push(currentNode.value); + currentNode.children.forEach(child => traverse(child, depth + 1)); + } + }; + + if (nodes.length > 0) { + nodes.forEach(node => { + traverse(node, 0); + }); + } + + return parentNodes; +} diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/util/getKeyForFilterScopeTree.js similarity index 67% copy from superset/assets/src/dashboard/stylesheets/index.less copy to superset/assets/src/dashboard/util/getKeyForFilterScopeTree.js index 01a0e3c..e85dd51 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/util/getKeyForFilterScopeTree.js @@ -16,17 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -@import './variables.less'; +import { safeStringify } from '../../utils/safeStringify'; -@import './builder.less'; -@import './builder-sidepane.less'; -@import './buttons.less'; -@import './dashboard.less'; -@import './dnd.less'; -@import './filter-indicator.less'; -@import './filter-indicator-tooltip.less'; -@import './grid.less'; -@import './hover-menu.less'; -@import './popover-menu.less'; -@import './resizable.less'; -@import './components/index.less'; +export default function getKeyForFilterScopeTree({ + activeFilterField, + checkedFilterFields, +}) { + return activeFilterField + ? safeStringify([activeFilterField]) + : safeStringify(checkedFilterFields); +} diff --git a/superset/assets/src/dashboard/stylesheets/index.less b/superset/assets/src/dashboard/util/getRevertedFilterScope.js similarity index 57% copy from superset/assets/src/dashboard/stylesheets/index.less copy to superset/assets/src/dashboard/util/getRevertedFilterScope.js index 01a0e3c..92e4a29 100644 --- a/superset/assets/src/dashboard/stylesheets/index.less +++ b/superset/assets/src/dashboard/util/getRevertedFilterScope.js @@ -16,17 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -@import './variables.less'; +export default function getRevertedFilterScope({ + checked = [], + filterFields = [], + filterScopeMap = {}, +}) { + const checkedChartIdsByFilterField = checked.reduce((map, value) => { + const [chartId, filterField] = value.split(':'); + return { + ...map, + [filterField]: (map[filterField] || []).concat(parseInt(chartId, 10)), + }; + }, {}); -@import './builder.less'; -@import './builder-sidepane.less'; -@import './buttons.less'; -@import './dashboard.less'; -@import './dnd.less'; -@import './filter-indicator.less'; -@import './filter-indicator-tooltip.less'; -@import './grid.less'; -@import './hover-menu.less'; -@import './popover-menu.less'; -@import './resizable.less'; -@import './components/index.less'; + return filterFields.reduce( + (map, filterField) => ({ + ...map, + [filterField]: { + ...filterScopeMap[filterField], + checked: checkedChartIdsByFilterField[filterField], + }, + }), + {}, + ); +} diff --git a/superset/assets/src/dashboard/util/getSelectedChartIdForFilterScopeTree.js b/superset/assets/src/dashboard/util/getSelectedChartIdForFilterScopeTree.js new file mode 100644 index 0000000..cde72e3 --- /dev/null +++ b/superset/assets/src/dashboard/util/getSelectedChartIdForFilterScopeTree.js @@ -0,0 +1,53 @@ +/** + * 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 { getChartIdAndColumnFromFilterKey } from './getDashboardFilterKey'; + +export default function getSelectedChartIdForFilterScopeTree({ + activeFilterField, + checkedFilterFields, +}) { + // we don't apply filter on filter_box itself, so we will disable + // checkbox in filter scope selector. + // this function returns chart id based on current filter scope selector local state: + // 1. if in single-edit mode, return the chart id for selected filter field. + // 2. if in multi-edit mode, if all filter fields are from same chart id, + // return the single chart id. + // otherwise, there is no chart to disable. + if (activeFilterField) { + return getChartIdAndColumnFromFilterKey(activeFilterField).chartId; + } + + if (checkedFilterFields.length) { + const { chartId } = getChartIdAndColumnFromFilterKey( + checkedFilterFields[0], + ); + + if ( + checkedFilterFields.some( + filterKey => + getChartIdAndColumnFromFilterKey(filterKey).chartId !== chartId, + ) + ) { + return null; + } + return chartId; + } + + return null; +} diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx index d4fb6dd..b59f41e 100644 --- a/superset/assets/src/dashboard/util/propShapes.jsx +++ b/superset/assets/src/dashboard/util/propShapes.jsx @@ -35,6 +35,9 @@ export const componentShape = PropTypes.shape({ // Row background: PropTypes.oneOf(backgroundStyleOptions.map(opt => opt.value)), + + // Chart + chartId: PropTypes.number, }), }); @@ -76,7 +79,7 @@ export const filterIndicatorPropShape = PropTypes.shape({ isInstantFilter: PropTypes.bool.isRequired, label: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - scope: PropTypes.string.isRequired, + scope: PropTypes.arrayOf(PropTypes.string), values: PropTypes.array.isRequired, }); @@ -102,6 +105,30 @@ export const dashboardInfoPropShape = PropTypes.shape({ userId: PropTypes.string.isRequired, }); +/* eslint-disable-next-line no-undef */ +const lazyFunction = f => () => f().apply(this, arguments); + +const leafType = PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + label: PropTypes.string.isRequired, +}); + +const parentShape = { + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + label: PropTypes.string.isRequired, + children: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.shape(lazyFunction(() => parentShape)), + leafType, + ]), + ), +}; + +export const filterScopeSelectorTreeNodePropShape = PropTypes.oneOfType([ + PropTypes.shape(parentShape), + leafType, +]); + export const loadStatsPropShape = PropTypes.objectOf( PropTypes.shape({ didLoad: PropTypes.bool.isRequired, diff --git a/superset/assets/src/visualizations/FilterBox/FilterBox.css b/superset/assets/src/visualizations/FilterBox/FilterBox.css index f546c9c..0b678e0 100644 --- a/superset/assets/src/visualizations/FilterBox/FilterBox.css +++ b/superset/assets/src/visualizations/FilterBox/FilterBox.css @@ -60,7 +60,7 @@ ul.select2-results div.filter_box{ .filter-container label { display: flex; font-weight: bold; - margin-bottom: 8px; + margin: 0 0 8px 8px; } .filter-container .filter-badge-container { width: 30px; diff --git a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx index a2b9cc8..b259e7b 100644 --- a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx +++ b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx @@ -29,7 +29,8 @@ import Control from '../../explore/components/Control'; import controls from '../../explore/controls'; import OnPasteSelect from '../../components/OnPasteSelect'; import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap'; -import { getFilterColorKey, getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap'; +import { getDashboardFilterKey } from '../../dashboard/util/getDashboardFilterKey'; +import { getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap'; import FilterBadgeIcon from '../../components/FilterBadgeIcon'; import './FilterBox.css'; @@ -303,7 +304,7 @@ class FilterBox extends React.Component { } renderFilterBadge(chartId, column) { - const colorKey = getFilterColorKey(chartId, column); + const colorKey = getDashboardFilterKey({ chartId, column }); const filterColorMap = getFilterColorMap(); const colorCode = filterColorMap[colorKey];