refactor: Removes the Filter Box code (#26328)

Co-authored-by: John Bodley <john.bodley@gmail.com>
This commit is contained in:
Michael S. Molina 2024-01-19 09:54:53 -03:00 committed by GitHub
parent 591f266543
commit d9a3c3e1dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 577 additions and 4970 deletions

View File

@ -28,6 +28,7 @@ assists people when migrating to a new version.
### Breaking Changes
- [26328](https://github.com/apache/superset/issues/26328): Removes the deprecated Filter Box code and it's associated dependencies `react-select` and `array-move`. It also removes the `DeprecatedSelect` and `AsyncSelect` components that were exclusively used by filter boxes. Existing filter boxes will be automatically migrated to native filters.
- [26330](https://github.com/apache/superset/issues/26330): Removes the deprecated `DASHBOARD_FILTERS_EXPERIMENTAL` feature flag. The previous value of the feature flag was `False` and now the feature is permanently removed.
- [26344](https://github.com/apache/superset/issues/26344): Removes the deprecated `ENABLE_EXPLORE_JSON_CSRF_PROTECTION` feature flag. The previous value of the feature flag was `False` and now the feature is permanently removed.
- [26345](https://github.com/apache/superset/issues/26345): Removes the deprecated `ENABLE_TEMPLATE_REMOVE_FILTERS` feature flag. The previous value of the feature flag was `True` and now the feature is permanently enabled.

View File

@ -1,94 +0,0 @@
---
title: Migrating from Legacy to Native Filters
sidebar_position: 5
version: 1
---
##
The `superset native-filters` CLI command group—somewhat akin to an Alembic migration—
comprises of a number of sub-commands which allows administrators to upgrade/downgrade
existing dashboards which use the legacy filter-box charts—in combination with the
filter scopes/filter mapping—to use the native filter dashboard component.
Even though both legacy and native filters can coexist the overall user experience (UX)
is substandard as the already convoluted filter space becomes overly complex. After
enabling the `DASHBOARD_NATIVE_FILTERS` it is strongly advised to run the migration ASAP to
ensure users are not exposed to the hybrid state.
### Upgrading
The
```
superset native-filters upgrade
```
command—which provides the option to target either specific dashboard(s) or all
dashboards—migrates the legacy filters to native filters.
Specifically, the command performs the following:
- Replaces every filter-box chart within the dashboard with a markdown element which
provides a link to the deprecated chart. This preserves the layout whilst simultaneously
providing context to help owners review/verify said change.
- Migrates the filter scopes/filter mappings to the native filter configuration.
#### Quality Control
Dashboard owners should:
- Verify that the filter behavior is correct.
- Consolidate any conflicting/redundant filters—this previously may not have been
obvious given the embedded nature of the legacy filters and/or the non-optimal UX of the
legacy filter mapping (scopes and immunity).
- Rename the filters—which may not be uniquely named—to provide the necessary context
which previously was likely provided by both the location of the filter-box and the
corresponding filter-box title.
Dashboard owners may:
- Remove† the markdown elements from their dashboards and adjust the layout accordingly.
† Note removing the markdown elements—which contain metadata relating to the replaced
chart—prevents the dashboard from being fully restored and thus this operation should
only be performed if it is evident that a downgrade is not necessary.
### Downgrading
Similarly the
```
superset native-filters downgrade
```
command reverses said migration, i.e., restores the dashboard to the previous state.
### Cleanup
The ability to downgrade/reverse the migration requires temporary storage of the
dashboard metadata—relating to both positional composition and filter configuration.
Once the upgrade has been verified it is recommended to run the
```
superset native-filters cleanup
```
command—which provides the option to target either specific dashboard(s) or all
dashboards. Note this operation is irreversible.
Specifically, the command performs the following:
- Removes the temporary dashboard metadata.
- Deletes the filter-box charts associated with the dashboard†.
† Note the markdown elements will still remain however the link to the referenced filter-box
chart will no longer be valid.
#### Quality Control
Dashboard owners should:
- Remove the markdown elements from their dashboards and adjust the layout accordingly.

View File

@ -1,82 +0,0 @@
/**
* 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 {
isLegacyResponse,
parsePostForm,
getChartAliasesBySpec,
waitForChartLoad,
} from 'cypress/utils';
import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
import { WORLD_HEALTH_CHARTS } from './utils';
describe.skip('Dashboard filter', () => {
before(() => {
cy.visit(WORLD_HEALTH_DASHBOARD);
});
it('should apply filter', () => {
WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
getChartAliasesBySpec(
WORLD_HEALTH_CHARTS.filter(({ viz }) => viz !== 'filter_box'),
).then(nonFilterChartAliases => {
cy.get('.Select__placeholder:first').click();
// should show the filter indicator
cy.get('span[aria-label="filter"]:visible').should(nodes => {
expect(nodes.length).to.least(9);
});
cy.get('.Select__control:first input[type=text]').type('So', {
force: true,
delay: 100,
});
cy.get('.Select__menu').first().contains('South Asia').click();
// should still have all filter indicators
cy.get('span[aria-label="filter"]:visible').should(nodes => {
expect(nodes.length).to.least(9);
});
cy.get('.filter_box button').click({ force: true });
cy.wait(nonFilterChartAliases).then(requests => {
requests.forEach(({ response, request }) => {
const responseBody = response?.body;
let requestFilter;
if (isLegacyResponse(responseBody)) {
const requestFormData = parsePostForm(request.body);
const requestParams = JSON.parse(
requestFormData.form_data as string,
);
requestFilter = requestParams.extra_filters[0];
} else {
requestFilter = request.body.queries[0].filters[0];
}
expect(requestFilter).deep.eq({
col: 'region',
op: 'IN',
val: ['South Asia'],
});
});
});
});
// TODO add test with South Asia{enter} type action to select filter
});
});

View File

@ -101,7 +101,7 @@ describe('Dashboard tabs', () => {
cy.get('.Select__control').first().should('be.visible').click();
cy.get('.Select__control input[type=text]').first().focus().type('South');
cy.get('.Select__option').contains('South Asia').click();
cy.get('.filter_box button:not(:disabled)').contains('Apply').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// send new query from same tab
cy.wait(treemapAlias).then(({ request }) => {
@ -149,7 +149,7 @@ describe('Dashboard tabs', () => {
cy.get('.ant-tabs-tab').contains('row tab 1').click();
cy.get('.Select__clear-indicator').click();
cy.get('.filter_box button:not(:disabled)').contains('Apply').click();
cy.get('.filter button:not(:disabled)').contains('Apply').click();
// trigger 1 new query
waitForChartLoad(TREEMAP);

View File

@ -1,38 +0,0 @@
/**
* 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 { FORM_DATA_DEFAULTS } from './visualizations/shared.helper';
describe('Edit FilterBox Chart', () => {
const VIZ_DEFAULTS = { ...FORM_DATA_DEFAULTS, viz_type: 'filter_box' };
function verify(formData) {
cy.visitChartByParams(formData);
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
beforeEach(() => {
cy.intercept('POST', '/superset/explore_json/**').as('getJson');
});
it('should work with default date filter', () => {
verify(VIZ_DEFAULTS);
// Filter box should default to having a date filter with no filter selected
cy.get('div.filter_box').contains('No filter');
});
});

View File

@ -78,7 +78,9 @@ describe('Test explore links', () => {
cy.url().then(() => {
cy.get('[data-test="query-save-button"]').click();
cy.get('[data-test="saveas-radio"]').check();
cy.get('[data-test="new-chart-name"]').type(newChartName);
cy.get('[data-test="new-chart-name"]').type(newChartName, {
force: true,
});
cy.get('[data-test="btn-modal-save"]').click();
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.visitChartByName(newChartName);

View File

@ -657,7 +657,7 @@ export const dashboardView = {
treeMapChartModal: {
selectItem: '.Select_control',
selectItemInput: '.Select__control input[type=text]',
applyButton: '.filter_box button:not(:disabled)',
applyButton: '.filter button:not(:disabled)',
clearItemIcon: '.Select__clear-indicator',
},
sliceThreeDots: '[aria-label="More Options"]',

View File

@ -59,7 +59,6 @@
"ace-builds": "^1.4.14",
"ansi-regex": "^4.1.1",
"antd": "4.10.3",
"array-move": "^2.2.1",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"bootstrap": "^3.4.1",
"bootstrap-slider": "^10.0.0",
@ -124,7 +123,6 @@
"react-reverse-portal": "^2.1.1",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
"react-select": "^3.2.0",
"react-sortable-hoc": "^2.0.0",
"react-split": "^2.0.9",
"react-syntax-highlighter": "^15.4.5",
@ -201,8 +199,8 @@
"@types/react-loadable": "^5.5.6",
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^3.0.19",
"@types/react-table": "^7.0.19",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.0",
"@types/react-window": "^1.8.5",
"@types/redux-localstorage": "^1.0.8",
@ -19787,17 +19785,6 @@
"@types/react-router": "*"
}
},
"node_modules/@types/react-select": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.0.19.tgz",
"integrity": "sha512-d+6qtfFXZeIOAABlVL1e50RZn8ctOABE4tFDxM6KW4lKuXgTTgLVrSik5AX9XjBjV7N80FtS6GTN/WeoXL9Jww==",
"dev": true,
"dependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"@types/react-transition-group": "*"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz",
@ -19823,9 +19810,9 @@
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
"integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==",
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dev": true,
"dependencies": {
"@types/react": "*"
@ -22895,17 +22882,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-move": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/array-move/-/array-move-2.2.1.tgz",
"integrity": "sha512-qQpEHBnVT6HAFgEVUwRdHVd8TYJThrZIT5wSXpEUTPwBaYhPLclw12mEpyUvRWVdl1VwPOqnIy6LqTFN3cSeUQ==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
@ -79812,17 +79788,6 @@
"@types/react-router": "*"
}
},
"@types/react-select": {
"version": "3.0.19",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.0.19.tgz",
"integrity": "sha512-d+6qtfFXZeIOAABlVL1e50RZn8ctOABE4tFDxM6KW4lKuXgTTgLVrSik5AX9XjBjV7N80FtS6GTN/WeoXL9Jww==",
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-dom": "*",
"@types/react-transition-group": "*"
}
},
"@types/react-syntax-highlighter": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz",
@ -79848,9 +79813,9 @@
}
},
"@types/react-transition-group": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
"integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==",
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dev": true,
"requires": {
"@types/react": "*"
@ -82292,11 +82257,6 @@
"is-string": "^1.0.7"
}
},
"array-move": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/array-move/-/array-move-2.2.1.tgz",
"integrity": "sha512-qQpEHBnVT6HAFgEVUwRdHVd8TYJThrZIT5wSXpEUTPwBaYhPLclw12mEpyUvRWVdl1VwPOqnIy6LqTFN3cSeUQ=="
},
"array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",

View File

@ -125,7 +125,6 @@
"ace-builds": "^1.4.14",
"ansi-regex": "^4.1.1",
"antd": "4.10.3",
"array-move": "^2.2.1",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"bootstrap": "^3.4.1",
"bootstrap-slider": "^10.0.0",
@ -190,7 +189,6 @@
"react-reverse-portal": "^2.1.1",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
"react-select": "^3.2.0",
"react-sortable-hoc": "^2.0.0",
"react-split": "^2.0.9",
"react-syntax-highlighter": "^15.4.5",
@ -267,8 +265,8 @@
"@types/react-loadable": "^5.5.6",
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^3.0.19",
"@types/react-table": "^7.0.19",
"@types/react-transition-group": "^4.4.10",
"@types/react-ultimate-pagination": "^1.2.0",
"@types/react-window": "^1.8.5",
"@types/redux-localstorage": "^1.0.8",

View File

@ -280,7 +280,6 @@ export type SelectControlType =
| 'AdhocFilterControl'
| 'FilterBoxItemControl';
// via react-select/src/filters
export interface FilterOption<T extends SelectOption> {
label: string;
value: string;

View File

@ -76,10 +76,6 @@ export interface ChartPropsConfig {
annotationData?: AnnotationData;
/** Datasource metadata */
datasource?: SnakeCaseDatasource;
/**
* Formerly called "filters", which was misleading because it is actually
* initial values of the filter_box and table vis
*/
initialValues?: DataRecordFilters;
/** Main configuration of the chart */
formData?: RawFormData;

View File

@ -32,7 +32,7 @@ describe('extractTimegrain', () => {
).toEqual('P1D');
});
it('should extract filter box time grain from form data', () => {
it('should extract filter time grain from form data', () => {
expect(
extractTimegrain({
...baseFormData,

View File

@ -29,9 +29,6 @@ import { AsyncAceEditorProps } from 'src/components/AsyncAceEditor';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -34,9 +34,6 @@ import EstimateQueryCostButton, {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -32,9 +32,6 @@ import QueryLimitSelect, {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -30,9 +30,6 @@ import RunQueryActionButton, {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -42,9 +42,6 @@ import {
} from 'src/SqlLab/actions/sqlLab';
import SqlEditorTabHeader from 'src/SqlLab/components/SqlEditorTabHeader';
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -33,9 +33,6 @@ import TemplateParamsEditor, {
TemplateParamsEditorProps,
} from 'src/SqlLab/components/TemplateParamsEditor';
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));

View File

@ -1,150 +0,0 @@
/**
* 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 { shallow } from 'enzyme';
import fetchMock from 'fetch-mock';
import Select from 'src/components/DeprecatedSelect';
import AsyncSelect from 'src/components/AsyncSelect';
describe('AsyncSelect', () => {
afterAll(fetchMock.reset);
afterEach(fetchMock.resetHistory);
const dataEndpoint = '/chart/api/read';
const dataGlob = 'glob:*/chart/api/read';
fetchMock.get(dataGlob, []);
fetchMock.resetHistory();
const mockedProps = {
dataEndpoint,
onChange: () => {},
placeholder: 'Select...',
mutator: () => [
{ value: 1, label: 'main' },
{ value: 2, label: 'another' },
],
valueRenderer: opt => opt.label,
};
it('is valid element', () => {
expect(React.isValidElement(<AsyncSelect {...mockedProps} />)).toBe(true);
});
it('has one select', () => {
const wrapper = shallow(<AsyncSelect {...mockedProps} />);
expect(wrapper.find(Select)).toExist();
});
it('calls onChange on select change', () => {
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect {...mockedProps} onChange={onChangeSpy} />,
);
wrapper.find(Select).simulate('change', { value: 1 });
expect(onChangeSpy.mock.calls).toHaveLength(1);
});
describe('auto select', () => {
it('should not call onChange if autoSelect=false', () =>
new Promise(done => {
expect.assertions(2);
const onChangeSpy = jest.fn();
shallow(<AsyncSelect {...mockedProps} onChange={onChangeSpy} />);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
done();
});
}));
it('should auto select the first option if autoSelect=true', () =>
new Promise(done => {
expect.assertions(3);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect {...mockedProps} onChange={onChangeSpy} autoSelect />,
);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(1);
expect(onChangeSpy).toBeCalledWith(
wrapper.instance().state.options[0],
);
done();
});
}));
it('should not auto select when value prop is set and autoSelect=true', () =>
new Promise(done => {
expect.assertions(3);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect
{...mockedProps}
value={2}
onChange={onChangeSpy}
autoSelect
/>,
);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
expect(wrapper.find(Select)).toExist();
done();
});
}));
it('should call onAsyncError if there is an error fetching options', () => {
expect.assertions(3);
const errorEndpoint = 'async/error/';
const errorGlob = 'glob:*async/error/';
fetchMock.get(errorGlob, { throws: 'error' });
const onAsyncError = jest.fn();
const wrapper = shallow(
<AsyncSelect
{...mockedProps}
dataEndpoint={errorEndpoint}
onAsyncError={onAsyncError}
/>,
);
return wrapper
.instance()
.fetchOptions()
.then(() => {
// Fails then retries thrice whenever fetching options, which happens twice:
// once on component mount and once when calling `fetchOptions` again
expect(fetchMock.calls(errorGlob)).toHaveLength(8);
expect(onAsyncError.mock.calls).toHaveLength(2);
expect(onAsyncError).toBeCalledWith('error');
return Promise.resolve();
});
});
});
});

View File

@ -1,104 +0,0 @@
/**
* 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';
// TODO: refactor this with `import { AsyncSelect } from src/components/Select`
import { Select } from 'src/components/DeprecatedSelect';
import { t, SupersetClient } from '@superset-ui/core';
import { getClientErrorObject } from '../../utils/getClientErrorObject';
const propTypes = {
dataEndpoint: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
mutator: PropTypes.func.isRequired,
onAsyncError: PropTypes.func,
value: PropTypes.oneOfType([
PropTypes.number,
PropTypes.arrayOf(PropTypes.number),
]),
valueRenderer: PropTypes.func,
placeholder: PropTypes.string,
autoSelect: PropTypes.bool,
};
const defaultProps = {
placeholder: t('Select ...'),
onAsyncError: () => {},
};
class AsyncSelect extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isLoading: false,
options: [],
};
this.onChange = this.onChange.bind(this);
}
componentDidMount() {
this.fetchOptions();
}
onChange(option) {
this.props.onChange(option);
}
fetchOptions() {
this.setState({ isLoading: true });
const { mutator, dataEndpoint } = this.props;
return SupersetClient.get({ endpoint: dataEndpoint })
.then(({ json }) => {
const options = mutator ? mutator(json) : json;
this.setState({ options, isLoading: false });
if (!this.props.value && this.props.autoSelect && options.length > 0) {
this.onChange(options[0]);
}
})
.catch(response =>
getClientErrorObject(response).then(error => {
this.props.onAsyncError(error.error || error.statusText || error);
this.setState({ isLoading: false });
}),
);
}
render() {
return (
<Select
placeholder={this.props.placeholder}
options={this.state.options}
value={this.props.value}
isLoading={this.state.isLoading}
onChange={this.onChange}
valueRenderer={this.props.valueRenderer}
{...this.props}
/>
);
}
}
AsyncSelect.propTypes = propTypes;
AsyncSelect.defaultProps = defaultProps;
export default AsyncSelect;

View File

@ -1,143 +0,0 @@
/**
* 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 { useArgs } from '@storybook/client-api';
import { OptionTypeBase } from 'react-select';
import Select from '.';
const OPTIONS = [
{ label: 'Blue', value: 'blue' },
{ label: 'Red', value: 'red' },
{ label: 'Orange', value: 'orange' },
];
export default {
title: 'DeprecatedSelect',
argTypes: {
options: {
type: 'select',
options: OPTIONS,
},
multi: {
type: 'boolean',
},
value: {
type: 'string',
},
clearable: {
type: 'boolean',
},
placeholder: {
type: 'string',
},
},
};
export const SelectGallery = ({ value }: { value: OptionTypeBase }) => (
<>
<h4>With default value</h4>
<Select
value={OPTIONS[0]}
ignoreAccents={false}
name="select-datasource"
onChange={() => {}}
options={OPTIONS}
placeholder="choose one"
width={600}
/>
<hr />
<h4>With no value</h4>
<Select
ignoreAccents={false}
name="select-datasource"
onChange={() => {}}
options={OPTIONS}
placeholder="choose one"
width={600}
value={value}
/>
<hr />
<h4>Multi select</h4>
<Select
ignoreAccents={false}
name="select-datasource"
onChange={() => {}}
options={OPTIONS}
placeholder="choose one or more values"
width={600}
value={[OPTIONS[0]]}
multi
/>
</>
);
SelectGallery.args = {
value: '',
options: OPTIONS,
};
SelectGallery.story = {
parameters: {
knobs: {
disabled: true,
},
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const InteractiveSelect = (args: any) => {
const [{ value, multi, clearable, placeholder }, updateArgs] = useArgs();
const onSelect = (selection: {}) => {
const { value }: { value?: any } = selection || {};
if (multi) {
updateArgs({ value: selection });
return;
}
updateArgs({ value });
};
return (
<Select
clearable={clearable}
onChange={onSelect}
name="interactive-select"
options={OPTIONS}
placeholder={placeholder}
with={600}
value={value}
multi={multi}
/>
);
};
InteractiveSelect.args = {
value: '',
multi: false,
options: OPTIONS,
clearable: false,
placeholder: "I'm interactive",
};
InteractiveSelect.story = {
parameters: {
knobs: {
disabled: true,
},
},
};

View File

@ -1,324 +0,0 @@
/**
* 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, { SyntheticEvent, MutableRefObject, ComponentType } from 'react';
import { merge } from 'lodash';
import BasicSelect, {
OptionTypeBase,
MultiValueProps,
FormatOptionLabelMeta,
ValueType,
SelectComponentsConfig,
components as defaultComponents,
createFilter,
Props as SelectProps,
} from 'react-select';
import Async from 'react-select/async';
import Creatable from 'react-select/creatable';
import AsyncCreatable from 'react-select/async-creatable';
import type { SelectComponents } from 'react-select/src/components';
import {
SortableContainer,
SortableElement,
SortableContainerProps,
} from 'react-sortable-hoc';
import arrayMove from 'array-move';
import { useTheme } from '@superset-ui/core';
import { findValue } from './utils';
import {
WindowedSelectComponentType,
WindowedSelectProps,
WindowedSelect,
WindowedCreatableSelect,
WindowedAsyncCreatableSelect,
} from './WindowedSelect';
import {
DEFAULT_CLASS_NAME,
DEFAULT_CLASS_NAME_PREFIX,
DEFAULT_STYLES,
DEFAULT_COMPONENTS,
VALUE_LABELED_STYLES,
PartialThemeConfig,
PartialStylesConfig,
SelectComponentsType,
InputProps,
defaultTheme,
} from './styles';
type AnyReactSelect<OptionType extends OptionTypeBase> =
| BasicSelect<OptionType>
| Async<OptionType>
| Creatable<OptionType>
| AsyncCreatable<OptionType>;
export type SupersetStyledSelectProps<
OptionType extends OptionTypeBase,
T extends WindowedSelectProps<OptionType> = WindowedSelectProps<OptionType>,
> = T & {
// additional props for easier usage or backward compatibility
labelKey?: string;
valueKey?: string;
assistiveText?: string;
multi?: boolean;
clearable?: boolean;
sortable?: boolean;
ignoreAccents?: boolean;
creatable?: boolean;
selectRef?:
| React.RefCallback<AnyReactSelect<OptionType>>
| MutableRefObject<AnyReactSelect<OptionType>>;
getInputValue?: (selectBalue: ValueType<OptionType>) => string | undefined;
optionRenderer?: (option: OptionType) => React.ReactNode;
valueRenderer?: (option: OptionType) => React.ReactNode;
valueRenderedAsLabel?: boolean;
// callback for paste event
onPaste?: (e: SyntheticEvent) => void;
forceOverflow?: boolean;
// for simpler theme overrides
themeConfig?: PartialThemeConfig;
stylesConfig?: PartialStylesConfig;
};
function styled<
OptionType extends OptionTypeBase,
SelectComponentType extends
| WindowedSelectComponentType<OptionType>
| ComponentType<
SelectProps<OptionType>
> = WindowedSelectComponentType<OptionType>,
>(SelectComponent: SelectComponentType) {
type SelectProps = SupersetStyledSelectProps<OptionType>;
type Components = SelectComponents<OptionType>;
const SortableSelectComponent = SortableContainer(SelectComponent, {
withRef: true,
});
// default components for the given OptionType
const supersetDefaultComponents: SelectComponentsConfig<OptionType> =
DEFAULT_COMPONENTS;
const getSortableMultiValue = (MultiValue: Components['MultiValue']) =>
SortableElement((props: MultiValueProps<OptionType>) => {
const onMouseDown = (e: SyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
};
const innerProps = { onMouseDown };
return <MultiValue {...props} innerProps={innerProps} />;
});
/**
* Superset styled `Select` component. Apply Superset themed stylesheets and
* consolidate props API for backward compatibility with react-select v1.
*/
function StyledSelect(selectProps: SelectProps) {
let stateManager: AnyReactSelect<OptionType>; // reference to react-select StateManager
const {
// additional props for Superset Select
selectRef,
labelKey = 'label',
valueKey = 'value',
themeConfig,
stylesConfig = {},
optionRenderer,
valueRenderer,
// whether value is rendered as `option-label` in input,
// useful for AdhocMetric and AdhocFilter
valueRenderedAsLabel: valueRenderedAsLabel_,
onPaste,
multi = false, // same as `isMulti`, used for backward compatibility
clearable, // same as `isClearable`
sortable = true, // whether to enable drag & drop sorting
forceOverflow, // whether the dropdown should be forcefully overflowing
// react-select props
className = DEFAULT_CLASS_NAME,
classNamePrefix = DEFAULT_CLASS_NAME_PREFIX,
options,
value: value_,
components: components_,
isMulti: isMulti_,
isClearable: isClearable_,
minMenuHeight = 100, // apply different defaults
maxMenuHeight = 220,
filterOption,
ignoreAccents = false, // default is `true`, but it is slow
asText = (value: any) => String(value ?? ''),
getOptionValue = option =>
typeof option === 'string' ? option : option[valueKey],
getOptionLabel = option =>
typeof option === 'string'
? option
: asText(option[labelKey]) || asText(option[valueKey]),
formatOptionLabel = (
option: OptionType,
{ context }: FormatOptionLabelMeta<OptionType>,
) => {
if (context === 'value') {
return valueRenderer ? valueRenderer(option) : getOptionLabel(option);
}
return optionRenderer ? optionRenderer(option) : getOptionLabel(option);
},
...restProps
} = selectProps;
// `value` may be rendered values (strings), we want option objects
const value: OptionType[] = findValue(value_, options || [], valueKey);
// Add backward compatibility to v1 API
const isMulti = isMulti_ === undefined ? multi : isMulti_;
const isClearable = isClearable_ === undefined ? clearable : isClearable_;
// Sort is only applied when there are multiple selected values
const shouldAllowSort =
isMulti && sortable && Array.isArray(value) && value.length > 1;
const MaybeSortableSelect = shouldAllowSort
? SortableSelectComponent
: SelectComponent;
const components = { ...supersetDefaultComponents, ...components_ };
// Make multi-select sortable as per https://react-select.netlify.app/advanced
if (shouldAllowSort) {
components.MultiValue = getSortableMultiValue(
components.MultiValue || defaultComponents.MultiValue,
);
const sortableContainerProps: Partial<SortableContainerProps> = {
getHelperDimensions: ({ node }) => node.getBoundingClientRect(),
axis: 'xy',
onSortEnd: ({ oldIndex, newIndex }) => {
const newValue = arrayMove(value, oldIndex, newIndex);
if (restProps.onChange) {
restProps.onChange(newValue, { action: 'set-value' });
}
},
distance: 4,
};
Object.assign(restProps, sortableContainerProps);
}
// When values are rendered as labels, adjust valueContainer padding
const valueRenderedAsLabel =
valueRenderedAsLabel_ === undefined ? isMulti : valueRenderedAsLabel_;
if (valueRenderedAsLabel && !stylesConfig.valueContainer) {
Object.assign(stylesConfig, VALUE_LABELED_STYLES);
}
// Handle onPaste event
if (onPaste) {
const Input =
(components.Input as SelectComponentsType['Input']) ||
(defaultComponents.Input as SelectComponentsType['Input']);
components.Input = (props: InputProps) => (
<Input {...props} onPaste={onPaste} />
);
}
// for CreaTable
if (SelectComponent === WindowedCreatableSelect) {
restProps.getNewOptionData = (inputValue: string, label: string) => ({
label: label || inputValue,
[valueKey]: inputValue,
isNew: true,
});
}
// handle forcing dropdown overflow
// use only when setting overflow:visible isn't possible on the container element
if (forceOverflow) {
Object.assign(restProps, {
closeMenuOnScroll: (e: Event) => {
// ensure menu is open
const menuIsOpen = (stateManager as BasicSelect<OptionType>)?.state
?.menuIsOpen;
const target = e.target as HTMLElement;
return (
menuIsOpen &&
target &&
!target.classList?.contains('Select__menu-list')
);
},
menuPosition: 'fixed',
});
}
// Make sure always return StateManager for the refs.
// To get the real `Select` component, keep tap into `obj.select`:
// - for normal <Select /> component: StateManager -> Select,
// - for <Creatable />: StateManager -> Creatable -> Select
const setRef = (instance: any) => {
stateManager =
shouldAllowSort && instance && 'refs' in instance
? instance.refs.wrappedInstance // obtain StateManger from SortableContainer
: instance;
if (typeof selectRef === 'function') {
selectRef(stateManager);
} else if (selectRef && 'current' in selectRef) {
selectRef.current = stateManager;
}
};
const theme = useTheme();
return (
<MaybeSortableSelect
ref={setRef}
className={className}
classNamePrefix={classNamePrefix}
isMulti={isMulti}
isClearable={isClearable}
options={options}
value={value}
minMenuHeight={minMenuHeight}
maxMenuHeight={maxMenuHeight}
filterOption={
// filterOption may be NULL
filterOption !== undefined
? filterOption
: createFilter({ ignoreAccents })
}
styles={{ ...DEFAULT_STYLES, ...stylesConfig } as SelectProps['styles']}
// merge default theme from `react-select`, default theme for Superset,
// and the theme from props.
theme={reactSelectTheme =>
merge(reactSelectTheme, defaultTheme(theme), themeConfig)
}
formatOptionLabel={formatOptionLabel}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
components={components}
{...restProps}
/>
);
}
// React.memo makes sure the component does no rerender given the same props
return React.memo(StyledSelect);
}
export const Select = styled(WindowedSelect);
export const CreatableSelect = styled(WindowedCreatableSelect);
export const AsyncCreatableSelect = styled(WindowedAsyncCreatableSelect);
export default Select;

View File

@ -1,61 +0,0 @@
/**
* 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 { styled } from '@superset-ui/core';
import Select, { SelectProps } from 'antd/lib/select';
export type {
OptionType as NativeSelectOptionType,
SelectProps as NativeSelectProps,
} from 'antd/lib/select';
const StyledNativeSelect = styled((props: SelectProps<any>) => (
<Select getPopupContainer={(trigger: any) => trigger.parentNode} {...props} />
))`
display: block;
`;
const StyledNativeGraySelect = styled(Select)`
&.ant-select-single {
.ant-select-selector {
height: 36px;
padding: 0 11px;
background-color: ${({ theme }) => theme.colors.grayscale.light3};
border: none;
.ant-select-selection-search-input {
height: 100%;
}
.ant-select-selection-item,
.ant-select-selection-placeholder {
line-height: 35px;
color: ${({ theme }) => theme.colors.grayscale.dark1};
}
}
}
`;
export const NativeSelect = Object.assign(StyledNativeSelect, {
Option: Select.Option,
});
export const NativeGraySelect = Object.assign(StyledNativeGraySelect, {
Option: Select.Option,
});

View File

@ -1,104 +0,0 @@
/**
* 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 { Select } from 'src/components/DeprecatedSelect';
export default class OnPasteSelect extends React.Component {
constructor(props) {
super(props);
this.onPaste = this.onPaste.bind(this);
}
onPaste(evt) {
if (!this.props.isMulti) {
return;
}
evt.preventDefault();
const clipboard = evt.clipboardData.getData('Text');
if (!clipboard) {
return;
}
const regex = `[${this.props.separator}]+`;
const values = clipboard.split(new RegExp(regex)).map(v => v.trim());
const validator = this.props.isValidNewOption;
const selected = this.props.value || [];
const existingOptions = {};
const existing = {};
this.props.options.forEach(v => {
existingOptions[v[this.props.valueKey]] = 1;
});
let options = [];
selected.forEach(v => {
options.push({ [this.props.labelKey]: v, [this.props.valueKey]: v });
existing[v] = 1;
});
options = options.concat(
values
.filter(v => {
const notExists = !existing[v];
existing[v] = 1;
return (
notExists &&
(validator ? validator({ [this.props.labelKey]: v }) : !!v)
);
})
.map(v => {
const opt = { [this.props.labelKey]: v, [this.props.valueKey]: v };
if (!existingOptions[v]) {
this.props.options.unshift(opt);
}
return opt;
}),
);
if (options.length) {
if (this.props.onChange) {
this.props.onChange(options);
}
}
}
render() {
const { selectWrap: SelectComponent, ...restProps } = this.props;
return <SelectComponent {...restProps} onPaste={this.onPaste} />;
}
}
OnPasteSelect.propTypes = {
separator: PropTypes.array,
selectWrap: PropTypes.elementType,
selectRef: PropTypes.func,
onChange: PropTypes.func.isRequired,
valueKey: PropTypes.string,
labelKey: PropTypes.string,
options: PropTypes.array,
isMulti: PropTypes.bool,
value: PropTypes.any,
isValidNewOption: PropTypes.func,
noResultsText: PropTypes.string,
forceOverflow: PropTypes.bool,
};
OnPasteSelect.defaultProps = {
separator: [',', '\n', '\t', ';'],
selectWrap: Select,
valueKey: 'value',
labelKey: 'label',
options: [],
isMulti: false,
};

View File

@ -1,216 +0,0 @@
/**
* 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.
*/
/* eslint-disable no-unused-expressions */
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import {
Select,
OnPasteSelect,
CreatableSelect,
} from 'src/components/DeprecatedSelect';
const defaultProps = {
onChange: sinon.spy(),
isMulti: true,
isValidNewOption: sinon.spy(s => !!s.label),
value: [],
options: [
{ value: 'United States', label: 'United States' },
{ value: 'China', label: 'China' },
{ value: 'India', label: 'India' },
{ value: 'Canada', label: 'Canada' },
{ value: 'Russian Federation', label: 'Russian Federation' },
{ value: 'Japan', label: 'Japan' },
{ value: 'Mexico', label: 'Mexico' },
],
};
const defaultEvt = {
preventDefault: sinon.spy(),
clipboardData: {
getData: sinon.spy(() => ' United States, China, India, Canada, '),
},
};
describe('OnPasteSelect', () => {
let wrapper;
let props;
let evt;
let expected;
beforeEach(() => {
props = { ...defaultProps };
wrapper = shallow(<OnPasteSelect {...props} />);
evt = { ...defaultEvt };
});
it('renders the supplied selectWrap component', () => {
const select = wrapper.findWhere(x => x.type() === Select);
expect(select).toHaveLength(1);
});
it('renders custom selectWrap components', () => {
props.selectWrap = CreatableSelect;
wrapper = shallow(<OnPasteSelect {...props} />);
expect(wrapper.findWhere(x => x.type() === CreatableSelect)).toHaveLength(
1,
);
});
describe('onPaste', () => {
it('calls onChange with pasted comma separated values', () => {
wrapper.instance().onPaste(evt);
expected = props.options.slice(0, 4);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(5);
});
it('calls onChange with pasted new line separated values', () => {
evt.clipboardData.getData = sinon.spy(
() => 'United States\nChina\nRussian Federation\nIndia',
);
wrapper.instance().onPaste(evt);
expected = [
props.options[0],
props.options[1],
props.options[4],
props.options[2],
];
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(9);
});
it('calls onChange with pasted tab separated values', () => {
evt.clipboardData.getData = sinon.spy(
() => 'Russian Federation\tMexico\tIndia\tCanada',
);
wrapper.instance().onPaste(evt);
expected = [
props.options[4],
props.options[6],
props.options[2],
props.options[3],
];
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(13);
});
it('calls onChange without duplicate values and adds new comma separated values', () => {
evt.clipboardData.getData = sinon.spy(
() => 'China, China, China, China, Mexico, Mexico, Chi na, Mexico, ',
);
expected = [
props.options[1],
props.options[6],
{ label: 'Chi na', value: 'Chi na' },
];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(17);
expect(props.options[0].value).toBe(expected[2].value);
props.options.splice(0, 1);
});
it('calls onChange without duplicate values and parses new line separated values', () => {
evt.clipboardData.getData = sinon.spy(
() => 'United States\nCanada\nMexico\nUnited States\nCanada',
);
expected = [props.options[0], props.options[3], props.options[6]];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(20);
});
it('calls onChange without duplicate values and parses tab separated values', () => {
evt.clipboardData.getData = sinon.spy(
() => 'China\tIndia\tChina\tRussian Federation\tJapan\tJapan',
);
expected = [
props.options[1],
props.options[2],
props.options[4],
props.options[5],
];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(24);
});
it('calls onChange with currently selected values and new comma separated values', () => {
props.value = ['United States', 'Canada', 'Mexico'];
evt.clipboardData.getData = sinon.spy(
() => 'United States, Canada, Japan, India',
);
wrapper = shallow(<OnPasteSelect {...props} />);
expected = [
props.options[0],
props.options[3],
props.options[6],
props.options[5],
props.options[2],
];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(26);
});
it('calls onChange with currently selected values and new "new line" separated values', () => {
props.value = ['China', 'India', 'Japan'];
evt.clipboardData.getData = sinon.spy(() => 'Mexico\nJapan\nIndia');
wrapper = shallow(<OnPasteSelect {...props} />);
expected = [
props.options[1],
props.options[2],
props.options[5],
props.options[6],
];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(27);
});
it('calls onChange with currently selected values and new tab separated values', () => {
props.value = ['United States', 'Canada', 'Mexico', 'Russian Federation'];
evt.clipboardData.getData = sinon.spy(
() => 'United States\tCanada\tJapan\tIndia',
);
wrapper = shallow(<OnPasteSelect {...props} />);
expected = [
props.options[0],
props.options[3],
props.options[6],
props.options[4],
props.options[5],
props.options[2],
];
wrapper.instance().onPaste(evt);
expect(props.onChange.calledWith(expected)).toBe(true);
expect(evt.preventDefault.called).toBe(true);
expect(props.isValidNewOption.callCount).toBe(29);
});
});
});

View File

@ -1,158 +0,0 @@
/**
* 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, {
useRef,
useEffect,
Component,
FunctionComponent,
ReactElement,
RefObject,
} from 'react';
import {
ListChildComponentProps,
FixedSizeList as WindowedList,
} from 'react-window';
import {
OptionTypeBase,
OptionProps,
MenuListComponentProps,
} from 'react-select';
import { ThemeConfig } from '../styles';
export type WindowedMenuListProps = {
selectProps: {
windowListRef?: RefObject<WindowedList>;
optionHeight?: number;
};
};
/**
* MenuListComponentProps should always have `children` elements, as guaranteed
* by https://github.com/JedWatson/react-select/blob/32ad5c040bdd96cd1ca71010c2558842d684629c/packages/react-select/src/Select.js#L1686-L1719
*
* `children` may also be `Component<NoticeProps<OptionType>>` if options are not
* provided (e.g., when async list is still loading, or no results), but that's
* not possible because this MenuList will only be rendered when
* optionsLength > windowThreshold.
*
* If may also be `Component<GroupProps<OptionType>>[]` but we are not supporting
* grouped options just yet.
*/
type MenuListPropsChildren<OptionType extends OptionTypeBase> =
| Component<OptionProps<OptionType>>[]
| ReactElement[];
export type MenuListProps<OptionType extends OptionTypeBase> =
MenuListComponentProps<OptionType> & {
children: MenuListPropsChildren<OptionType>;
// theme is not present with built-in @types/react-select, but is actually
// available via CommonProps.
theme?: ThemeConfig;
className?: string;
} & WindowedMenuListProps;
const DEFAULT_OPTION_HEIGHT = 30;
/**
* Get the index of the last selected option.
*/
function getLastSelected(children: MenuListPropsChildren<any>) {
return Array.isArray(children)
? children.findIndex(
({ props: { isFocused = false } = {} }) => isFocused,
) || 0
: -1;
}
/**
* Calculate probable option height as set in theme configs
*/
function detectHeight({ spacing: { baseUnit, lineHeight } }: ThemeConfig) {
// Option item expects 2 * baseUnit for each of top and bottom padding.
return baseUnit * 4 + lineHeight;
}
export default function WindowedMenuList<OptionType extends OptionTypeBase>({
children,
...props
}: MenuListProps<OptionType>) {
const {
maxHeight,
selectProps,
theme,
getStyles,
cx,
innerRef,
isMulti,
className,
} = props;
const {
// Expose react-window VariableSizeList instance and HTML elements
windowListRef: windowListRef_,
windowListInnerRef,
} = selectProps;
const defaultWindowListRef = useRef<WindowedList>(null);
const windowListRef = windowListRef_ || defaultWindowListRef;
// try get default option height from theme configs
let { optionHeight } = selectProps;
if (!optionHeight) {
optionHeight = theme ? detectHeight(theme) : DEFAULT_OPTION_HEIGHT;
}
const itemCount = children.length;
const totalHeight = optionHeight * itemCount;
const Row: FunctionComponent<ListChildComponentProps> = ({
data,
index,
style,
}) => <div style={style}>{data[index]}</div>;
useEffect(() => {
const lastSelected = getLastSelected(children);
if (windowListRef.current && lastSelected) {
windowListRef.current.scrollToItem(lastSelected);
}
}, [children, windowListRef]);
return (
<WindowedList
css={getStyles('menuList', props)}
className={cx(
{
'menu-list': true,
'menu-list--is-multi': isMulti,
},
className,
)}
ref={windowListRef}
outerRef={innerRef}
innerRef={windowListInnerRef}
height={Math.min(totalHeight, maxHeight)}
width="100%"
itemData={children}
itemCount={children.length}
itemSize={optionHeight}
>
{Row}
</WindowedList>
);
}

View File

@ -1,29 +0,0 @@
/**
* 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 Select from 'react-select';
import Creatable from 'react-select/creatable';
import AsyncCreatable from 'react-select/async-creatable';
import windowed from './windowed';
export * from './windowed';
export const WindowedSelect = windowed(Select);
export const WindowedCreatableSelect = windowed(Creatable);
export const WindowedAsyncCreatableSelect = windowed(AsyncCreatable);
export default WindowedSelect;

View File

@ -1,84 +0,0 @@
/**
* 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, {
ComponentType,
FunctionComponent,
ReactElement,
forwardRef,
} from 'react';
import Select, {
Props as SelectProps,
OptionTypeBase,
MenuListComponentProps,
components as defaultComponents,
} from 'react-select';
import WindowedMenuList, { WindowedMenuListProps } from './WindowedMenuList';
const { MenuList: DefaultMenuList } = defaultComponents;
export const DEFAULT_WINDOW_THRESHOLD = 100;
export type WindowedSelectProps<OptionType extends OptionTypeBase> =
SelectProps<OptionType> & {
windowThreshold?: number;
} & WindowedMenuListProps['selectProps'];
export type WindowedSelectComponentType<OptionType extends OptionTypeBase> =
FunctionComponent<WindowedSelectProps<OptionType>>;
export function MenuList<OptionType extends OptionTypeBase>({
children,
...props
}: MenuListComponentProps<OptionType> & {
selectProps: WindowedSelectProps<OptionType>;
}) {
const { windowThreshold = DEFAULT_WINDOW_THRESHOLD } = props.selectProps;
if (Array.isArray(children) && children.length > windowThreshold) {
return (
<WindowedMenuList {...props}>
{children as ReactElement[]}
</WindowedMenuList>
);
}
return <DefaultMenuList {...props}>{children}</DefaultMenuList>;
}
/**
* Add "windowThreshold" option to a react-select component, turn the options
* list into a virtualized list when appropriate.
*
* @param SelectComponent the React component to render Select
*/
export default function windowed<OptionType extends OptionTypeBase>(
SelectComponent: ComponentType<SelectProps<OptionType>>,
): WindowedSelectComponentType<OptionType> {
const WindowedSelect = forwardRef(
(
props: WindowedSelectProps<OptionType>,
ref: React.RefObject<Select<OptionType>>,
) => {
const { components: components_ = {}, ...restProps } = props;
const components = { ...components_, MenuList };
return (
<SelectComponent components={components} ref={ref} {...restProps} />
);
},
);
return WindowedSelect;
}

View File

@ -1,23 +0,0 @@
/**
* 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.
*/
export * from './DeprecatedSelect';
export * from './styles';
export { default } from './DeprecatedSelect';
export { default as OnPasteSelect } from './OnPasteSelect';
export { NativeSelect, NativeGraySelect } from './NativeSelect';

View File

@ -1,406 +0,0 @@
/**
* 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.
*/
// Deprecated component
/* eslint-disable theme-colors/no-literal-colors */
import React, { CSSProperties, ComponentType, ReactNode } from 'react';
import { SerializedStyles } from '@emotion/react';
import { SupersetTheme, css } from '@superset-ui/core';
import {
Styles,
Theme,
SelectComponentsConfig,
components as defaultComponents,
InputProps as ReactSelectInputProps,
Props as SelectProps,
} from 'react-select';
import type { colors as reactSelectColors } from 'react-select/src/theme';
import type { DeepNonNullable } from 'react-select/src/components';
import { OptionType } from 'antd/lib/select';
import { SupersetStyledSelectProps } from './DeprecatedSelect';
export const DEFAULT_CLASS_NAME = 'Select';
export const DEFAULT_CLASS_NAME_PREFIX = 'Select';
type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
const colors = (theme: SupersetTheme) => ({
primary: theme.colors.success.base,
danger: theme.colors.error.base,
warning: theme.colors.warning.base,
indicator: theme.colors.info.base,
almostBlack: theme.colors.grayscale.dark1,
grayDark: theme.colors.grayscale.dark1,
grayLight: theme.colors.grayscale.light2,
gray: theme.colors.grayscale.light1,
grayBg: theme.colors.grayscale.light4,
grayBgDarker: theme.colors.grayscale.light3,
grayBgDarkest: theme.colors.grayscale.light2,
grayHeading: theme.colors.grayscale.light1,
menuHover: theme.colors.grayscale.light3,
lightest: theme.colors.grayscale.light5,
darkest: theme.colors.grayscale.dark2,
grayBorder: theme.colors.grayscale.light2,
grayBorderLight: theme.colors.grayscale.light3,
grayBorderDark: theme.colors.grayscale.light1,
textDefault: theme.colors.grayscale.dark1,
textDarkest: theme.colors.grayscale.dark2,
dangerLight: theme.colors.error.light1,
});
export type ThemeConfig = {
borderRadius: number;
// z-index for menu dropdown
// (the same as `@z-index-above-dashboard-charts + 1` in variables.less)
zIndex: number;
colors: {
// add known colors
[key in keyof typeof reactSelectColors]: string;
} & {
[key in keyof ReturnType<typeof colors>]: string;
} & {
[key: string]: string; // any other colors
};
spacing: Theme['spacing'] & {
// line height and font size must be pixels for easier computation
// of option item height in WindowedMenuList
lineHeight: number;
fontSize: number;
// other relative size must be string
minWidth: string;
};
};
export type PartialThemeConfig = RecursivePartial<ThemeConfig>;
export const defaultTheme: (theme: SupersetTheme) => PartialThemeConfig =
theme => ({
borderRadius: theme.borderRadius,
zIndex: 11,
colors: colors(theme),
spacing: {
baseUnit: 3,
menuGutter: 0,
controlHeight: 34,
lineHeight: 19,
fontSize: 14,
minWidth: '6.5em',
},
weights: theme.typography.weights,
});
// let styles accept serialized CSS, too
type CSSStyles = CSSProperties | SerializedStyles;
type styleFnWithSerializedStyles = (
base: CSSProperties,
state: any,
) => CSSStyles | CSSStyles[];
export type StylesConfig = {
[key in keyof Styles]: styleFnWithSerializedStyles;
};
export type PartialStylesConfig = Partial<StylesConfig>;
export const DEFAULT_STYLES: PartialStylesConfig = {
container: (
provider,
{
theme: {
spacing: { minWidth },
},
},
) => [
provider,
css`
min-width: ${minWidth};
`,
],
placeholder: provider => [
provider,
css`
white-space: nowrap;
`,
],
indicatorSeparator: () => css`
display: none;
`,
indicatorsContainer: provider => [
provider,
css`
i {
width: 1em;
display: inline-block;
}
`,
],
clearIndicator: provider => [
provider,
css`
padding: 4px 0 4px 6px;
`,
],
control: (
provider,
{ isFocused, menuIsOpen, theme: { borderRadius, colors } },
) => {
const isPseudoFocused = isFocused && !menuIsOpen;
let borderColor = colors.grayBorder;
if (isPseudoFocused || menuIsOpen) {
borderColor = colors.grayBorderDark;
}
return [
provider,
css`
border-color: ${borderColor};
box-shadow: ${isPseudoFocused
? 'inset 0 1px 1px rgba(0,0,0,.075), 0 0 0 3px rgba(0,0,0,.1)'
: 'none'};
border-radius: ${menuIsOpen
? `${borderRadius}px ${borderRadius}px 0 0`
: `${borderRadius}px`};
&:hover {
border-color: ${borderColor};
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
}
flex-wrap: nowrap;
padding-left: 1px;
`,
];
},
menu: (provider, { theme: { zIndex } }) => [
provider,
css`
padding-bottom: 2em;
z-index: ${zIndex}; /* override at least multi-page pagination */
width: auto;
min-width: 100%;
max-width: 80vw;
background: none;
box-shadow: none;
border: 0;
`,
],
menuList: (provider, { theme: { borderRadius, colors } }) => [
provider,
css`
background: ${colors.lightest};
border-radius: 0 0 ${borderRadius}px ${borderRadius}px;
border: 1px solid ${colors.grayBorderDark};
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
margin-top: -1px;
border-top-color: ${colors.grayBorderLight};
min-width: 100%;
width: auto;
border-radius: 0 0 ${borderRadius}px ${borderRadius}px;
padding-top: 0;
padding-bottom: 0;
`,
],
option: (
provider,
{
isDisabled,
isFocused,
isSelected,
theme: {
colors,
spacing: { lineHeight, fontSize },
weights,
},
},
) => {
let color = colors.textDefault;
let backgroundColor = colors.lightest;
if (isFocused) {
backgroundColor = colors.grayBgDarker;
} else if (isDisabled) {
color = '#ccc';
}
return [
provider,
css`
cursor: pointer;
line-height: ${lineHeight}px;
font-size: ${fontSize}px;
background-color: ${backgroundColor};
color: ${color};
font-weight: ${isSelected ? weights.bold : weights.normal};
white-space: nowrap;
&:hover:active {
background-color: ${colors.grayBg};
}
`,
];
},
valueContainer: (
provider,
{
isMulti,
hasValue,
theme: {
spacing: { baseUnit },
},
},
) => [
provider,
css`
padding-left: ${isMulti && hasValue ? 1 : baseUnit * 3}px;
`,
],
multiValueLabel: (
provider,
{
theme: {
spacing: { baseUnit },
},
},
) => ({
...provider,
paddingLeft: baseUnit * 1.2,
paddingRight: baseUnit * 1.2,
}),
input: (provider, { selectProps }) => [
provider,
css`
margin-left: 0;
vertical-align: middle;
${selectProps?.isMulti && selectProps?.value?.length
? 'padding: 0 6px; width: 100%'
: 'padding: 0; flex: 1 1 auto;'};
`,
],
menuPortal: base => ({
...base,
zIndex: 1030, // must be same or higher of antd popover
}),
};
const INPUT_TAG_BASE_STYLES = {
background: 'none',
border: 'none',
outline: 'none',
padding: 0,
};
export type SelectComponentsType = Omit<
SelectComponentsConfig<any>,
'Input'
> & {
Input: ComponentType<InputProps>;
};
// react-select is missing selectProps from their props type
// so overwriting it here to avoid errors
export type InputProps = ReactSelectInputProps & {
placeholder?: ReactNode;
selectProps: SelectProps;
autoComplete?: string;
onPaste?: SupersetStyledSelectProps<OptionType>['onPaste'];
inputStyle?: object;
};
const { ClearIndicator, DropdownIndicator, Option, Input, SelectContainer } =
defaultComponents as Required<DeepNonNullable<SelectComponentsType>>;
export const DEFAULT_COMPONENTS: SelectComponentsType = {
SelectContainer: ({ children, ...props }) => {
const {
selectProps: { assistiveText },
} = props;
return (
<div>
<SelectContainer {...props}>{children}</SelectContainer>
{assistiveText && (
<span
css={(theme: SupersetTheme) => ({
marginLeft: 3,
fontSize: theme.typography.sizes.s,
color: theme.colors.grayscale.light1,
})}
>
{assistiveText}
</span>
)}
</div>
);
},
Option: ({ children, innerProps, data, ...props }) => (
<Option
{...props}
data={data}
css={data?.style ? data.style : null}
innerProps={innerProps}
>
{children}
</Option>
),
ClearIndicator: props => (
<ClearIndicator {...props}>
<i className="fa">×</i>
</ClearIndicator>
),
DropdownIndicator: props => (
<DropdownIndicator {...props}>
<i
className={`fa fa-caret-${
props.selectProps.menuIsOpen ? 'up' : 'down'
}`}
/>
</DropdownIndicator>
),
Input: (props: InputProps) => {
const { getStyles } = props;
return (
<Input
{...props}
css={getStyles('input', props)}
autoComplete="chrome-off"
inputStyle={INPUT_TAG_BASE_STYLES}
/>
);
},
};
export const VALUE_LABELED_STYLES: PartialStylesConfig = {
valueContainer: (
provider,
{
getValue,
theme: {
spacing: { baseUnit },
},
isMulti,
},
) => ({
...provider,
paddingLeft: getValue().length > 0 ? 1 : baseUnit * 3,
overflow: isMulti && getValue().length > 0 ? 'visible' : 'hidden',
}),
// render single value as is they are multi-value
singleValue: (provider, props) => {
const { getStyles } = props;
return {
...getStyles('multiValue', props),
'.metric-option': getStyles('multiValueLabel', props),
};
},
};

View File

@ -1,57 +0,0 @@
/**
* 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 {
OptionTypeBase,
ValueType,
OptionsType,
GroupedOptionsType,
} from 'react-select';
/**
* Find Option value that matches a possibly string value.
*
* Translate possible string values to `OptionType` objects, fallback to value
* itself if cannot be found in the options list.
*
* Always returns an array.
*/
export function findValue<OptionType extends OptionTypeBase>(
value: ValueType<OptionType> | string,
options: GroupedOptionsType<OptionType> | OptionsType<OptionType> = [],
valueKey = 'value',
): OptionType[] {
if (value === null || value === undefined || value === '') {
return [];
}
const isGroup = Array.isArray(options[0]?.options);
const flatOptions = isGroup
? (options as GroupedOptionsType<OptionType>).flatMap(x => x.options || [])
: (options as OptionsType<OptionType>);
const find = (val: OptionType) => {
const realVal = value?.hasOwnProperty(valueKey) ? val[valueKey] : val;
return (
flatOptions.find(x => x === realVal || x[valueKey] === realVal) || val
);
};
// If value is a single string, must return an Array so `cleanValue` won't be
// empty: https://github.com/JedWatson/react-select/blob/32ad5c040bdd96cd1ca71010c2558842d684629c/packages/react-select/src/utils.js#L64
return (Array.isArray(value) ? value : [value]).map(find);
}

View File

@ -35,7 +35,6 @@ import {
import rison from 'rison';
import { isEqual } from 'lodash';
import { PartialStylesConfig } from 'src/components/DeprecatedSelect';
import {
FetchDataConfig,
Filter,
@ -381,21 +380,3 @@ export function useListViewState({
query,
};
}
export const filterSelectStyles: PartialStylesConfig = {
container: (provider, { getValue }) => ({
...provider,
// dynamic width based on label string length
minWidth: `${Math.min(
12,
Math.max(5, 3 + getValue()[0].label.length / 2),
)}em`,
}),
control: provider => ({
...provider,
borderWidth: 0,
boxShadow: 'none',
cursor: 'pointer',
backgroundColor: 'transparent',
}),
};

View File

@ -90,8 +90,6 @@ const getQueryCacheKey = (value: string, page: number, pageSize: number) =>
/**
* This component is a customized version of the Antdesign 4.X Select component
* https://ant.design/components/select/.
* The aim of the component was to combine all the instances of select components throughout the
* project under one and to remove the react-select component entirely.
* This Select component provides an API that is tested against all the different use cases of Superset.
* It limits and overrides the existing Antdesign API in order to keep their usage to the minimum
* and to enforce simplification and standardization.

View File

@ -73,8 +73,6 @@ import { customTagRender } from './CustomTag';
/**
* This component is a customized version of the Antdesign 4.X Select component
* https://ant.design/components/select/.
* The aim of the component was to combine all the instances of select components throughout the
* project under one and to remove the react-select component entirely.
* This Select component provides an API that is tested against all the different use cases of Superset.
* It limits and overrides the existing Antdesign API in order to keep their usage to the minimum
* and to enforce simplification and standardization.

View File

@ -22,26 +22,6 @@ function isValidFilter(getState, chartId) {
return getState().dashboardState.sliceIds.includes(chartId);
}
export const ADD_FILTER = 'ADD_FILTER';
export function addFilter(chartId, component, form_data) {
return (dispatch, getState) => {
if (isValidFilter(getState, chartId)) {
return dispatch({ type: ADD_FILTER, chartId, component, form_data });
}
return getState().dashboardFilters;
};
}
export const REMOVE_FILTER = 'REMOVE_FILTER';
export function removeFilter(chartId) {
return (dispatch, getState) => {
if (isValidFilter(getState, chartId)) {
return dispatch({ type: REMOVE_FILTER, chartId });
}
return getState().dashboardFilters;
};
}
export const CHANGE_FILTER = 'CHANGE_FILTER';
export function changeFilter(chartId, newSelectedValues, merge) {
return (dispatch, getState) => {

View File

@ -61,11 +61,7 @@ import {
SAVE_CHART_CONFIG_COMPLETE,
} from './dashboardInfo';
import { fetchDatasourceMetadata } from './datasources';
import {
addFilter,
removeFilter,
updateDirectPathToFilter,
} from './dashboardFilters';
import { updateDirectPathToFilter } from './dashboardFilters';
import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters';
import getOverwriteItems from '../util/getOverwriteItems';
@ -554,7 +550,7 @@ export function showBuilderPane() {
return { type: SHOW_BUILDER_PANE };
}
export function addSliceToDashboard(id, component) {
export function addSliceToDashboard(id) {
return (dispatch, getState) => {
const { sliceEntities } = getState();
const selectedSlice = sliceEntities.slices[id];
@ -580,21 +576,12 @@ export function addSliceToDashboard(id, component) {
dispatch(fetchDatasourceMetadata(form_data.datasource)),
]).then(() => {
dispatch(addSlice(selectedSlice));
if (selectedSlice && selectedSlice.viz_type === 'filter_box') {
dispatch(addFilter(id, component, selectedSlice.form_data));
}
});
};
}
export function removeSliceFromDashboard(id) {
return (dispatch, getState) => {
const sliceEntity = getState().sliceEntities.slices[id];
if (sliceEntity && sliceEntity.viz_type === 'filter_box') {
dispatch(removeFilter(id));
}
return dispatch => {
dispatch(removeSlice(id));
dispatch(removeChart(id));
getSharedLabelColor().removeSlice(id);

View File

@ -21,12 +21,10 @@ import { SupersetClient } from '@superset-ui/core';
import { waitFor } from '@testing-library/react';
import {
removeSliceFromDashboard,
SAVE_DASHBOARD_STARTED,
saveDashboardRequest,
SET_OVERRIDE_CONFIRM,
} from 'src/dashboard/actions/dashboardState';
import { REMOVE_FILTER } from 'src/dashboard/actions/dashboardFilters';
import * as uiCore from '@superset-ui/core';
import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout';
import {
@ -193,14 +191,4 @@ describe('dashboardState actions', () => {
});
});
});
it('should dispatch removeFilter if a removed slice is a filter_box', () => {
const { getState, dispatch } = setup(mockState);
const thunk = removeSliceFromDashboard(filterId);
thunk(dispatch, getState);
const removeFilter = dispatch.getCall(0).args[0];
removeFilter(dispatch, getState);
expect(dispatch.getCall(3).args[0].type).toBe(REMOVE_FILTER);
});
});

View File

@ -32,10 +32,6 @@ import {
getCrossFiltersConfiguration,
isCrossFiltersEnabled,
} from 'src/dashboard/util/crossFilters';
import {
DASHBOARD_FILTER_SCOPE_GLOBAL,
dashboardFilter,
} from 'src/dashboard/reducers/dashboardFilters';
import {
DASHBOARD_HEADER_ID,
GRID_DEFAULT_CHART_WIDTH,
@ -49,10 +45,8 @@ import {
} from 'src/dashboard/util/componentTypes';
import findFirstParentContainerId from 'src/dashboard/util/findFirstParentContainer';
import getEmptyLayout from 'src/dashboard/util/getEmptyLayout';
import getFilterConfigsFromFormdata from 'src/dashboard/util/getFilterConfigsFromFormdata';
import getLocationHash from 'src/dashboard/util/getLocationHash';
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { TIME_RANGE } from 'src/visualizations/FilterBox/FilterBox';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
@ -72,20 +66,10 @@ export const hydrateDashboard =
const reservedUrlParams = extractUrlParams('reserved');
const editMode = reservedUrlParams.edit === 'true';
let preselectFilters = {};
charts.forEach(chart => {
// eslint-disable-next-line no-param-reassign
chart.slice_id = chart.form_data.slice_id;
});
try {
// allow request parameter overwrite dashboard metadata
preselectFilters =
getUrlParam(URL_PARAMS.preselectFilters) ||
JSON.parse(metadata.default_filters);
} catch (e) {
//
}
if (metadata?.shared_label_colors) {
updateColorSchema(metadata, metadata?.shared_label_colors);
@ -117,8 +101,6 @@ export const hydrateDashboard =
let newSlicesContainer;
let newSlicesContainerWidth = 0;
const filterScopes = metadata?.filter_scopes || {};
const chartQueries = {};
const dashboardFilters = {};
const slices = {};
@ -189,57 +171,6 @@ export const hydrateDashboard =
newSlicesContainerWidth += GRID_DEFAULT_CHART_WIDTH;
}
// build DashboardFilters for interactive filter features
if (slice.form_data.viz_type === 'filter_box') {
const configs = getFilterConfigsFromFormdata(slice.form_data);
let { columns } = configs;
const { labels } = configs;
if (preselectFilters[key]) {
Object.keys(columns).forEach(col => {
if (preselectFilters[key][col]) {
columns = {
...columns,
[col]: preselectFilters[key][col],
};
}
});
}
const scopesByChartId = Object.keys(columns).reduce((map, column) => {
const scopeSettings = {
...filterScopes[key],
};
const { scope, immune } = {
...DASHBOARD_FILTER_SCOPE_GLOBAL,
...scopeSettings[column],
};
return {
...map,
[column]: {
scope,
immune,
},
};
}, {});
const componentId = chartIdToLayoutId[key];
const directPathToFilter = (layout[componentId].parents || []).slice();
directPathToFilter.push(componentId);
dashboardFilters[key] = {
...dashboardFilter,
chartId: key,
componentId,
datasourceId: slice.form_data.datasource,
filterName: slice.slice_name,
directPathToFilter,
columns,
labels,
scopes: scopesByChartId,
isDateFilter: Object.keys(columns).includes(TIME_RANGE),
};
}
// sync layout names with current slice names in case a slice was edited
// in explore since the layout was updated. name updates go through layout for undo/redo
// functionality and python updates slice names based on layout upon dashboard save

View File

@ -17,13 +17,7 @@
* under the License.
*/
import rison from 'rison';
import {
DatasourceType,
isFeatureEnabled,
FeatureFlag,
SupersetClient,
t,
} from '@superset-ui/core';
import { DatasourceType, SupersetClient, t } from '@superset-ui/core';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { Dispatch } from 'redux';
@ -114,14 +108,6 @@ export function fetchSlices(
? [{ col: 'slice_name', opr: 'chart_all_text', value: filter_value }]
: [];
if (isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS)) {
filters.push({
col: 'viz_type',
opr: 'neq',
value: 'filter_box',
});
}
if (userId) {
filters.push({ col: 'owners', opr: 'rel_m_m', value: userId });
}

View File

@ -47,12 +47,6 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({
jest.mock('src/components/ResizableSidebar/useStoredSidebarWidth');
// mock following dependant components to fix the prop warnings
jest.mock('src/components/DeprecatedSelect/WindowedSelect', () => () => (
<div data-test="mock-windowed-select" />
));
jest.mock('src/components/DeprecatedSelect', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-select" />
));

View File

@ -1,95 +0,0 @@
/**
* 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, { FunctionComponent } from 'react';
import { styled, t } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import Button from 'src/components/Button';
const StyledFilterBoxMigrationModal = styled(Modal)`
.modal-content {
height: 900px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.modal-header {
flex: 0 1 auto;
}
.modal-body {
flex: 1 1 auto;
overflow: auto;
}
.modal-footer {
flex: 0 1 auto;
}
.ant-modal-body {
overflow: auto;
}
`;
interface FilterBoxMigrationModalProps {
onHide: () => void;
onClickReview: () => void;
onClickSnooze: () => void;
show: boolean;
hideFooter: boolean;
}
const FilterBoxMigrationModal: FunctionComponent<FilterBoxMigrationModalProps> =
({ onClickReview, onClickSnooze, onHide, show, hideFooter = false }) => (
<StyledFilterBoxMigrationModal
show={show}
onHide={onHide}
title={t('Ready to review filters in this dashboard?')}
hideFooter={hideFooter}
footer={
<>
<Button buttonSize="small" onClick={onClickSnooze}>
{t('Remind me in 24 hours')}
</Button>
<Button buttonSize="small" onClick={onHide}>
{t('Cancel')}
</Button>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={onClickReview}
>
{t('Start Review')}
</Button>
</>
}
responsive
>
<div>
{t(
'filter_box will be deprecated ' +
'in a future version of Superset. ' +
'Please replace filter_box by dashboard filter components.',
)}
</div>
</StyledFilterBoxMigrationModal>
);
export default FilterBoxMigrationModal;

View File

@ -183,12 +183,6 @@ test('Should "export to Excel"', async () => {
expect(props.exportXLSX).toBeCalledWith(371);
});
test('Should not show "Download" if slice is filter box', () => {
const props = createProps('filter_box');
renderWrapper(props);
expect(screen.queryByText('Download')).not.toBeInTheDocument();
});
test('Export full CSV is under featureflag', async () => {
// @ts-ignore
global.featureFlags = {

View File

@ -489,7 +489,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
</Menu.SubMenu>
)}
{props.slice.viz_type !== 'filter_box' && props.supersetCanCSV && (
{props.supersetCanCSV && (
<Menu.SubMenu title={t('Download')}>
<Menu.Item
key={MENU_KEYS.EXPORT_CSV}
@ -504,8 +504,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
{t('Export to Excel')}
</Menu.Item>
{props.slice.viz_type !== 'filter_box' &&
isFeatureEnabled(FeatureFlag.ALLOW_FULL_CSV_EXPORT) &&
{isFeatureEnabled(FeatureFlag.ALLOW_FULL_CSV_EXPORT) &&
props.supersetCanCSV &&
isTable && (
<>

View File

@ -30,7 +30,7 @@ import getKeyForFilterScopeTree from 'src/dashboard/util/getKeyForFilterScopeTre
import getSelectedChartIdForFilterScopeTree from 'src/dashboard/util/getSelectedChartIdForFilterScopeTree';
import getFilterScopeFromNodesTree from 'src/dashboard/util/getFilterScopeFromNodesTree';
import getRevertedFilterScope from 'src/dashboard/util/getRevertedFilterScope';
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import {
getChartIdAndColumnFromFilterKey,
getDashboardFilterKey,
@ -277,12 +277,6 @@ const ScopeSelector = styled.div`
}
.multi-edit-mode {
&.filter-scope-pane {
.rct-node.rct-node-leaf .filter-scope-type.filter_box {
display: none;
}
}
.filter-field-item {
padding: 0 ${theme.gridUnit * 4}px 0 ${theme.gridUnit * 12}px;
margin-left: ${theme.gridUnit * -12}px;
@ -367,9 +361,8 @@ export default class FilterScopeSelector extends React.PureComponent {
selectedChartId: filterId,
});
const expanded = getFilterScopeParentNodes(nodes, 1);
// force display filter_box chart as unchecked, but show checkbox as disabled
const chartIdsInFilterScope = (
getChartIdsInFilterBoxScope({
getChartIdsInFilterScope({
filterScope: dashboardFilters[filterId].scopes[columnName],
}) || []
).filter(id => id !== filterId);

View File

@ -40,9 +40,6 @@ import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart';
import { slicePropShape, chartPropShape } from '../../util/propShapes';
import { isFilterBox } from '../../util/activeDashboardFilters';
import getFilterValuesByFilterId from '../../util/getFilterValuesByFilterId';
const propTypes = {
id: PropTypes.number.isRequired,
componentId: PropTypes.string.isRequired,
@ -100,7 +97,6 @@ const SHOULD_UPDATE_ON_PROP_CHANGES = Object.keys(propTypes).filter(
prop =>
prop !== 'width' && prop !== 'height' && prop !== 'isComponentVisible',
);
const OVERFLOWABLE_VIZ_TYPES = new Set(['filter_box']);
const DEFAULT_HEADER_HEIGHT = 22;
const ChartWrapper = styled.div`
@ -421,13 +417,7 @@ class Chart extends React.Component {
const cachedDttm =
// eslint-disable-next-line camelcase
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
const isOverflowable = OVERFLOWABLE_VIZ_TYPES.has(slice.viz_type);
const initialValues = isFilterBox(id)
? getFilterValuesByFilterId({
activeFilters: filters,
filterId: id,
})
: {};
const initialValues = {};
return (
<SliceContainer
@ -489,12 +479,7 @@ class Chart extends React.Component {
/>
)}
<ChartWrapper
className={cx(
'dashboard-chart',
isOverflowable && 'dashboard-chart--overflowable',
)}
>
<ChartWrapper className={cx('dashboard-chart')}>
{isLoading && (
<ChartOverlay
style={{

View File

@ -17855,63 +17855,6 @@ describe('Ensure buildTree does not throw runtime errors when encountering an in
},
},
},
'127': {
id: 127,
chartAlert: null,
chartStatus: 'loading',
chartStackTrace: null,
chartUpdateEndTime: null,
chartUpdateStartTime: 0,
latestQueryFormData: {},
sliceFormData: null,
queryController: null,
queriesResponse: null,
triggerQuery: true,
lastRendered: 0,
form_data: {
datasource: '20__table',
viz_type: 'filter_box',
slice_id: 127,
url_params: {
preselect_filters:
'{"1389": {"platform": ["PS", "PS2", "PS3", "PS4"], "genre": null, "__time_range": "No filter"}}',
},
granularity_sqla: 'Year',
time_range: 'No filter',
filter_configs: [
{
asc: true,
clearable: true,
column: 'platform',
key: 's3ItH9vhG',
label: 'Platform',
multiple: true,
searchAllOptions: false,
},
{
asc: true,
clearable: true,
column: 'genre',
key: '202hDeMsG',
label: 'Genre',
multiple: true,
searchAllOptions: false,
},
{
asc: true,
clearable: true,
column: 'publisher',
key: '5Os6jsJFK',
label: 'Publisher',
multiple: true,
searchAllOptions: false,
},
],
date_filter: true,
adhoc_filters: [],
queryFields: {},
},
},
'131': {
id: 131,
chartAlert: null,

View File

@ -26,9 +26,8 @@ import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { logging, NativeFilterScope, t } from '@superset-ui/core';
import { BuildTreeLeafTitle, TreeItem } from './types';
export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) =>
(type === TAB_TYPE || type === CHART_TYPE || type === DASHBOARD_ROOT_TYPE) &&
(!charts || charts[meta?.chartId]?.form_data?.viz_type !== 'filter_box');
export const isShowTypeInTree = ({ type }: LayoutItem) =>
type === TAB_TYPE || type === CHART_TYPE || type === DASHBOARD_ROOT_TYPE;
export const getNodeTitle = (node: LayoutItem) =>
node?.meta?.sliceNameOverride ??
@ -51,7 +50,7 @@ export const buildTree = (
if (
node &&
treeItem &&
isShowTypeInTree(node, charts) &&
isShowTypeInTree(node) &&
node.type !== DASHBOARD_ROOT_TYPE &&
validNodes?.includes?.(node.id)
) {

View File

@ -31,7 +31,7 @@ import {
QueryFormColumn,
} from '@superset-ui/core';
import { TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import {
ChartConfiguration,
DashboardLayout,
@ -130,7 +130,7 @@ const selectIndicatorsForChartFromFilter = (
return Object.keys(filter.columns)
.filter(column =>
getChartIdsInFilterBoxScope({
getChartIdsInFilterScope({
filterScope: filter.scopes[column],
}).includes(chartId),
)

View File

@ -54,11 +54,10 @@ function mapStateToProps(state: RootState) {
dashboardInfo,
dashboardState,
datasources,
// filters prop: a map structure for all the active filter_box's values and scope in this dashboard,
// filters prop: a map structure for all the active filter's values and scope in this dashboard,
// for each filter field. map key is [chartId_column]
// When dashboard is first loaded into browser,
// its value is from preselect_filters that dashboard owner saved in dashboard's meta data
// When user start interacting with dashboard, it will be user picked values from all filter_box
activeFilters: {
...getActiveFilters(),
...getAllActiveFilters({

View File

@ -18,17 +18,13 @@
*/
/* eslint-disable camelcase */
import {
ADD_FILTER,
REMOVE_FILTER,
CHANGE_FILTER,
UPDATE_DIRECT_PATH_TO_FILTER,
UPDATE_LAYOUT_COMPONENTS,
UPDATE_DASHBOARD_FILTERS_SCOPE,
} from '../actions/dashboardFilters';
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
import { TIME_RANGE } from '../../visualizations/FilterBox/FilterBox';
import { DASHBOARD_ROOT_ID } from '../util/constants';
import getFilterConfigsFromFormdata from '../util/getFilterConfigsFromFormdata';
import { buildActiveFilters } from '../util/activeDashboardFilters';
import { getChartIdAndColumnFromFilterKey } from '../util/getDashboardFilterKey';
@ -50,41 +46,10 @@ export const dashboardFilter = {
scopes: {},
};
const CHANGE_FILTER_VALUE_ACTIONS = [ADD_FILTER, REMOVE_FILTER, CHANGE_FILTER];
const CHANGE_FILTER_VALUE_ACTIONS = [CHANGE_FILTER];
export default function dashboardFiltersReducer(dashboardFilters = {}, action) {
const actionHandlers = {
[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)
: [];
const newFilter = {
...dashboardFilter,
chartId,
componentId: component.id,
datasourceId: form_data.datasource,
filterName: component.meta.sliceName,
directPathToFilter,
columns,
labels,
scopes,
isInstantFilter: !!form_data.instant_filtering,
isDateFilter: Object.keys(columns).includes(TIME_RANGE),
};
return newFilter;
},
[CHANGE_FILTER](state) {
const { newSelectedValues, merge } = action;
const updatedColumns = Object.keys(newSelectedValues).reduce(
@ -155,13 +120,6 @@ export default function dashboardFiltersReducer(dashboardFilters = {}, action) {
buildActiveFilters({ dashboardFilters: updatedFilters });
return updatedFilters;
}
if (action.type === REMOVE_FILTER) {
const { chartId } = action;
const { [chartId]: deletedFilter, ...updatedFilters } = dashboardFilters;
buildActiveFilters({ dashboardFilters: updatedFilters });
return updatedFilters;
}
if (action.type === HYDRATE_DASHBOARD) {
return action.data.dashboardFilters;
}

View File

@ -18,8 +18,6 @@
*/
/* eslint-disable camelcase */
import {
ADD_FILTER,
REMOVE_FILTER,
CHANGE_FILTER,
UPDATE_DASHBOARD_FILTERS_SCOPE,
} from 'src/dashboard/actions/dashboardFilters';
@ -27,10 +25,7 @@ import dashboardFiltersReducer, {
DASHBOARD_FILTER_SCOPE_GLOBAL,
} from 'src/dashboard/reducers/dashboardFilters';
import * as activeDashboardFilters from 'src/dashboard/util/activeDashboardFilters';
import {
emptyFilters,
dashboardFilters,
} from 'spec/fixtures/mockDashboardFilters';
import { dashboardFilters } from 'spec/fixtures/mockDashboardFilters';
import {
sliceEntitiesForDashboard,
filterId,
@ -44,35 +39,6 @@ describe('dashboardFilters reducer', () => {
const directPathToFilter = (component.parents || []).slice();
directPathToFilter.push(component.id);
it('should add a new filter if it does not exist', () => {
expect(
dashboardFiltersReducer(emptyFilters, {
type: ADD_FILTER,
chartId: filterId,
component,
form_data,
}),
).toEqual({
[filterId]: {
chartId: filterId,
componentId: component.id,
directPathToFilter,
filterName: component.meta.sliceName,
isDateFilter: false,
isInstantFilter: !!form_data.instant_filtering,
columns: {
[column]: undefined,
},
labels: {
[column]: column,
},
scopes: {
[column]: DASHBOARD_FILTER_SCOPE_GLOBAL,
},
},
});
});
it('should overwrite a filter if merge is false', () => {
expect(
dashboardFiltersReducer(dashboardFilters, {
@ -139,15 +105,6 @@ describe('dashboardFilters reducer', () => {
});
});
it('should remove the filter if values are empty', () => {
expect(
dashboardFiltersReducer(dashboardFilters, {
type: REMOVE_FILTER,
chartId: filterId,
}),
).toEqual({});
});
it('should buildActiveFilters on UPDATE_DASHBOARD_FILTERS_SCOPE', () => {
const regionScope = {
scope: ['TAB-1'],

View File

@ -25,7 +25,6 @@ import {
import { CHART_TYPE } from './componentTypes';
import { DASHBOARD_FILTER_SCOPE_GLOBAL } from '../reducers/dashboardFilters';
let allFilterBoxChartIds = [];
let activeFilters = {};
let appliedFilterValuesByChart = {};
let allComponents = {};
@ -35,13 +34,6 @@ export function getActiveFilters() {
return activeFilters;
}
// currently filter_box is a chart,
// when selecting filter scopes, they have to be out pulled out in a few places.
// after we make filter_box a dashboard build-in component, will not need this check anymore.
export function isFilterBox(chartId) {
return allFilterBoxChartIds.includes(chartId);
}
// this function is to find all filter values applied to a chart,
// it goes through all active filters and their scopes.
// return: { [column]: array of selected values }
@ -61,10 +53,10 @@ export function getAppliedFilterValues(chartId, filters) {
return appliedFilterValuesByChart[chartId];
}
// Legacy - getChartIdsInFilterBoxScope is used only by
// components and functions related to filter box
// Please use src/dashboard/util/getChartIdsInFilterScope instead
export function getChartIdsInFilterBoxScope({ filterScope }) {
/**
* @deprecated Please use src/dashboard/util/getChartIdsInFilterScope instead
*/
export function getChartIdsInFilterScope({ filterScope }) {
function traverse(chartIds = [], component = {}, immuneChartIds = []) {
if (!component) {
return;
@ -99,10 +91,6 @@ export function getChartIdsInFilterBoxScope({ filterScope }) {
// values: array of selected values
// scope: array of chartIds that applicable to the filter field.
export function buildActiveFilters({ dashboardFilters = {}, components = {} }) {
allFilterBoxChartIds = Object.values(dashboardFilters).map(
filter => filter.chartId,
);
// clear cache
if (!isEmpty(components)) {
allComponents = components;
@ -119,7 +107,7 @@ export function buildActiveFilters({ dashboardFilters = {}, components = {} }) {
: columns[column] !== undefined
) {
// remove filter itself
const scope = getChartIdsInFilterBoxScope({
const scope = getChartIdsInFilterScope({
filterScope: scopes[column],
}).filter(id => chartId !== id);

View File

@ -51,12 +51,7 @@ function traverse({
return {
...chartNode,
children: filterFields.map(filterField => ({
value: `${currentNode.meta.chartId}:${filterField}`,
label: `${chartNode.label}`,
type: 'filter_box',
showCheckbox: false,
})),
children: [],
};
}

View File

@ -1,38 +0,0 @@
/**
* 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';
// input: { [id_column1]: values, [id_column2]: values }
// output: { column1: values, column2: values }
export default function getFilterValuesByFilterId({
activeFilters = {},
filterId,
}) {
return Object.entries(activeFilters).reduce((map, entry) => {
const [filterKey, { values }] = entry;
const { chartId, column } = getChartIdAndColumnFromFilterKey(filterKey);
if (chartId === filterId) {
return {
...map,
[column]: values,
};
}
return map;
}, {});
}

View File

@ -16,8 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getChartIdAndColumnFromFilterKey } from './getDashboardFilterKey';
interface FilterScopeMap {
[key: string]: number[];
}
@ -44,19 +42,14 @@ export default function getRevertedFilterScope({
{},
);
return filterFields.reduce<FilterScopeMap>((map, filterField) => {
const { chartId } = getChartIdAndColumnFromFilterKey(filterField);
// force display filter_box chart as unchecked, but show checkbox as disabled
const updatedCheckedIds = (
checkedChartIdsByFilterField[filterField] || []
).filter(id => id !== chartId);
return {
return filterFields.reduce<FilterScopeMap>(
(map, filterField) => ({
...map,
[filterField]: {
...filterScopeMap[filterField],
checked: updatedCheckedIds,
checked: checkedChartIdsByFilterField[filterField] || [],
},
};
}, {});
}),
{},
);
}

View File

@ -22,8 +22,6 @@ 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,

View File

@ -24,14 +24,7 @@ export default function childChartsDidLoad({ chartQueries, layout, id }) {
let minQueryStartTime = Infinity;
const didLoad = chartIds.every(chartId => {
const query = chartQueries[chartId] || {};
// filterbox's don't re-render, don't use stale update time
if (query.form_data && query.form_data.viz_type !== 'filter_box') {
minQueryStartTime = Math.min(
query.chartUpdateStartTime,
minQueryStartTime,
);
}
minQueryStartTime = Math.min(query.chartUpdateStartTime, minQueryStartTime);
return ['stopped', 'failed', 'rendered'].indexOf(query.chartStatus) > -1;
});

View File

@ -24,9 +24,6 @@ import mockState from 'spec/fixtures/mockState';
import reducerIndex from 'spec/helpers/reducerIndex';
import { screen, render } from 'spec/helpers/testing-library';
import { initialState } from 'src/SqlLab/fixtures';
import { dashboardFilters } from 'spec/fixtures/mockDashboardFilters';
import { dashboardWithFilter } from 'spec/fixtures/mockDashboardLayout';
import { buildActiveFilters } from './activeDashboardFilters';
import useFilterFocusHighlightStyles from './useFilterFocusHighlightStyles';
const TestComponent = ({ chartId }: { chartId: number }) => {
@ -185,64 +182,4 @@ describe('useFilterFocusHighlightStyles', () => {
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
it('should return unfocused styles if chart is not inside filter box scope', async () => {
buildActiveFilters({
dashboardFilters,
components: dashboardWithFilter,
});
const chartId = 18;
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId,
column: 'test',
},
},
dashboardFilters: {
[chartId]: {
scopes: {
column: {},
},
},
},
});
renderWrapper(20, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(0.3);
});
it('should return focused styles if chart is inside filter box scope', async () => {
buildActiveFilters({
dashboardFilters,
components: dashboardWithFilter,
});
const chartId = 18;
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId,
column: 'test',
},
},
dashboardFilters: {
[chartId]: {
scopes: {
column: {},
},
},
},
});
renderWrapper(chartId, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
});

View File

@ -19,7 +19,7 @@
import { useTheme } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { getChartIdsInFilterBoxScope } from 'src/dashboard/util/activeDashboardFilters';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { DashboardState, RootState } from 'src/dashboard/types';
const selectFocusedFilterScope = (
@ -78,7 +78,7 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
}
} else if (
chartId === focusedFilterScope?.chartId ||
getChartIdsInFilterBoxScope({
getChartIdsInFilterScope({
filterScope: focusedFilterScope?.scope,
}).includes(chartId)
) {

View File

@ -430,31 +430,27 @@ const ExploreChartPanel = ({
className="panel panel-default chart-container"
showSplite={showSplite}
>
{vizType === 'filter_box' ? (
panelBody
) : (
<Split
sizes={splitSizes}
minSize={MIN_SIZES}
direction="vertical"
gutterSize={gutterHeight}
onDragEnd={onDragEnd}
elementStyle={elementStyle}
expandToMin
>
{panelBody}
<DataTablesPane
ownState={ownState}
queryFormData={queryFormData}
datasource={datasource}
queryForce={force}
onCollapseChange={onCollapseChange}
chartStatus={chart.chartStatus}
errorMessage={errorMessage}
actions={actions}
/>
</Split>
)}
<Split
sizes={splitSizes}
minSize={MIN_SIZES}
direction="vertical"
gutterSize={gutterHeight}
onDragEnd={onDragEnd}
elementStyle={elementStyle}
expandToMin
>
{panelBody}
<DataTablesPane
ownState={ownState}
queryFormData={queryFormData}
datasource={datasource}
queryForce={force}
onCollapseChange={onCollapseChange}
chartStatus={chart.chartStatus}
errorMessage={errorMessage}
actions={actions}
/>
</Split>
{showDatasetModal && (
<SaveDatasetModal
visible={showDatasetModal}

View File

@ -26,8 +26,6 @@ import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import {
css,
DatasourceType,
isFeatureEnabled,
FeatureFlag,
isDefined,
styled,
SupersetClient,
@ -70,7 +68,6 @@ type SaveModalState = {
action: SaveActionType;
isLoading: boolean;
saveStatus?: string | null;
vizType?: string;
dashboard?: { label: string; value: string | number };
};
@ -93,7 +90,6 @@ class SaveModal extends React.Component<SaveModalProps, SaveModalState> {
datasetName: props.datasource?.name,
action: this.canOverwriteSlice() ? 'overwrite' : 'saveas',
isLoading: false,
vizType: props.form_data?.viz_type,
dashboard: undefined,
};
this.onDashboardChange = this.onDashboardChange.bind(this);
@ -383,32 +379,27 @@ class SaveModal extends React.Component<SaveModalProps, SaveModalState> {
/>
</FormItem>
)}
{!(
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
this.state.vizType === 'filter_box'
) && (
<FormItem
label={t('Add to dashboard')}
data-test="save-chart-modal-select-dashboard-form"
>
<AsyncSelect
allowClear
allowNewOptions
ariaLabel={t('Select a dashboard')}
options={this.loadDashboards}
onChange={this.onDashboardChange}
value={this.state.dashboard}
placeholder={
<div>
<b>{t('Select')}</b>
{t(' a dashboard OR ')}
<b>{t('create')}</b>
{t(' a new one')}
</div>
}
/>
</FormItem>
)}
<FormItem
label={t('Add to dashboard')}
data-test="save-chart-modal-select-dashboard-form"
>
<AsyncSelect
allowClear
allowNewOptions
ariaLabel={t('Select a dashboard')}
options={this.loadDashboards}
onChange={this.onDashboardChange}
value={this.state.dashboard}
placeholder={
<div>
<b>{t('Select')}</b>
{t(' a dashboard OR ')}
<b>{t('create')}</b>
{t(' a new one')}
</div>
}
/>
</FormItem>
{info && <Alert type="info" message={info} closable={false} />}
{this.props.alert && (
<Alert
@ -455,9 +446,7 @@ class SaveModal extends React.Component<SaveModalProps, SaveModalState> {
!this.state.newSliceName ||
!this.state.dashboard ||
(this.props.datasource?.type !== DatasourceType.Table &&
!this.state.datasetName) ||
(isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
this.state.vizType === 'filter_box')
!this.state.datasetName)
}
onClick={() => this.saveOrOverwrite(true)}
>

View File

@ -1,108 +0,0 @@
/**
* 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.
*/
/* eslint-disable no-unused-expressions */
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import FilterBoxItemControl from 'src/explore/components/controls/FilterBoxItemControl';
import FormRow from 'src/components/FormRow';
import datasources from 'spec/fixtures/mockDatasource';
import ControlPopover from '../ControlPopover/ControlPopover';
const defaultProps = {
label: 'some label',
datasource: datasources['7__table'],
onChange: sinon.spy(),
};
describe('FilterBoxItemControl', () => {
let wrapper;
let inst;
const getWrapper = propOverrides => {
const props = { ...defaultProps, ...propOverrides };
return shallow(<FilterBoxItemControl {...props} />);
};
beforeEach(() => {
wrapper = getWrapper();
inst = wrapper.instance();
});
it('renders a Popover', () => {
expect(wrapper.find(ControlPopover)).toExist();
});
it('renderForms does the job', () => {
const popover = shallow(inst.renderForm());
expect(popover.find(FormRow)).toHaveLength(8);
expect(popover.find(FormRow).get(1).props.control.props.value).toEqual(
'some label',
);
});
it('convert type for single value filter_box', () => {
inst = getWrapper({
datasource: {
columns: [
{
column_name: 'SP_POP_TOTL',
description: null,
expression: null,
filterable: true,
groupby: true,
id: 312,
is_dttm: false,
type: 'FLOAT',
verbose_name: null,
},
],
metrics: [
{
d3format: null,
description: null,
expression: 'sum("SP_POP_TOTL")',
id: 3,
metric_name: 'sum__SP_POP_TOTL',
verbose_name: null,
warning_text: null,
},
],
},
}).instance();
inst.setState({
asc: true,
clearable: true,
column: 'SP_POP_TOTL',
defaultValue: 254454778,
metric: undefined,
multiple: false,
});
inst.setState = sinon.spy();
inst.onControlChange('defaultValue', '1');
expect(inst.setState.callCount).toBe(1);
expect(inst.setState.getCall(0).args[0]).toEqual({ defaultValue: 1 });
// user input is invalid for number type column
inst.onControlChange('defaultValue', 'abc');
expect(inst.setState.callCount).toBe(2);
expect(inst.setState.getCall(1).args[0]).toEqual({ defaultValue: null });
});
});

View File

@ -1,61 +0,0 @@
/**
* 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 { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import FilterBoxItemControl from '.';
const createProps = () => ({
datasource: {
columns: [],
metrics: [],
},
asc: true,
clearable: true,
multiple: true,
column: 'developer_type',
label: 'Developer Type',
metric: undefined,
searchAllOptions: false,
defaultValue: undefined,
onChange: jest.fn(),
});
test('Should render', () => {
const props = createProps();
render(<FilterBoxItemControl {...props} />);
expect(screen.getByTestId('FilterBoxItemControl')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
test('Should open modal', () => {
const props = createProps();
render(<FilterBoxItemControl {...props} />);
userEvent.click(screen.getByRole('button'));
expect(screen.getByText('Filter configuration')).toBeInTheDocument();
expect(screen.getByText('Column')).toBeInTheDocument();
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.getByText('Default')).toBeInTheDocument();
expect(screen.getByText('Sort metric')).toBeInTheDocument();
expect(screen.getByText('Sort ascending')).toBeInTheDocument();
expect(screen.getByText('Allow multiple selections')).toBeInTheDocument();
expect(screen.getByText('Search all filter options')).toBeInTheDocument();
expect(screen.getByText('Required')).toBeInTheDocument();
});

View File

@ -1,295 +0,0 @@
/**
* 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 { t } from '@superset-ui/core';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import FormRow from 'src/components/FormRow';
import { Select } from 'src/components';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl';
import { FILTER_CONFIG_ATTRIBUTES } from 'src/explore/constants';
import ControlPopover from '../ControlPopover/ControlPopover';
const INTEGRAL_TYPES = new Set([
'TINYINT',
'SMALLINT',
'INT',
'INTEGER',
'BIGINT',
'LONG',
]);
const DECIMAL_TYPES = new Set([
'FLOAT',
'DOUBLE',
'REAL',
'NUMERIC',
'DECIMAL',
'MONEY',
]);
const propTypes = {
datasource: PropTypes.object.isRequired,
onChange: PropTypes.func,
asc: PropTypes.bool,
clearable: PropTypes.bool,
multiple: PropTypes.bool,
column: PropTypes.string,
label: PropTypes.string,
metric: PropTypes.string,
searchAllOptions: PropTypes.bool,
defaultValue: PropTypes.string,
};
const defaultProps = {
onChange: () => {},
asc: true,
clearable: true,
multiple: true,
searchAllOptions: false,
};
const STYLE_WIDTH = { width: 350 };
export default class FilterBoxItemControl extends React.Component {
constructor(props) {
super(props);
const {
column,
metric,
asc,
clearable,
multiple,
searchAllOptions,
label,
defaultValue,
} = props;
this.state = {
column,
metric,
label,
asc,
clearable,
multiple,
searchAllOptions,
defaultValue,
};
this.onChange = this.onChange.bind(this);
this.onControlChange = this.onControlChange.bind(this);
}
onChange() {
this.props.onChange(this.state);
}
onControlChange(attr, value) {
let typedValue = value;
const { column: selectedColumnName, multiple } = this.state;
if (value && !multiple && attr === FILTER_CONFIG_ATTRIBUTES.DEFAULT_VALUE) {
// if single value filter_box,
// convert input value string to the column's data type
const { datasource } = this.props;
const selectedColumn = datasource.columns.find(
col => col.column_name === selectedColumnName,
);
if (selectedColumn && selectedColumn.type) {
const type = selectedColumn.type.toUpperCase();
if (type === 'BOOLEAN') {
typedValue = value === 'true';
} else if (INTEGRAL_TYPES.has(type)) {
typedValue = Number.isNaN(Number(value)) ? null : parseInt(value, 10);
} else if (DECIMAL_TYPES.has(type)) {
typedValue = Number.isNaN(Number(value)) ? null : parseFloat(value);
}
}
}
this.setState({ [attr]: typedValue }, this.onChange);
}
setType() {}
textSummary() {
return this.state.column || 'N/A';
}
renderForm() {
return (
<div>
<FormRow
label={t('Column')}
control={
<Select
ariaLabel={t('Column')}
value={this.state.column}
name="column"
options={this.props.datasource.columns
.filter(col => col.column_name !== this.state.column)
.map(col => ({
value: col.column_name,
label: col.column_name,
}))
.concat([
{ value: this.state.column, label: this.state.column },
])}
onChange={v => this.onControlChange('column', v)}
/>
}
/>
<FormRow
label={t('Label')}
control={
<TextControl
value={this.state.label}
name="label"
onChange={v => this.onControlChange('label', v)}
/>
}
/>
<FormRow
label={t('Default')}
tooltip={t(
'(optional) default value for the filter, when using ' +
'the multiple option, you can use a semicolon-delimited list ' +
'of options.',
)}
control={
<TextControl
value={this.state.defaultValue}
name="defaultValue"
onChange={v =>
this.onControlChange(FILTER_CONFIG_ATTRIBUTES.DEFAULT_VALUE, v)
}
/>
}
/>
<FormRow
label={t('Sort metric')}
tooltip={t('Metric to sort the results by')}
control={
<Select
ariaLabel={t('Sort metric')}
value={this.state.metric}
name="column"
options={this.props.datasource.metrics
.filter(m => m.metric_name !== this.state.metric)
.map(m => ({
value: m.metric_name,
label: m.metric_name,
}))
.concat([
{ value: this.state.metric, label: this.state.metric },
])}
onChange={v => this.onControlChange('metric', v)}
/>
}
/>
<FormRow
label={t('Sort ascending')}
tooltip={t('Check for sorting ascending')}
isCheckbox
control={
<CheckboxControl
value={this.state.asc}
onChange={v => this.onControlChange('asc', v)}
/>
}
/>
<FormRow
label={t('Allow multiple selections')}
isCheckbox
tooltip={t(
'Multiple selections allowed, otherwise filter ' +
'is limited to a single value',
)}
control={
<CheckboxControl
value={this.state.multiple}
onChange={v =>
this.onControlChange(FILTER_CONFIG_ATTRIBUTES.MULTIPLE, v)
}
/>
}
/>
<FormRow
label={t('Search all filter options')}
tooltip={t(
'By default, each filter loads at most 1000 choices at the initial page load. ' +
'Check this box if you have more than 1000 filter values and want to enable dynamically ' +
'searching that loads filter values as users type (may add stress to your database).',
)}
isCheckbox
control={
<CheckboxControl
value={this.state.searchAllOptions}
onChange={v =>
this.onControlChange(
FILTER_CONFIG_ATTRIBUTES.SEARCH_ALL_OPTIONS,
v,
)
}
/>
}
/>
<FormRow
label={t('Required')}
tooltip={t('User must select a value for this filter')}
isCheckbox
control={
<CheckboxControl
value={!this.state.clearable}
onChange={v => this.onControlChange('clearable', !v)}
/>
}
/>
</div>
);
}
renderPopover() {
return (
<div id="ts-col-popo" style={STYLE_WIDTH}>
{this.renderForm()}
</div>
);
}
render() {
return (
<span data-test="FilterBoxItemControl">
{this.textSummary()}{' '}
<ControlPopover
trigger="click"
content={this.renderPopover()}
title={t('Filter configuration')}
>
<InfoTooltipWithTrigger
icon="edit"
className="text-primary"
label="edit-ts-column"
/>
</ControlPopover>
</span>
);
}
}
FilterBoxItemControl.propTypes = propTypes;
FilterBoxItemControl.defaultProps = defaultProps;

View File

@ -77,7 +77,6 @@ const DEFAULT_ORDER = [
'echarts_timeseries_scatter',
'pie',
'mixed_timeseries',
'filter_box',
'dist_bar',
'area',
'bar',

View File

@ -23,8 +23,6 @@ import {
getChartMetadataRegistry,
styled,
SupersetTheme,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { usePluginContext } from 'src/components/DynamicPlugins';
import Modal from 'src/components/Modal';
@ -48,13 +46,6 @@ const bootstrapData = getBootstrapData();
const denyList: string[] = bootstrapData.common.conf.VIZ_TYPE_DENYLIST || [];
const metadataRegistry = getChartMetadataRegistry();
if (
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
!denyList.includes('filter_box')
) {
denyList.push('filter_box');
}
export const VIZ_TYPE_CONTROL_TEST_ID = 'viz-type-control';
function VizSupportValidation({ vizType }: { vizType: string }) {

View File

@ -38,7 +38,6 @@ import ViewportControl from './ViewportControl';
import VizTypeControl from './VizTypeControl';
import MetricsControl from './MetricControl/MetricsControl';
import AdhocFilterControl from './FilterControl/AdhocFilterControl';
import FilterBoxItemControl from './FilterBoxItemControl';
import ConditionalFormattingControl from './ConditionalFormattingControl';
import ContourControl from './ContourControl';
import DndColumnSelectControl, {
@ -78,7 +77,6 @@ const controlMap = {
VizTypeControl,
MetricsControl,
AdhocFilterControl,
FilterBoxItemControl,
ConditionalFormattingControl,
XAxisSortControl,
ContourControl,

View File

@ -20,8 +20,6 @@ import React, { ReactNode } from 'react';
import rison from 'rison';
import querystring from 'query-string';
import {
isFeatureEnabled,
FeatureFlag,
isDefined,
JsonResponse,
styled,
@ -64,13 +62,6 @@ const ELEMENTS_EXCEPT_VIZ_GALLERY = ESTIMATED_NAV_HEIGHT + 250;
const bootstrapData = getBootstrapData();
const denyList: string[] = bootstrapData.common.conf.VIZ_TYPE_DENYLIST || [];
if (
isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) &&
!denyList.includes('filter_box')
) {
denyList.push('filter_box');
}
const StyledContainer = styled.div`
${({ theme }) => `
flex: 1 1 auto;

View File

@ -30,7 +30,6 @@ export enum LocalStorageKeys {
* TODO: Update all local storage keys to follow the new pattern. This is a breaking change,
* and therefore should be done in a major release.
*/
filter_box_transition_snoozed_at = 'filter_box_transition_snoozed_at',
db = 'db',
chart_split_sizes = 'chart_split_sizes',
controls_width = 'controls_width',
@ -59,7 +58,6 @@ export enum LocalStorageKeys {
}
export type LocalStorageValues = {
filter_box_transition_snoozed_at: Record<number, number>;
db: object | null;
chart_split_sizes: [number, number];
controls_width: number;

View File

@ -172,7 +172,7 @@ export function getDashboardPermalink({
*/
anchor?: string;
}) {
// only encode filter box state if non-empty
// only encode filter state if non-empty
return getPermalink(`/api/v1/dashboard/${dashboardId}/permalink`, {
urlParams: getDashboardUrlParams(),
dataMask,

View File

@ -1,480 +0,0 @@
/**
* 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 { debounce } from 'lodash';
import { max as d3Max } from 'd3-array';
import {
AsyncCreatableSelect,
CreatableSelect,
} from 'src/components/DeprecatedSelect';
import Button from 'src/components/Button';
import {
css,
styled,
t,
SupersetClient,
ensureIsArray,
withTheme,
} from '@superset-ui/core';
import { Global } from '@emotion/react';
import {
BOOL_FALSE_DISPLAY,
BOOL_TRUE_DISPLAY,
SLOW_DEBOUNCE,
} from 'src/constants';
import { FormLabel } from 'src/components/Form';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import ControlRow from 'src/explore/components/ControlRow';
import Control from 'src/explore/components/Control';
import { controls } from 'src/explore/controls';
import { getExploreUrl } from 'src/explore/exploreUtils';
import OnPasteSelect from 'src/components/DeprecatedSelect/OnPasteSelect';
import {
FILTER_CONFIG_ATTRIBUTES,
FILTER_OPTIONS_LIMIT,
TIME_FILTER_LABELS,
TIME_FILTER_MAP,
} from 'src/explore/constants';
// a shortcut to a map key, used by many components
export const TIME_RANGE = TIME_FILTER_MAP.time_range;
const propTypes = {
chartId: PropTypes.number.isRequired,
origSelectedValues: PropTypes.object,
datasource: PropTypes.object.isRequired,
instantFiltering: PropTypes.bool,
filtersFields: PropTypes.arrayOf(
PropTypes.shape({
field: PropTypes.string,
label: PropTypes.string,
}),
),
filtersChoices: PropTypes.objectOf(
PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
text: PropTypes.string,
filter: PropTypes.string,
metric: PropTypes.number,
}),
),
),
onChange: PropTypes.func,
onFilterMenuOpen: PropTypes.func,
onFilterMenuClose: PropTypes.func,
showDateFilter: PropTypes.bool,
showSqlaTimeGrain: PropTypes.bool,
showSqlaTimeColumn: PropTypes.bool,
};
const defaultProps = {
origSelectedValues: {},
onChange: () => {},
onFilterMenuOpen: () => {},
onFilterMenuClose: () => {},
showDateFilter: false,
showSqlaTimeGrain: false,
showSqlaTimeColumn: false,
instantFiltering: false,
};
const StyledFilterContainer = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
margin-bottom: ${theme.gridUnit * 2 + 2}px;
&:last-child {
margin-bottom: 0;
}
label {
display: flex;
font-weight: ${theme.typography.weights.bold};
}
.filter-badge-container {
width: 30px;
padding-right: ${theme.gridUnit * 2 + 2}px;
}
.filter-badge-container + div {
width: 100%;
}
`}
`;
/**
* @deprecated in version 3.0.
*/
class FilterBox extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
selectedValues: props.origSelectedValues,
// this flag is used by non-instant filter, to make the apply button enabled/disabled
hasChanged: false,
};
this.debouncerCache = {};
this.maxValueCache = {};
this.changeFilter = this.changeFilter.bind(this);
this.onFilterMenuOpen = this.onFilterMenuOpen.bind(this);
this.onOpenDateFilterControl = this.onOpenDateFilterControl.bind(this);
this.onFilterMenuClose = this.onFilterMenuClose.bind(this);
}
onFilterMenuOpen(column) {
return this.props.onFilterMenuOpen(this.props.chartId, column);
}
onFilterMenuClose(column) {
return this.props.onFilterMenuClose(this.props.chartId, column);
}
onOpenDateFilterControl() {
return this.onFilterMenuOpen(TIME_RANGE);
}
onCloseDateFilterControl = () => this.onFilterMenuClose(TIME_RANGE);
getControlData(controlName) {
const { selectedValues } = this.state;
const control = {
...controls[controlName], // TODO: make these controls ('granularity_sqla', 'time_grain_sqla') accessible from getControlsForVizType.
name: controlName,
key: `control-${controlName}`,
value: selectedValues[TIME_FILTER_MAP[controlName]],
actions: { setControlValue: this.changeFilter },
};
const mapFunc = control.mapStateToProps;
return mapFunc ? { ...control, ...mapFunc(this.props) } : control;
}
/**
* Get known max value of a column
*/
getKnownMax(key, choices) {
this.maxValueCache[key] = Math.max(
this.maxValueCache[key] || 0,
d3Max(choices || this.props.filtersChoices[key] || [], x => x.metric),
);
return this.maxValueCache[key];
}
clickApply() {
const { selectedValues } = this.state;
this.setState({ hasChanged: false }, () => {
this.props.onChange(selectedValues, false);
});
}
changeFilter(filter, options) {
const fltr = TIME_FILTER_MAP[filter] || filter;
let vals = null;
if (options !== null) {
if (Array.isArray(options)) {
vals = options.map(opt => (typeof opt === 'string' ? opt : opt.value));
} else if (Object.values(TIME_FILTER_MAP).includes(fltr)) {
vals = options.value ?? options;
} else {
// must use array member for legacy extra_filters's value
vals = ensureIsArray(options.value ?? options);
}
}
this.setState(
prevState => ({
selectedValues: {
...prevState.selectedValues,
[fltr]: vals,
},
hasChanged: true,
}),
() => {
if (this.props.instantFiltering) {
this.props.onChange({ [fltr]: vals }, false);
}
},
);
}
/**
* Generate a debounce function that loads options for a specific column
*/
debounceLoadOptions(key) {
if (!(key in this.debouncerCache)) {
this.debouncerCache[key] = debounce((input, callback) => {
this.loadOptions(key, input).then(callback);
}, SLOW_DEBOUNCE);
}
return this.debouncerCache[key];
}
/**
* Transform select options, add bar background
*/
transformOptions(options, max) {
const maxValue = max === undefined ? d3Max(options, x => x.metric) : max;
return options.map(opt => {
const perc = Math.round((opt.metric / maxValue) * 100);
const color = 'lightgrey';
const backgroundImage = `linear-gradient(to right, ${color}, ${color} ${perc}%, rgba(0,0,0,0) ${perc}%`;
const style = { backgroundImage };
let label = opt.id;
if (label === true) {
label = BOOL_TRUE_DISPLAY;
} else if (label === false) {
label = BOOL_FALSE_DISPLAY;
}
return { value: opt.id, label, style };
});
}
async loadOptions(key, inputValue = '') {
const input = inputValue.toLowerCase();
const sortAsc = this.props.filtersFields.find(x => x.key === key).asc;
const formData = {
...this.props.rawFormData,
adhoc_filters: inputValue
? [
{
clause: 'WHERE',
expressionType: 'SIMPLE',
subject: key,
operator: 'ILIKE',
comparator: `%${input}%`,
},
]
: null,
};
const { json } = await SupersetClient.get({
url: getExploreUrl({
formData,
endpointType: 'json',
method: 'GET',
}),
});
const options = (json?.data?.[key] || []).filter(x => x.id);
if (!options || options.length === 0) {
return [];
}
if (input) {
// sort those starts with search query to front
options.sort((a, b) => {
const labelA = a.id.toLowerCase();
const labelB = b.id.toLowerCase();
const textOrder = labelB.startsWith(input) - labelA.startsWith(input);
return textOrder === 0
? (a.metric - b.metric) * (sortAsc ? 1 : -1)
: textOrder;
});
}
return this.transformOptions(options, this.getKnownMax(key, options));
}
renderDateFilter() {
const { showDateFilter } = this.props;
const label = TIME_FILTER_LABELS.time_range;
if (showDateFilter) {
return (
<div className="row space-1">
<div
className="col-lg-12 col-xs-12"
data-test="date-filter-container"
>
<DateFilterControl
name={TIME_RANGE}
label={label}
description={t('Select start and end date')}
onChange={newValue => {
this.changeFilter(TIME_RANGE, newValue);
}}
onOpenDateFilterControl={this.onOpenDateFilterControl}
onCloseDateFilterControl={this.onCloseDateFilterControl}
value={this.state.selectedValues[TIME_RANGE] || 'No filter'}
endpoints={['inclusive', 'exclusive']}
/>
</div>
</div>
);
}
return null;
}
renderDatasourceFilters() {
const { showSqlaTimeGrain, showSqlaTimeColumn } = this.props;
const datasourceFilters = [];
const sqlaFilters = [];
if (showSqlaTimeGrain) sqlaFilters.push('time_grain_sqla');
if (showSqlaTimeColumn) sqlaFilters.push('granularity_sqla');
if (sqlaFilters.length) {
datasourceFilters.push(
<ControlRow
key="sqla-filters"
controls={sqlaFilters.map(control => (
<Control {...this.getControlData(control)} />
))}
/>,
);
}
return datasourceFilters;
}
renderSelect(filterConfig) {
const { filtersChoices } = this.props;
const { selectedValues } = this.state;
this.debouncerCache = {};
this.maxValueCache = {};
// Add created options to filtersChoices, even though it doesn't exist,
// or these options will exist in query sql but invisible to end user.
Object.keys(selectedValues)
.filter(key => key in filtersChoices)
.forEach(key => {
// empty values are ignored
if (!selectedValues[key]) {
return;
}
const choices = filtersChoices[key] || (filtersChoices[key] = []);
const choiceIds = new Set(choices.map(f => f.id));
const selectedValuesForKey = Array.isArray(selectedValues[key])
? selectedValues[key]
: [selectedValues[key]];
selectedValuesForKey
.filter(value => value !== null && !choiceIds.has(value))
.forEach(value => {
choices.unshift({
filter: key,
id: value,
text: value,
metric: 0,
});
});
});
const {
key,
label,
[FILTER_CONFIG_ATTRIBUTES.MULTIPLE]: isMultiple,
[FILTER_CONFIG_ATTRIBUTES.DEFAULT_VALUE]: defaultValue,
[FILTER_CONFIG_ATTRIBUTES.CLEARABLE]: isClearable,
[FILTER_CONFIG_ATTRIBUTES.SEARCH_ALL_OPTIONS]: searchAllOptions,
} = filterConfig;
const data = filtersChoices[key] || [];
let value = selectedValues[key] || null;
// Assign default value if required
if (value === undefined && defaultValue) {
// multiple values are separated by semicolons
value = isMultiple ? defaultValue.split(';') : defaultValue;
}
return (
<OnPasteSelect
cacheOptions
loadOptions={this.debounceLoadOptions(key)}
defaultOptions={this.transformOptions(data)}
key={key}
placeholder={t('Type or Select [%s]', label)}
isMulti={isMultiple}
isClearable={isClearable}
value={value}
options={this.transformOptions(data)}
onChange={newValue => {
// avoid excessive re-renders
if (newValue !== value) {
this.changeFilter(key, newValue);
}
}}
// TODO try putting this back once react-select is upgraded
// onFocus={() => this.onFilterMenuOpen(key)}
onMenuOpen={() => this.onFilterMenuOpen(key)}
onBlur={() => this.onFilterMenuClose(key)}
onMenuClose={() => this.onFilterMenuClose(key)}
selectWrap={
searchAllOptions && data.length >= FILTER_OPTIONS_LIMIT
? AsyncCreatableSelect
: CreatableSelect
}
noResultsText={t('No results found')}
forceOverflow
/>
);
}
renderFilters() {
const { filtersFields = [] } = this.props;
return filtersFields.map(filterConfig => {
const { label, key } = filterConfig;
return (
<StyledFilterContainer key={key} className="filter-container">
<FormLabel htmlFor={`LABEL-${key}`}>{label}</FormLabel>
{this.renderSelect(filterConfig)}
</StyledFilterContainer>
);
});
}
render() {
const { instantFiltering, width, height } = this.props;
const { zIndex, gridUnit } = this.props.theme;
return (
<>
<Global
styles={css`
.dashboard .filter_box .slice_container > div:not(.alert) {
padding-top: 0;
}
.filter_box {
padding: ${gridUnit * 2 + 2}px 0;
overflow: visible !important;
&:hover {
z-index: ${zIndex.max};
}
}
`}
/>
<div style={{ width, height, overflow: 'auto' }}>
{this.renderDateFilter()}
{this.renderDatasourceFilters()}
{this.renderFilters()}
{!instantFiltering && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.clickApply.bind(this)}
disabled={!this.state.hasChanged}
>
{t('Apply')}
</Button>
)}
</div>
</>
);
}
}
FilterBox.propTypes = propTypes;
FilterBox.defaultProps = defaultProps;
export default withTheme(FilterBox);

View File

@ -1,87 +0,0 @@
/**
* 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 { styledMount as mount } from 'spec/helpers/theming';
import FilterBox from 'src/visualizations/FilterBox/FilterBox';
import SelectControl from 'src/explore/components/controls/SelectControl';
describe('FilterBox', () => {
it('should only add defined non-predefined options to filtersChoices', () => {
const wrapper = mount(
<FilterBox
chartId={1001}
datasource={{ id: 1 }}
filtersChoices={{
name: [
{ id: 'John', text: 'John', metric: 1234 },
{ id: 'Jane', text: 'Jane', metric: 345678 },
],
}}
filtersFields={[
{
asc: false,
clearable: true,
column: 'name',
key: 'name',
label: 'name',
metric: 'sum__COUNT',
multiple: true,
},
]}
origSelectedValues={{}}
/>,
);
const inst = wrapper.find('FilterBox').instance();
// choose a predefined value
inst.setState({ selectedValues: { name: ['John'] } });
expect(inst.props.filtersChoices.name.length).toEqual(2);
// reset selection
inst.setState({ selectedValues: { name: null } });
expect(inst.props.filtersChoices.name.length).toEqual(2);
// Add a new name
inst.setState({ selectedValues: { name: 'James' } });
expect(inst.props.filtersChoices.name.length).toEqual(3);
});
it('should support granularity_sqla options', () => {
const wrapper = mount(
<FilterBox
chartId={1001}
datasource={{
id: 1,
columns: [],
databases: {},
granularity_sqla: [
['created_on', 'created_on'],
['changed_on', 'changed_on'],
],
}}
showSqlaTimeColumn
instantFiltering
/>,
);
expect(wrapper.find(SelectControl).props().choices).toEqual(
expect.arrayContaining([
['created_on', 'created_on'],
['changed_on', 'changed_on'],
]),
);
});
});

View File

@ -1,52 +0,0 @@
/**
* 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 { t, ChartMetadata, ChartPlugin, ChartLabel } from '@superset-ui/core';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import example1 from './images/example1.jpg';
import example2 from './images/example2.jpg';
import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Tools'),
label: ChartLabel.DEPRECATED,
name: t('Filter box (legacy)'),
description:
t(`Chart component that lets you add a custom filter UI in your dashboard. When added to dashboard, a filter box lets users specify specific values or ranges to filter charts by. The charts that each filter box is applied to can be fine tuned as well in the dashboard view.
Note that this plugin is being replaced with the new Filters feature that lives in the dashboard view itself. It's easier to use and has more capabilities!`),
exampleGallery: [{ url: example1 }, { url: example2 }],
thumbnail,
useLegacyApi: true,
tags: [t('Legacy'), t('Deprecated')],
});
/**
* @deprecated in version 3.0.
*/
export default class FilterBoxChartPlugin extends ChartPlugin {
constructor() {
super({
controlPanel,
metadata,
transformProps,
loadChart: () => import('./FilterBox'),
});
}
}

View File

@ -1,103 +0,0 @@
/**
* 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 { t } from '@superset-ui/core';
import { sections } from '@superset-ui/chart-controls';
export default {
controlPanelSections: [
sections.legacyTimeseriesTime,
{
label: t('Filters configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'filter_configs',
config: {
type: 'CollectionControl',
label: t('Filters'),
description: t('Filter configuration for the filter box'),
validators: [],
controlName: 'FilterBoxItemControl',
mapStateToProps: ({ datasource }) => ({ datasource }),
},
},
],
[<hr />],
[
{
name: 'date_filter',
config: {
type: 'CheckboxControl',
label: t('Date filter'),
default: true,
description: t('Whether to include a time filter'),
},
},
],
[
{
name: 'instant_filtering',
config: {
type: 'CheckboxControl',
label: t('Instant filtering'),
renderTrigger: true,
default: false,
description: t(
'Check to apply filters instantly as they change instead of displaying [Apply] button',
),
},
},
],
[
{
name: 'show_sqla_time_granularity',
config: {
type: 'CheckboxControl',
label: t('Show time grain dropdown'),
default: false,
description: t('Check to include time grain dropdown'),
},
},
],
[
{
name: 'show_sqla_time_column',
config: {
type: 'CheckboxControl',
label: t('Show time column'),
default: false,
description: t('Check to include time column dropdown'),
},
},
],
['adhoc_filters'],
],
},
],
controlOverrides: {
adhoc_filters: {
label: t('Limit selector values'),
description: t(
'These filters apply to the values available in the dropdowns',
),
},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,74 +0,0 @@
/**
* 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 { FilterBoxChartProps } from './types';
const NOOP = () => {};
export default function transformProps(chartProps: FilterBoxChartProps) {
const {
datasource,
formData,
hooks,
initialValues,
queriesData,
rawDatasource = {},
rawFormData,
width,
height,
} = chartProps;
const {
onAddFilter = NOOP,
onFilterMenuOpen = NOOP,
onFilterMenuClose = NOOP,
} = hooks;
const {
sliceId,
dateFilter,
instantFiltering,
showSqlaTimeColumn,
showSqlaTimeGranularity,
} = formData;
const { verboseMap = {} } = datasource;
const filterConfigs = formData.filterConfigs || [];
const filtersFields = filterConfigs.map(flt => ({
...flt,
key: flt.column,
label: flt.label || verboseMap[flt.column] || flt.column,
}));
return {
chartId: sliceId,
width,
height,
datasource: rawDatasource,
filtersChoices: queriesData[0].data,
filtersFields,
instantFiltering,
onChange: onAddFilter,
onFilterMenuOpen,
onFilterMenuClose,
origSelectedValues: initialValues || {},
showDateFilter: dateFilter,
showSqlaTimeColumn,
showSqlaTimeGrain: showSqlaTimeGranularity,
// the original form data, needed for async select options
rawFormData,
};
}

View File

@ -1,29 +0,0 @@
/**
* 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 { ChartProps, Datasource } from '@superset-ui/core';
export interface FilterConfig {
column: string;
label: string;
}
export type FilterBoxChartProps = ChartProps & {
datasource?: Datasource;
formData: ChartProps['formData'] & { filterConfigs: FilterConfig[] };
};

View File

@ -76,7 +76,6 @@ import {
} from 'src/filters/components';
import { PivotTableChartPlugin as PivotTableChartPluginV2 } from '@superset-ui/plugin-chart-pivot-table';
import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars';
import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin';
import TimeTableChartPlugin from '../TimeTable';
export default class MainPreset extends Preset {
@ -98,7 +97,6 @@ export default class MainPreset extends Preset {
new CountryMapChartPlugin().configure({ key: 'country_map' }),
new DistBarChartPlugin().configure({ key: 'dist_bar' }),
new EventFlowChartPlugin().configure({ key: 'event_flow' }),
new FilterBoxChartPlugin().configure({ key: 'filter_box' }),
new EchartsFunnelChartPlugin().configure({ key: 'funnel' }),
new EchartsTreemapChartPlugin().configure({ key: 'treemap_v2' }),
new EchartsGaugeChartPlugin().configure({ key: 'gauge_chart' }),

View File

@ -247,7 +247,6 @@ const config = {
'redux',
'react-redux',
'react-hot-loader',
'react-select',
'react-sortable-hoc',
'react-table',
'react-ace',

View File

@ -1,398 +0,0 @@
# 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 json
from copy import deepcopy
from textwrap import dedent
import click
from click_option_group import optgroup, RequiredMutuallyExclusiveOptionGroup
from flask.cli import with_appcontext
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from superset import db, is_feature_enabled
Base = declarative_base()
dashboard_slices = Table(
"dashboard_slices",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("dashboard_id", Integer, ForeignKey("dashboards.id")),
Column("slice_id", Integer, ForeignKey("slices.id")),
)
slice_user = Table(
"slice_user",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("slice_id", Integer, ForeignKey("slices.id")),
)
class Dashboard(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "dashboards"
id = Column(Integer, primary_key=True)
json_metadata = Column(Text)
slices = relationship("Slice", secondary=dashboard_slices, backref="dashboards")
position_json = Column()
def __repr__(self) -> str:
return f"Dashboard<{self.id}>"
class Slice(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "slices"
id = Column(Integer, primary_key=True)
datasource_id = Column(Integer)
params = Column(Text)
slice_name = Column(String(250))
viz_type = Column(String(250))
def __repr__(self) -> str:
return f"Slice<{self.id}>"
@click.group()
def native_filters() -> None:
"""
Perform native filter operations.
"""
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Upgrade all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Upgrade the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def upgrade(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: tuple[int, ...],
) -> None:
"""
Upgrade legacy filter-box charts to native dashboard filters.
"""
# pylint: disable=import-outside-toplevel
from superset.utils.dashboard_filter_scopes_converter import (
convert_filter_scopes_to_native_filters,
)
if not is_feature_enabled("DASHBOARD_NATIVE_FILTERS"):
click.echo("The 'DASHBOARD_NATIVE_FILTERS' feature needs to be enabled.")
return
# Mapping between the CHART- and MARKDOWN- IDs.
mapping = {}
for dashboard in ( # pylint: disable=too-many-nested-blocks
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Upgrading {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
if "native_filter_migration" in json_metadata:
click.echo(f"{dashboard} has already been upgraded")
continue
# Save the native and legacy filter configurations for recovery purposes.
json_metadata["native_filter_migration"] = {
key: deepcopy(json_metadata[key])
for key in (
"default_filters",
"filter_scopes",
"native_filter_configuration",
)
if key in json_metadata
}
filter_boxes_by_id = {
slc.id: slc for slc in dashboard.slices if slc.viz_type == "filter_box"
}
# Convert the legacy filter configurations to native filters.
native_filter_configuration = json_metadata.setdefault(
"native_filter_configuration",
[],
)
native_filter_configuration.extend(
convert_filter_scopes_to_native_filters(
json_metadata,
position_json,
filter_boxes=list(filter_boxes_by_id.values()),
),
)
# Remove the legacy filter configuration.
for key in ["default_filters", "filter_scopes"]:
json_metadata.pop(key, None)
# Replace the filter-box charts with markdown elements.
for key, value in list(position_json.items()): # Immutable iteration
if (
isinstance(value, dict)
and value["type"] == "CHART"
and (meta := value.get("meta"))
and meta["chartId"] in filter_boxes_by_id
):
slc = filter_boxes_by_id[meta["chartId"]]
mapping[key] = key.replace("CHART-", "MARKDOWN-")
value["id"] = mapping[key]
value["type"] = "MARKDOWN"
meta["code"] = dedent(
f"""
&#9888; The <a href="/superset/slice/{slc.id}/">{slc.slice_name}
</a> filter-box chart has been migrated to a native filter.
This placeholder markdown element can be safely removed after
verifying that the native filter(s) have been correctly applied,
otherwise ask an admin to revert the migration.
"""
)
# Save the filter-box info for recovery purposes.
meta["native_filter_migration"] = {
key: meta.pop(key)
for key in (
"chartId",
"sliceName",
"sliceNameOverride",
)
if key in meta
}
position_json[mapping[key]] = value
del position_json[key]
# Replace the relevant CHART- references.
for value in position_json.values():
if isinstance(value, dict):
for relation in ["children", "parents"]:
if relation in value:
for idx, key in enumerate(value[relation]):
if key in mapping:
value[relation][idx] = mapping[key]
# Remove the filter-box charts from the dashboard/slice mapping
dashboard.slices = [
slc for slc in dashboard.slices if slc.viz_type != "filter_box"
]
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to upgrade {str(dashboard)}")
db.session.commit()
db.session.close()
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Downgrade all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Downgrade the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def downgrade(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: tuple[int, ...],
) -> None:
"""
Downgrade native dashboard filters to legacy filter-box charts (where applicable).
"""
# Mapping between the MARKDOWN- and CHART- IDs.
mapping = {}
for dashboard in ( # pylint: disable=too-many-nested-blocks
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Downgrading {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
if "native_filter_migration" not in json_metadata:
click.echo(f"{str(dashboard)} has not been upgraded")
continue
# Restore the native and legacy filter configurations.
for key in (
"default_filters",
"filter_scopes",
"native_filter_configuration",
):
json_metadata.pop(key, None)
json_metadata.update(json_metadata.pop("native_filter_migration"))
# Replace the relevant markdown elements with filter-box charts.
slice_ids = set()
for key, value in list(position_json.items()): # Immutable iteration
if (
isinstance(value, dict)
and value["type"] == "MARKDOWN"
and (meta := value.get("meta"))
and "native_filter_migration" in meta
):
meta.update(meta.pop("native_filter_migration"))
slice_ids.add(meta["chartId"])
mapping[key] = key.replace("MARKDOWN-", "CHART-")
value["id"] = mapping[key]
del meta["code"]
value["type"] = "CHART"
position_json[mapping[key]] = value
del position_json[key]
# Replace the relevant CHART- references.
for value in position_json.values():
if isinstance(value, dict):
for relation in ["children", "parents"]:
if relation in value:
for idx, key in enumerate(value[relation]):
if key in mapping:
value[relation][idx] = mapping[key]
# Restore the filter-box charts to the dashboard/slice mapping.
for slc in db.session.query(Slice).filter(Slice.id.in_(slice_ids)).all():
dashboard.slices.append(slc)
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to downgrade {str(dashboard)}")
db.session.commit()
db.session.close()
@native_filters.command()
@with_appcontext
@optgroup.group(
"Grouped options",
cls=RequiredMutuallyExclusiveOptionGroup,
)
@optgroup.option(
"--all",
"all_",
default=False,
help="Cleanup all dashboards",
is_flag=True,
)
@optgroup.option(
"--id",
"dashboard_ids",
help="Cleanup the specific dashboard. Can be supplied multiple times.",
multiple=True,
type=int,
)
def cleanup(
all_: bool, # pylint: disable=unused-argument
dashboard_ids: tuple[int, ...],
) -> None:
"""
Cleanup obsolete legacy filter-box charts and interim metadata.
Note this operation is irreversible.
"""
slice_ids: set[int] = set()
# Cleanup the dashboard which contains legacy fields used for downgrading.
for dashboard in (
db.session.query(Dashboard)
.filter(*[Dashboard.id.in_(dashboard_ids)] if dashboard_ids else [])
.all()
):
click.echo(f"Cleaning up {str(dashboard)}")
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
# Remove the saved filter configurations.
if "native_filter_migration" in json_metadata:
del json_metadata["native_filter_migration"]
dashboard.json_metadata = json.dumps(json_metadata)
for value in position_json.values():
if (
isinstance(value, dict)
and value["type"] == "MARKDOWN"
and (meta := value.get("meta"))
and "native_filter_migration" in meta
):
slice_ids.add(meta["native_filter_migration"]["chartId"])
del meta["native_filter_migration"]
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
click.echo(f"Unable to cleanup {str(dashboard)}")
# Delete the obsolete filter-box charts associated with the dashboards.
db.session.query(slice_user).filter(slice_user.c.slice_id.in_(slice_ids)).delete()
db.session.query(Slice).filter(Slice.id.in_(slice_ids)).delete()
db.session.commit()
db.session.close()

View File

@ -83,6 +83,10 @@ class ImportChartsCommand(ImportModelsCommand):
# import charts with the correct parent ref
for file_name, config in configs.items():
if file_name.startswith("charts/") and config["dataset_uuid"] in datasets:
# Ignore obsolete filter-box charts.
if config["viz_type"] == "filter_box":
continue
# update datasource id, type, and name
dataset = datasets[config["dataset_uuid"]]
config.update(

View File

@ -29,6 +29,7 @@ from superset.commands.base import BaseCommand
from superset.commands.dataset.importers.v0 import import_dataset
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
from superset.exceptions import DashboardImportException
from superset.migrations.shared.native_filters import migrate_dashboard
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils.dashboard_filter_scopes_converter import (
@ -79,7 +80,7 @@ def import_chart(
def import_dashboard(
# pylint: disable=too-many-locals,too-many-statements
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
dashboard_to_import: Dashboard,
dataset_id_mapping: Optional[dict[int, int]] = None,
import_time: Optional[int] = None,
@ -173,6 +174,7 @@ def import_dashboard(
for slc in db.session.query(Slice).all()
if "remote_id" in slc.params_dict
}
new_slice_ids = []
for slc in slices:
logger.info(
"Importing slice %s from the dashboard: %s",
@ -181,6 +183,7 @@ def import_dashboard(
)
remote_slc = remote_id_slice_map.get(slc.id)
new_slc_id = import_chart(slc, remote_slc, import_time=import_time)
new_slice_ids.append(new_slc_id)
old_to_new_slc_id_dict[slc.id] = new_slc_id
# update json metadata that deals with slice ids
new_slc_id_str = str(new_slc_id)
@ -249,22 +252,21 @@ def import_dashboard(
alter_native_filters(dashboard_to_import)
new_slices = (
if existing_dashboard:
existing_dashboard.override(dashboard_to_import)
else:
db.session.add(dashboard_to_import)
dashboard = existing_dashboard or dashboard_to_import
dashboard.slices = (
db.session.query(Slice)
.filter(Slice.id.in_(old_to_new_slc_id_dict.values()))
.all()
)
if existing_dashboard:
existing_dashboard.override(dashboard_to_import)
existing_dashboard.slices = new_slices
db.session.flush()
return existing_dashboard.id
dashboard_to_import.slices = new_slices
db.session.add(dashboard_to_import)
# Migrate any filter-box charts to native dashboard filters.
migrate_dashboard(dashboard)
db.session.flush()
return dashboard_to_import.id # type: ignore
return dashboard.id
def decode_dashboards(o: dict[str, Any]) -> Any:

View File

@ -37,7 +37,8 @@ from superset.daos.dashboard import DashboardDAO
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.schemas import ImportV1DatasetSchema
from superset.models.dashboard import dashboard_slices
from superset.migrations.shared.native_filters import migrate_dashboard
from superset.models.dashboard import Dashboard, dashboard_slices
class ImportDashboardsCommand(ImportModelsCommand):
@ -105,6 +106,7 @@ class ImportDashboardsCommand(ImportModelsCommand):
}
# import charts with the correct parent ref
charts = []
chart_ids: dict[str, int] = {}
for file_name, config in configs.items():
if (
@ -121,6 +123,7 @@ class ImportDashboardsCommand(ImportModelsCommand):
config["query_context"] = None
chart = import_chart(session, config, overwrite=False)
charts.append(chart)
chart_ids[str(chart.uuid)] = chart.id
# store the existing relationship between dashboards and charts
@ -129,11 +132,13 @@ class ImportDashboardsCommand(ImportModelsCommand):
).fetchall()
# import dashboards
dashboards: list[Dashboard] = []
dashboard_chart_ids: list[tuple[int, int]] = []
for file_name, config in configs.items():
if file_name.startswith("dashboards/"):
config = update_id_refs(config, chart_ids, dataset_info)
dashboard = import_dashboard(session, config, overwrite=overwrite)
dashboards.append(dashboard)
for uuid in find_chart_uuids(config["position"]):
if uuid not in chart_ids:
break
@ -147,3 +152,12 @@ class ImportDashboardsCommand(ImportModelsCommand):
for (dashboard_id, chart_id) in dashboard_chart_ids
]
session.execute(dashboard_slices.insert(), values)
# Migrate any filter-box charts to native dashboard filters.
for dashboard in dashboards:
migrate_dashboard(dashboard)
# Remove all obsolete filter-box charts.
for chart in charts:
if chart.viz_type == "filter_box":
session.delete(chart)

View File

@ -42,6 +42,7 @@ from superset.commands.query.importers.v1.utils import import_saved_query
from superset.dashboards.schemas import ImportV1DashboardSchema
from superset.databases.schemas import ImportV1DatabaseSchema
from superset.datasets.schemas import ImportV1DatasetSchema
from superset.migrations.shared.native_filters import migrate_dashboard
from superset.models.dashboard import dashboard_slices
from superset.queries.saved_queries.schemas import ImportV1SavedQuerySchema
@ -106,6 +107,7 @@ class ImportAssetsCommand(BaseCommand):
}
# import charts
charts = []
chart_ids: dict[str, int] = {}
for file_name, config in configs.items():
if file_name.startswith("charts/"):
@ -117,6 +119,7 @@ class ImportAssetsCommand(BaseCommand):
if "query_context" in config:
config["query_context"] = None
chart = import_chart(session, config, overwrite=True)
charts.append(chart)
chart_ids[str(chart.uuid)] = chart.id
# import dashboards
@ -144,6 +147,14 @@ class ImportAssetsCommand(BaseCommand):
)
session.execute(insert(dashboard_slices).values(dashboard_chart_ids))
# Migrate any filter-box charts to native dashboard filters.
migrate_dashboard(dashboard)
# Remove all obsolete filter-box charts.
for chart in charts:
if chart.viz_type == "filter_box":
session.delete(chart)
def run(self) -> None:
self.validate()

View File

@ -0,0 +1,338 @@
import json
from collections import defaultdict
from textwrap import dedent
from typing import Any
from shortid import ShortId
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils.dashboard_filter_scopes_converter import convert_filter_scopes
def convert_filter_scopes_to_native_filters( # pylint: disable=invalid-name,too-many-branches,too-many-locals,too-many-nested-blocks,too-many-statements
json_metadata: dict[str, Any],
position_json: dict[str, Any],
filter_boxes: list[Slice],
) -> list[dict[str, Any]]:
"""
Convert the legacy filter scopes et al. to the native filter configuration.
Dashboard filter scopes are implicitly defined where an undefined scope implies
no immunity, i.e., they apply to all applicable charts. The `convert_filter_scopes`
method provides an explicit definition by extracting the underlying filter-box
configurations.
Hierarchical legacy filters are defined via non-exclusion of peer or children
filter-box charts whereas native hierarchical filters are defined via explicit
parental relationships, i.e., the inverse.
:param json_metadata: The dashboard metadata
:param position_json: The dashboard layout
:param filter_boxes: The filter-box charts associated with the dashboard
:returns: The native filter configuration
:see: convert_filter_scopes
"""
shortid = ShortId()
default_filters = json.loads(json_metadata.get("default_filters") or "{}")
filter_scopes = json_metadata.get("filter_scopes", {})
filter_box_ids = {filter_box.id for filter_box in filter_boxes}
filter_scope_by_key_and_field: dict[str, dict[str, dict[str, Any]]] = defaultdict(
dict
)
filter_by_key_and_field: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
# Dense representation of filter scopes, falling back to chart level filter configs
# if the respective filter scope is not defined at the dashboard level.
for filter_box in filter_boxes:
key = str(filter_box.id)
filter_scope_by_key_and_field[key] = {
**(
convert_filter_scopes(
json_metadata,
filter_boxes=[filter_box],
).get(filter_box.id, {})
),
**(filter_scopes.get(key, {})),
}
# Construct the native filters.
for filter_box in filter_boxes:
key = str(filter_box.id)
params = json.loads(filter_box.params or "{}")
for field, filter_scope in filter_scope_by_key_and_field[key].items():
default = default_filters.get(key, {}).get(field)
fltr: dict[str, Any] = {
"cascadeParentIds": [],
"id": f"NATIVE_FILTER-{shortid.generate()}",
"scope": {
"rootPath": filter_scope["scope"],
"excluded": [
id_
for id_ in filter_scope["immune"]
if id_ not in filter_box_ids
],
},
"type": "NATIVE_FILTER",
}
if field == "__time_col" and params.get("show_sqla_time_column"):
fltr.update(
{
"filterType": "filter_timecolumn",
"name": "Time Column",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("granularity_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"granularity_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_grain" and params.get("show_sqla_time_granularity"):
fltr.update(
{
"filterType": "filter_timegrain",
"name": "Time Grain",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("time_grain_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"time_grain_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_range" and params.get("date_filter"):
fltr.update(
{
"filterType": "filter_time",
"name": "Time Range",
"targets": [{}],
}
)
if not default:
default = params.get("time_range")
if default and default != "No filter":
fltr["defaultDataMask"] = {
"extraFormData": {"time_range": default},
"filterState": {"value": default},
}
else:
for config in params.get("filter_configs") or []:
if config["column"] == field:
fltr.update(
{
"controlValues": {
"defaultToFirstItem": False,
"enableEmptyFilter": not config.get(
"clearable",
True,
),
"inverseSelection": False,
"multiSelect": config.get(
"multiple",
False,
),
"searchAllOptions": config.get(
"searchAllOptions",
False,
),
},
"filterType": "filter_select",
"name": config.get("label") or field,
"targets": [
{
"column": {"name": field},
"datasetId": filter_box.datasource_id,
},
],
}
)
if "metric" in config:
fltr["sortMetric"] = config["metric"]
fltr["controlValues"]["sortAscending"] = config["asc"]
if params.get("adhoc_filters"):
fltr["adhoc_filters"] = params["adhoc_filters"]
# Pre-filter available values based on time range/column.
time_range = params.get("time_range")
if time_range and time_range != "No filter":
fltr.update(
{
"time_range": time_range,
"granularity_sqla": params.get("granularity_sqla"),
}
)
if not default:
default = config.get("defaultValue")
if default and config["multiple"]:
default = default.split(";")
if default:
if not isinstance(default, list):
default = [default]
fltr["defaultDataMask"] = {
"extraFormData": {
"filters": [
{
"col": field,
"op": "IN",
"val": default,
}
],
},
"filterState": {"value": default},
}
break
if "filterType" in fltr:
filter_by_key_and_field[key][field] = fltr
# Ancestors of filter-box charts.
ancestors_by_id = defaultdict(set)
for filter_box in filter_boxes:
for value in position_json.values():
try:
if (
isinstance(value, dict)
and value["type"] == "CHART"
and value["meta"]["chartId"] == filter_box.id
and value["parents"] # Misnomer as this the complete ancestry.
):
ancestors_by_id[filter_box.id] = set(value["parents"])
except KeyError:
pass
# Wire up the hierarchical filters.
for this in filter_boxes:
for other in filter_boxes:
if (
this != other
and any( # Immunity is at the chart rather than field level.
this.id not in filter_scope["immune"]
and set(filter_scope["scope"]) <= ancestors_by_id[this.id]
for filter_scope in filter_scope_by_key_and_field[
str(other.id)
].values()
)
):
for child in filter_by_key_and_field[str(this.id)].values():
if child["filterType"] == "filter_select":
for parent in filter_by_key_and_field[str(other.id)].values():
if (
parent["filterType"] in {"filter_select", "filter_time"}
and parent["id"] not in child["cascadeParentIds"]
):
child["cascadeParentIds"].append(parent["id"])
return sorted(
[
fltr
for key in filter_by_key_and_field
for fltr in filter_by_key_and_field[key].values()
],
key=lambda fltr: fltr["filterType"],
)
def migrate_dashboard(dashboard: Dashboard) -> None:
"""
Convert the dashboard to use native filters.
:param dashboard: The dashboard to convert
"""
# Mapping between the CHART- and MARKDOWN- IDs.
mapping = {}
try:
json_metadata = json.loads(dashboard.json_metadata or "{}")
position_json = json.loads(dashboard.position_json or "{}")
filter_boxes_by_id = {
slc.id: slc for slc in dashboard.slices if slc.viz_type == "filter_box"
}
# Convert the legacy filter configurations to native filters.
native_filter_configuration = json_metadata.setdefault(
"native_filter_configuration",
[],
)
native_filter_configuration.extend(
convert_filter_scopes_to_native_filters(
json_metadata,
position_json,
filter_boxes=list(filter_boxes_by_id.values()),
),
)
# Remove the legacy filter configuration.
for key in ["default_filters", "filter_scopes"]:
json_metadata.pop(key, None)
# Replace the filter-box charts with markdown elements.
for key, value in list(position_json.items()): # Immutable iteration
if (
isinstance(value, dict)
and value["type"] == "CHART"
and (meta := value.get("meta"))
and meta["chartId"] in filter_boxes_by_id
):
slc = filter_boxes_by_id[meta["chartId"]]
mapping[key] = key.replace("CHART-", "MARKDOWN-")
value["id"] = mapping[key]
value["type"] = "MARKDOWN"
meta["code"] = dedent(
f"""
&#9888; The <a href="/superset/slice/{slc.id}/">{slc.slice_name}
</a> filter-box chart has been migrated to a native filter.
"""
)
position_json[mapping[key]] = value
del position_json[key]
# Replace the relevant CHART- references.
for value in position_json.values():
if isinstance(value, dict):
for relation in ["children", "parents"]:
if relation in value:
for idx, key in enumerate(value[relation]):
if key in mapping:
value[relation][idx] = mapping[key]
# Remove the filter-box charts from the dashboard/slice mapping.
dashboard.slices = [
slc for slc in dashboard.slices if slc.viz_type != "filter_box"
]
dashboard.json_metadata = json.dumps(json_metadata)
dashboard.position_json = json.dumps(position_json)
except Exception: # pylint: disable=broad-except
print(f"Unable to upgrade {str(dashboard)}")

View File

@ -0,0 +1,85 @@
# 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.
"""migrate_filter_boxes_to_native_filters
Revision ID: 214f580d09c9
Revises: a32e0c4d8646
Create Date: 2024-01-10 09:20:32.233912
"""
# revision identifiers, used by Alembic.
revision = "214f580d09c9"
down_revision = "a32e0c4d8646"
from alembic import op
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from superset import db
from superset.migrations.shared.native_filters import migrate_dashboard
from superset.migrations.shared.utils import paginated_update
Base = declarative_base()
dashboard_slices = Table(
"dashboard_slices",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("dashboard_id", Integer, ForeignKey("dashboards.id")),
Column("slice_id", Integer, ForeignKey("slices.id")),
)
class Dashboard(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "dashboards"
id = Column(Integer, primary_key=True)
json_metadata = Column(Text)
slices = relationship("Slice", secondary=dashboard_slices, backref="dashboards")
position_json = Column()
def __repr__(self) -> str:
return f"Dashboard<{self.id}>"
class Slice(Base): # type: ignore # pylint: disable=too-few-public-methods
__tablename__ = "slices"
id = Column(Integer, primary_key=True)
datasource_id = Column(Integer)
params = Column(Text)
slice_name = Column(String(250))
viz_type = Column(String(250))
def __repr__(self) -> str:
return f"Slice<{self.id}>"
def upgrade():
session = db.Session(bind=op.get_bind())
for dashboard in paginated_update(session.query(Dashboard)):
migrate_dashboard(dashboard)
# Delete the obsolete filter-box charts.
session.query(Slice).filter(Slice.viz_type == "filter_box").delete()
session.commit()
def downgrade():
pass

View File

@ -1058,7 +1058,7 @@ def merge_extra_form_data(form_data: dict[str, Any]) -> None:
def merge_extra_filters(form_data: dict[str, Any]) -> None:
# extra_filters are temporary/contextual filters (using the legacy constructs)
# that are external to the slice definition. We use those for dynamic
# interactive filters like the ones emitted by the "Filter Box" visualization.
# interactive filters.
# Note extra_filters only support simple filters.
form_data.setdefault("applied_time_extras", {})
adhoc_filters = form_data.get("adhoc_filters", [])

View File

@ -19,8 +19,6 @@ import logging
from collections import defaultdict
from typing import Any
from shortid import ShortId
from superset.models.slice import Slice
logger = logging.getLogger(__name__)
@ -90,252 +88,3 @@ def copy_filter_scopes(
if int(slice_id) in old_to_new_slc_id_dict
]
return new_filter_scopes
def convert_filter_scopes_to_native_filters( # pylint: disable=invalid-name,too-many-branches,too-many-locals,too-many-nested-blocks,too-many-statements
json_metadata: dict[str, Any],
position_json: dict[str, Any],
filter_boxes: list[Slice],
) -> list[dict[str, Any]]:
"""
Convert the legacy filter scopes et al. to the native filter configuration.
Dashboard filter scopes are implicitly defined where an undefined scope implies
no immunity, i.e., they apply to all applicable charts. The `convert_filter_scopes`
method provides an explicit definition by extracting the underlying filter-box
configurations.
Hierarchical legacy filters are defined via non-exclusion of peer or children
filter-box charts whereas native hierarchical filters are defined via explicit
parental relationships, i.e., the inverse.
:param json_metadata: The dashboard metadata
:param position_json: The dashboard layout
:param filter_boxes: The filter-box charts associated with the dashboard
:returns: The native filter configuration
:see: convert_filter_scopes
"""
shortid = ShortId()
default_filters = json.loads(json_metadata.get("default_filters") or "{}")
filter_scopes = json_metadata.get("filter_scopes", {})
filter_box_ids = {filter_box.id for filter_box in filter_boxes}
filter_scope_by_key_and_field: dict[str, dict[str, dict[str, Any]]] = defaultdict(
dict
)
filter_by_key_and_field: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
# Dense representation of filter scopes, falling back to chart level filter configs
# if the respective filter scope is not defined at the dashboard level.
for filter_box in filter_boxes:
key = str(filter_box.id)
filter_scope_by_key_and_field[key] = {
**(
convert_filter_scopes(
json_metadata,
filter_boxes=[filter_box],
).get(filter_box.id, {})
),
**(filter_scopes.get(key, {})),
}
# Construct the native filters.
for filter_box in filter_boxes:
key = str(filter_box.id)
params = json.loads(filter_box.params or "{}")
for field, filter_scope in filter_scope_by_key_and_field[key].items():
default = default_filters.get(key, {}).get(field)
fltr: dict[str, Any] = {
"cascadeParentIds": [],
"id": f"NATIVE_FILTER-{shortid.generate()}",
"scope": {
"rootPath": filter_scope["scope"],
"excluded": [
id_
for id_ in filter_scope["immune"]
if id_ not in filter_box_ids
],
},
"type": "NATIVE_FILTER",
}
if field == "__time_col" and params.get("show_sqla_time_column"):
fltr.update(
{
"filterType": "filter_timecolumn",
"name": "Time Column",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("granularity_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"granularity_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_grain" and params.get("show_sqla_time_granularity"):
fltr.update(
{
"filterType": "filter_timegrain",
"name": "Time Grain",
"targets": [{"datasetId": filter_box.datasource_id}],
}
)
if not default:
default = params.get("time_grain_sqla")
if default:
fltr["defaultDataMask"] = {
"extraFormData": {"time_grain_sqla": default},
"filterState": {"value": [default]},
}
elif field == "__time_range" and params.get("date_filter"):
fltr.update(
{
"filterType": "filter_time",
"name": "Time Range",
"targets": [{}],
}
)
if not default:
default = params.get("time_range")
if default and default != "No filter":
fltr["defaultDataMask"] = {
"extraFormData": {"time_range": default},
"filterState": {"value": default},
}
else:
for config in params.get("filter_configs") or []:
if config["column"] == field:
fltr.update(
{
"controlValues": {
"defaultToFirstItem": False,
"enableEmptyFilter": not config.get(
"clearable",
True,
),
"inverseSelection": False,
"multiSelect": config.get(
"multiple",
False,
),
"searchAllOptions": config.get(
"searchAllOptions",
False,
),
},
"filterType": "filter_select",
"name": config.get("label") or field,
"targets": [
{
"column": {"name": field},
"datasetId": filter_box.datasource_id,
},
],
}
)
if "metric" in config:
fltr["sortMetric"] = config["metric"]
fltr["controlValues"]["sortAscending"] = config["asc"]
if params.get("adhoc_filters"):
fltr["adhoc_filters"] = params["adhoc_filters"]
# Pre-filter available values based on time range/column.
time_range = params.get("time_range")
if time_range and time_range != "No filter":
fltr.update(
{
"time_range": time_range,
"granularity_sqla": params.get("granularity_sqla"),
}
)
if not default:
default = config.get("defaultValue")
if default and config["multiple"]:
default = default.split(";")
if default:
if not isinstance(default, list):
default = [default]
fltr["defaultDataMask"] = {
"extraFormData": {
"filters": [
{
"col": field,
"op": "IN",
"val": default,
}
],
},
"filterState": {"value": default},
}
break
if "filterType" in fltr:
filter_by_key_and_field[key][field] = fltr
# Ancestors of filter-box charts.
ancestors_by_id = defaultdict(set)
for filter_box in filter_boxes:
for value in position_json.values():
try:
if (
isinstance(value, dict)
and value["type"] == "CHART"
and value["meta"]["chartId"] == filter_box.id
and value["parents"] # Misnomer as this the complete ancestry.
):
ancestors_by_id[filter_box.id] = set(value["parents"])
except KeyError:
pass
# Wire up the hierarchical filters.
for this in filter_boxes:
for other in filter_boxes:
if (
this != other
and any( # Immunity is at the chart rather than field level.
this.id not in filter_scope["immune"]
and set(filter_scope["scope"]) <= ancestors_by_id[this.id]
for filter_scope in filter_scope_by_key_and_field[
str(other.id)
].values()
)
):
for child in filter_by_key_and_field[str(this.id)].values():
if child["filterType"] == "filter_select":
for parent in filter_by_key_and_field[str(other.id)].values():
if (
parent["filterType"] in {"filter_select", "filter_time"}
and parent["id"] not in child["cascadeParentIds"]
):
child["cascadeParentIds"].append(parent["id"])
return sorted(
[
fltr
for key in filter_by_key_and_field
for fltr in filter_by_key_and_field[key].values()
],
key=lambda fltr: fltr["filterType"],
)

View File

@ -1568,85 +1568,6 @@ class WorldMapViz(BaseViz):
return data
class FilterBoxViz(BaseViz):
"""A multi filter, multi-choice filter box to make dashboards interactive"""
query_context_factory: QueryContextFactory | None = None
viz_type = "filter_box"
verbose_name = _("Filters")
is_timeseries = False
credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original'
cache_type = "get_data"
filter_row_limit = 1000
@deprecated(deprecated_in="3.0")
def query_obj(self) -> QueryObjectDict:
return {}
@deprecated(deprecated_in="3.0")
def run_extra_queries(self) -> None:
query_obj = super().query_obj()
filters = self.form_data.get("filter_configs") or []
query_obj["row_limit"] = self.filter_row_limit
self.dataframes = {} # pylint: disable=attribute-defined-outside-init
for flt in filters:
col = flt.get("column")
if not col:
raise QueryObjectValidationError(
_("Invalid filter configuration, please select a column")
)
query_obj["groupby"] = [col]
metric = flt.get("metric")
query_obj["metrics"] = [metric] if metric else []
asc = flt.get("asc")
if metric and asc is not None:
query_obj["orderby"] = [(metric, asc)]
self.get_query_context_factory().create(
datasource={"id": self.datasource.id, "type": self.datasource.type},
form_data=self.form_data,
queries=[query_obj],
).raise_for_access()
df = self.get_df_payload(query_obj=query_obj).get("df")
self.dataframes[col] = df
@deprecated(deprecated_in="3.0")
def get_data(self, df: pd.DataFrame) -> VizData:
filters = self.form_data.get("filter_configs") or []
data = {}
for flt in filters:
col = flt.get("column")
metric = flt.get("metric")
df = self.dataframes.get(col)
if df is not None and not df.empty:
if metric:
df = df.sort_values(
utils.get_metric_name(metric), ascending=flt.get("asc", False)
)
data[col] = [
{"id": row[0], "text": row[0], "metric": row[1]}
for row in df.itertuples(index=False)
]
else:
df = df.sort_values(col, ascending=flt.get("asc", False))
data[col] = [
{"id": row[0], "text": row[0]}
for row in df.itertuples(index=False)
]
else:
data[col] = []
return data
@deprecated(deprecated_in="3.0")
def get_query_context_factory(self) -> QueryContextFactory:
if self.query_context_factory is None:
# pylint: disable=import-outside-toplevel
from superset.common.query_context_factory import QueryContextFactory
self.query_context_factory = QueryContextFactory()
return self.query_context_factory
class ParallelCoordinatesViz(BaseViz):
"""Interactive parallel coordinate implementation

View File

@ -125,14 +125,9 @@ class TestImportAssetsCommand(SupersetTestCase):
}
assert json.loads(dashboard.json_metadata) == {
"color_scheme": None,
"default_filters": "{}",
"expanded_slices": {str(new_chart_id): True},
"filter_scopes": {
str(new_chart_id): {
"region": {"scope": ["ROOT_ID"], "immune": [new_chart_id]}
},
},
"import_time": 1604342885,
"native_filter_configuration": [],
"refresh_frequency": 0,
"remote_id": 7,
"timed_refresh_immune_slices": [new_chart_id],

View File

@ -551,14 +551,9 @@ class TestImportDashboardsCommand(SupersetTestCase):
}
assert json.loads(dashboard.json_metadata) == {
"color_scheme": None,
"default_filters": "{}",
"expanded_slices": {str(new_chart_id): True},
"filter_scopes": {
str(new_chart_id): {
"region": {"scope": ["ROOT_ID"], "immune": [new_chart_id]}
},
},
"import_time": 1604342885,
"native_filter_configuration": [],
"refresh_frequency": 0,
"remote_id": 7,
"timed_refresh_immune_slices": [new_chart_id],

View File

@ -381,7 +381,11 @@ class TestImportExport(SupersetTestCase):
expected_dash, imported_dash, check_position=False, check_slugs=False
)
self.assertEqual(
{"remote_id": 10002, "import_time": 1990},
{
"remote_id": 10002,
"import_time": 1990,
"native_filter_configuration": [],
},
json.loads(imported_dash.json_metadata),
)
@ -411,7 +415,7 @@ class TestImportExport(SupersetTestCase):
f"{e_slc.id}": True,
f"{b_slc.id}": False,
},
# mocked filter_scope metadata
# mocked legacy filter_scope metadata
"filter_scopes": {
str(e_slc.id): {
"region": {"scope": ["ROOT_ID"], "immune": [b_slc.id]}
@ -435,15 +439,11 @@ class TestImportExport(SupersetTestCase):
expected_json_metadata = {
"remote_id": 10003,
"import_time": 1991,
"filter_scopes": {
str(i_e_slc.id): {
"region": {"scope": ["ROOT_ID"], "immune": [i_b_slc.id]}
}
},
"expanded_slices": {
f"{i_e_slc.id}": True,
f"{i_b_slc.id}": False,
},
"native_filter_configuration": [],
}
self.assertEqual(
expected_json_metadata, json.loads(imported_dash.json_metadata)
@ -489,7 +489,11 @@ class TestImportExport(SupersetTestCase):
expected_dash, imported_dash, check_position=False, check_slugs=False
)
self.assertEqual(
{"remote_id": 10004, "import_time": 1992},
{
"remote_id": 10004,
"import_time": 1992,
"native_filter_configuration": [],
},
json.loads(imported_dash.json_metadata),
)
@ -517,6 +521,7 @@ class TestImportExport(SupersetTestCase):
self.assertEqual(imported_slc.changed_by, gamma_user)
self.assertEqual(imported_slc.owners, [gamma_user])
@pytest.mark.skip
def test_import_override_dashboard_slice_reset_ownership(self):
admin_user = security_manager.find_user(username="admin")
self.assertTrue(admin_user)
@ -539,7 +544,6 @@ class TestImportExport(SupersetTestCase):
# re-import with another user shouldn't change the permissions
g.user = admin_user
dash_with_1_slice = self._create_dashboard_for_import(id_=10300)
imported_dash_id = import_dashboard(dash_with_1_slice)

View File

@ -759,7 +759,7 @@ class TestUtils(SupersetTestCase):
def test_merge_extra_filters_with_unset_legacy_time_range(self):
"""
Make sure native filter is applied if filter box time range is unset.
Make sure native filter is applied if filter time range is unset.
"""
form_data = {
"time_range": "Last 10 days",
@ -778,28 +778,6 @@ class TestUtils(SupersetTestCase):
},
)
def test_merge_extra_filters_with_conflicting_time_ranges(self):
"""
Make sure filter box takes precedence if both native filter and filter box
time ranges are set.
"""
form_data = {
"time_range": "Last 10 days",
"extra_filters": [{"col": "__time_range", "op": "==", "val": "Last week"}],
"extra_form_data": {
"time_range": "Last year",
},
}
merge_extra_filters(form_data)
self.assertEqual(
form_data,
{
"time_range": "Last week",
"applied_time_extras": {"__time_range": "Last week"},
"adhoc_filters": [],
},
)
def test_merge_extra_filters_with_extras(self):
form_data = {
"time_range": "Last 10 days",

View File

@ -1105,70 +1105,3 @@ class TestTimeSeriesViz(SupersetTestCase):
)
with pytest.raises(QueryObjectValidationError):
test_viz.apply_rolling(df)
class TestFilterBoxViz(SupersetTestCase):
def test_get_data(self):
form_data = {
"filter_configs": [
{"column": "value1", "metric": "metric1"},
{"column": "value2", "metric": "metric2", "asc": True},
{"column": "value3"},
{"column": "value4", "asc": True},
{"column": "value5"},
{"column": "value6"},
],
}
datasource = self.get_datasource_mock()
test_viz = viz.FilterBoxViz(datasource, form_data)
test_viz.dataframes = {
"value1": pd.DataFrame(
data=[
{"value1": "v1", "metric1": 1},
{"value1": "v2", "metric1": 2},
]
),
"value2": pd.DataFrame(
data=[
{"value2": "v3", "metric2": 3},
{"value2": "v4", "metric2": 4},
]
),
"value3": pd.DataFrame(
data=[
{"value3": "v5"},
{"value3": "v6"},
]
),
"value4": pd.DataFrame(
data=[
{"value4": "v7"},
{"value4": "v8"},
]
),
"value5": pd.DataFrame(),
}
df = pd.DataFrame()
data = test_viz.get_data(df)
expected = {
"value1": [
{"id": "v2", "text": "v2", "metric": 2},
{"id": "v1", "text": "v1", "metric": 1},
],
"value2": [
{"id": "v3", "text": "v3", "metric": 3},
{"id": "v4", "text": "v4", "metric": 4},
],
"value3": [
{"id": "v6", "text": "v6"},
{"id": "v5", "text": "v5"},
],
"value4": [
{"id": "v7", "text": "v7"},
{"id": "v8", "text": "v8"},
],
"value5": [],
"value6": [],
}
self.assertEqual(expected, data)